4. 任务生命周期管理
UDP 这东西,说白了就是“发出去就不管了”。没有三次握手,没有重传机制,也没有流量控制。但正因为简单,它的延迟极低,非常适合实时音视频、游戏同步、传感器数据采集这些场景。我这些年做过的项目里,UDP 用得最多的反而是工业现场的数据采集——几百个传感器同时往服务器丢数据,用 TCP 根本扛不住连接数。
这一章,咱们就把 QUdpSocket 的底裤扒干净。广播怎么发?组播怎么加?丢包了怎么办?乱序了怎么排?一个一个来。
4.1 QUdpSocket 基础用法
QUdpSocket 的 API 比 QTcpSocket 少得多,核心就几个函数:bind()、writeDatagram()、readDatagram()、readyRead 信号。但越简单的 API,越容易踩坑。
4.1.1 最简单的 UDP 接收端
// 接收端
QUdpSocket *udpReceiver = new QUdpSocket(this);
if (!udpReceiver->bind(QHostAddress::AnyIPv4, 8888)) {
qWarning() << "绑定端口失败:" << udpReceiver->errorString();
return;
}
connect(udpReceiver, &QUdpSocket::readyRead, this, [=]() {
while (udpReceiver->hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(udpReceiver->pendingDatagramSize());
QHostAddress sender;
quint16 senderPort;
udpReceiver->readDatagram(datagram.data(), datagram.size(),
&sender, &senderPort);
qDebug() << "收到来自" << sender.toString() << ":" << senderPort
<< "的数据:" << datagram.toHex();
}
});
readDatagram,但 readyReadwhile (hasPendingDatagrams()) 循环读完,否则会丢包。我见过不止一个项目因为这个原因丢掉了 30% 的数据。
4.1.2 发送端
// 发送端
QUdpSocket *udpSender = new QUdpSocket(this);
QByteArray data = QByteArray::fromHex("AABBCCDD");
qint64 sent = udpSender->writeDatagram(data, QHostAddress("192.168.1.100"), 8888);
if (sent == -1) {
qWarning() << "发送失败:" << udpSender->errorString();
}
这里有个细节:writeDatagram 返回 -1 表示发送失败,但 UDP 的“发送成功”只代表数据交给了协议栈,不代表对端收到了。你想想看,如果网络断了,发送端根本不知道。
4.2 广播与组播
UDP 最吸引人的特性就是一对多通信。广播和组播虽然都是“发一次,多台收”,但适用范围完全不同。
| 特性 | 广播 (Broadcast) | 组播 (Multicast) |
|---|---|---|
| 目标地址 | 255.255.255.255 或子网广播地址 | 224.0.0.0 ~ 239.255.255.255 |
| 范围 | 同一子网内所有设备 | 加入组播组的设备 |
| 路由器转发 | 不转发 | 支持 IGMP 协议时可跨网段 |
| 性能影响 | 子网内所有设备都要处理,包括不相关的 | 只有订阅者处理 |
| 典型场景 | 局域网设备发现、DHCP | 视频会议、实时行情分发 |
4.2.1 广播发送与接收
发送广播很简单,把目标地址设为 QHostAddress::Broadcast 就行。但有个坑——Windows 上默认不允许广播,需要先设置 SocketOption。
// 广播发送端
QUdpSocket *broadcastSender = new QUdpSocket(this);
broadcastSender->setSocketOption(QAbstractSocket::BroadcastSocketOption, 1);
QByteArray msg = "DISCOVER_REQUEST";
broadcastSender->writeDatagram(msg, QHostAddress::Broadcast, 9999);
// 广播接收端
QUdpSocket *broadcastReceiver = new QUdpSocket(this);
// 绑定到特定端口,地址用 AnyIPv4
broadcastReceiver->bind(QHostAddress::AnyIPv4, 9999);
// 剩下的和普通 UDP 接收一样
QHostAddress::AnyIPv4 而不是 QHostAddress::LocalHost。后者只能收到本机回环的广播,收不到局域网其他设备的广播。我刚开始做设备发现协议时就栽在这个坑里,折腾了半天才发现是地址绑错了。
4.2.2 组播:加入与离开
组播比广播稍微复杂一点,需要先加入组播组。Qt 提供了 joinMulticastGroup() 和 leaveMulticastGroup()。
// 组播接收端
QUdpSocket *multiReceiver = new QUdpSocket(this);
multiReceiver->bind(QHostAddress::AnyIPv4, 10000);
// 加入组播组 239.0.0.1
if (!multiReceiver->joinMulticastGroup(QHostAddress("239.0.0.1"))) {
qWarning() << "加入组播组失败";
}
connect(multiReceiver, &QUdpSocket::readyRead, this, [=]() {
while (multiReceiver->hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(multiReceiver->pendingDatagramSize());
multiReceiver->readDatagram(datagram.data(), datagram.size());
qDebug() << "组播收到:" << datagram;
}
});
// 组播发送端
QUdpSocket *multiSender = new QUdpSocket(this);
QByteArray data = "MULTICAST_DATA";
// 目标地址是组播地址,不是接收端单播地址
multiSender->writeDatagram(data, QHostAddress("239.0.0.1"), 10000);
bind(),也不需要 joinMulticastGroup()。只有接收端才需要加入组播组。另外,如果接收端和发送端在同一台机器上,有些系统(比如 Linux)默认不会把组播数据回环到本机。需要调用 multiReceiver->setSocketOption(QAbstractSocket::MulticastLoopbackOption, 1) 开启回环。
4.3 UDP 丢包与乱序处理
UDP 不保证可靠传输,这是它的基因缺陷。但很多场景下,我们不能接受数据丢失或顺序错乱。怎么办?自己动手,在应用层做可靠性。
4.3.1 丢包的原因分析
丢包不是随机的,通常有这几个原因:
- 接收缓冲区溢出: 应用层读取速度跟不上网络层接收速度,内核缓冲区满了就丢。
- MTU 分片丢失: 单个数据报超过 MTU(通常 1500 字节),IP 层分片后只要一片丢了,整个数据报就废了。
- 防火墙/路由器丢弃: 某些网络设备会限制 UDP 流量。
- 接收端处理不过来:
readyRead信号触发太频繁,事件循环来不及处理。
4.3.2 序列号与确认机制
最简单的防丢包方案:给每个数据报加一个递增的序列号。接收端检查序列号是否连续,不连续就说明丢了。
// 发送端封装
struct UdpPacket {
quint32 seq; // 序列号
quint32 timestamp; // 时间戳
QByteArray payload;
};
// 发送时
static quint32 g_seq = 0;
UdpPacket pkt;
pkt.seq = g_seq++;
pkt.timestamp = QDateTime::currentMSecsSinceEpoch();
pkt.payload = data;
QByteArray buffer;
QDataStream stream(&buffer, QIODevice::WriteOnly);
stream << pkt.seq << pkt.timestamp << pkt.payload;
udpSender->writeDatagram(buffer, targetAddr, targetPort);
// 接收端解析
void onDataReceived(const QByteArray &rawData) {
QDataStream stream(rawData);
quint32 seq, timestamp;
QByteArray payload;
stream >> seq >> timestamp >> payload;
// 检查序列号连续性
static quint32 lastSeq = 0;
if (lastSeq != 0 && seq != lastSeq + 1) {
qint32 lostCount = seq - lastSeq - 1;
qWarning() << "检测到丢包:" << lostCount << "个包丢失";
// 这里可以触发重传请求
}
lastSeq = seq;
// 处理 payload
processPayload(payload);
}
4.3.3 乱序重排
UDP 数据报在网络中走不同的路径,先发的可能后到。乱序处理的核心是:不立即处理,而是先缓存,等序列号连续了再交给上层。
// 乱序重排缓冲区
class ReorderBuffer {
public:
void addPacket(quint32 seq, const QByteArray &data) {
buffer[seq] = data;
tryDeliver();
}
private:
QMap<quint32, QByteArray> buffer; // 按序列号自动排序
quint32 expectedSeq = 1;
void tryDeliver() {
while (buffer.contains(expectedSeq)) {
QByteArray data = buffer.take(expectedSeq);
emit packetReady(data); // 交给上层处理
expectedSeq++;
}
}
};
这里用 QMap 而不是 QHash,因为 QMap 按键排序,天然适合按序列号顺序处理。乱序窗口大小要有限制,否则内存会爆。我一般限制在 64 或 128 个包,超过就丢弃最老的。
4.3.4 性能调优:缓冲区与 Socket 选项
UDP 接收缓冲区默认只有 64KB 左右,高负载下很容易溢出。建议调大:
// 设置接收缓冲区大小(需要在 bind 之前)
udpSocket->setSocketOption(QAbstractSocket::ReceiveBufferSizeSocketOption, 512 * 1024);
// 设置发送缓冲区大小
udpSocket->setSocketOption(QAbstractSocket::SendBufferSizeSocketOption, 256 * 1024);
另外,bind() 时可以用 QUdpSocket::ShareAddress 选项,允许多个 socket 绑定到同一个端口,实现多线程接收。
udpSocket->bind(QHostAddress::AnyIPv4, 8888, QUdpSocket::ShareAddress);
4.4 本章小结
UDP 通信的核心就三件事:怎么发、怎么收、怎么保证质量。QUdpSocket 的 API 虽然简单,但广播和组播的细节不少,丢包和乱序的处理更是考验工程能力。
我个人习惯是:局域网内设备发现用广播,跨网段数据分发用组播,关键控制指令用“UDP + 序列号 + 超时重传”模拟可靠传输。至于音视频流,丢几个包不影响观看,直接跳过就好。
嗯,这一章的内容就到这里。记住一句话:UDP 不保证可靠,但我们可以用代码让它变得可靠——只要你知道代价是什么。