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();
    }
});
注意: 很多人写 UDP 接收时只用一次 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);
}
核心思路: 序列号 + 超时重传 = 可靠 UDP。但要注意,重传会增加延迟和网络负载。对于实时性要求高的场景(比如音视频),丢包了直接跳过比重传更合理。我在做视频会议项目时,就采用了“丢包不重传,只做 FEC 前向纠错”的策略,效果比重传好得多。

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 不保证可靠,但我们可以用代码让它变得可靠——只要你知道代价是什么。

UDP 通信核心知识体系 QUdpSocket 基础收发 广播与组播 可靠性与调优 bind() writeDatagram() readDatagram() readyRead 广播 255.255.255.255 组播 224.0.0.0/4 joinMulticastGroup() leaveMulticastGroup() 序列号 + 确认 乱序重排 缓冲区调优 超时重传 核心原则 UDP 不保证可靠,但应用层可以自己实现可靠性 广播用于局域网发现,组播用于跨网段分发 序列号 + 乱序缓冲区 + 超时重传 = 可靠 UDP