第 46 章 / 共 58 章 卷九 网络编程

UDP 编程

UDP 是"发了就走"的协议——没有连接、没有确认、没有重传,每个数据报独立寻路。这让它拥有 TCP 无法比拟的低延迟和低开销,适合实时游戏、视频流、DNS 查询、IoT 传感器上报等场景。xlang 标准库的 UDP API 极简:udp.bind 创建套接字,send 发送数据报,recv 接收数据报——三个函数覆盖全部操作。本章还会讨论 UDP 的可靠性模式和 TCP/UDP 选型策略。

本章导读

UDP(User Datagram Protocol)是传输层的"轻量级"协议。与 TCP 的"打电话"模型不同,UDP 是"寄信"模型——每封信(数据报)独立发送,不保证到达、不保证顺序、不保证不重复。但正因如此,它没有三次握手、没有拥塞控制、没有重传延迟,开销极小。

本章学习目标
  • 用 udp.bind 创建 UDP 套接字
  • 用 send/sendBytes 向指定地址发送数据报
  • 用 recv/recvAsync 接收数据报并解析 UdpPacket
  • 理解 UDP 的无连接特性和消息边界保留
  • 配置 ReuseAddr/ReusePort 选项
  • 掌握 UDP vs TCP 的选型原则
  • 实现简单的确认重传可靠模式

46.1 创建 UDP 套接字

UDP 没有"服务器"和"客户端"的区分——任何套接字既能发也能收:

// 创建 UDP 套接字——端口 0 = 系统自动分配
UdpSocket socket = UdpSocket(udp.bind(0))
int port = socket.port()
println("UDP 套接字绑定端口: " + port.toString())

// 也可以绑定指定端口
UdpSocket server = UdpSocket(udp.bind(9000))
println("监听 9000 端口")

udp.bind(port) 将套接字绑定到本机的指定端口。端口 0 让系统自动选择。与 TCP 不同,UDP 不需要 listen/accept——绑定后直接可以收发数据报。

UDP 没有连接

UDP 套接字不维护"对端"信息。每次 send 都要指定目标地址和端口;每次 recv 都能收到来自任何发送者的数据报。一个 UDP 套接字可以同时与多个对端通信。

46.2 发送数据报

每次 send 指定目标地址:

UdpSocket sender = UdpSocket(udp.bind(0))

// 发送文本数据报
bool ok = sender.send("127.0.0.1", uint16(9000), "Hello UDP")
if ok then
    println("数据报已发送")
end

// 发送到不同目标——同一个套接字
sender.send("192.168.1.100", uint16(9000), "msg for device A")
sender.send("192.168.1.101", uint16(9000), "msg for device B")

send(host, port, data) 的参数:目标 IP(字符串)、目标端口(uint16)、数据(字符串)。注意端口需要 uint16() 转换。

46.3 接收数据报与 UdpPacket

recv 返回一个 UdpPacket,包含数据和来源信息:

UdpSocket receiver = UdpSocket(udp.bind(9000))

// 阻塞等待数据报(超时 8 秒)
UdpPacket packet = UdpPacket(receiver.recv(8000))

// 提取数据
string text = packet.text()     // 数据报内容
println("收到: " + text)

// 来源信息(可用于回复)
// packet 包含发送者的 IP 和端口

完整的收发交互示例:

// 创建两个 UDP 套接字
UdpSocket left = UdpSocket(udp.bind(0))
UdpSocket right = UdpSocket(udp.bind(0))
int leftPort = left.port()
int rightPort = right.port()

// left → right
left.send("127.0.0.1", uint16(rightPort), "hello")
UdpPacket packet = UdpPacket(right.recv(8000))
println(packet.text())   // "hello"

// right → left(回复)
right.send("127.0.0.1", uint16(leftPort), "reply")
UdpPacket replyPacket = UdpPacket(left.recv(8000))
println(replyPacket.text())   // "reply"
UDP 保留消息边界

与 TCP 不同,UDP 是数据报协议——一次 send 对应一次 recv。你发 "hello",对端 recv 一定收到完整的 "hello",不会多也不会少(除非数据报丢失)。不需要长度前缀或分隔符。

46.4 二进制数据报

传输结构化二进制数据:

// 构建二进制数据报
Buffer data = Buffer()
data.appendByte(0)      // 消息类型:心跳
data.appendByte(2)      // 协议版本
data.appendByte(127)    // 载荷
data.appendByte(255)

// 发送二进制
UdpSocket sender = UdpSocket(udp.bind(0))
bool ok = sender.sendBytes("127.0.0.1", uint16(9000), data.data(), c_size(data.len()))

// 接收二进制
UdpSocket receiver = UdpSocket(udp.bind(9000))
UdpPacket packet = UdpPacket(receiver.recv(8000))
Buffer received = packet.bytes()

println("长度: " + received.len().toString())   // 4
println("类型: " + received[0].toString())       // 0
println("版本: " + received[1].toString())       // 2

sendBytes(host, port, ptr, size) 发送原始字节;packet.bytes() 返回 Buffer 对象。

46.5 异步 UDP 操作

异步版本避免阻塞线程:

UdpSocket left = UdpSocket(udp.bind(0))
UdpSocket right = UdpSocket(udp.bind(0))
int leftPort = left.port()
int rightPort = right.port()

// 异步接收——先发起
future<point> packetFuture = right.recvAsync(8000)

// 短暂等待确保接收就绪
time.sleep(10)

// 异步发送
bool sent = await left.sendAsync("127.0.0.1", uint16(rightPort), "async-udp")

// 等待接收完成
UdpPacket packet = UdpPacket(await packetFuture)
println(packet.text())   // "async-udp"

异步 API 对照:

同步异步返回类型
socket.send(h, p, d)socket.sendAsync(h, p, d)future<bool>
socket.recv(t)socket.recvAsync(t)future<point>
先 recv 后 send

UDP 数据报到达时如果没有套接字在 recv,数据报会被丢弃。因此在本地测试中,通常先发起 recvAsync(开始监听),再 send。加一个短暂的 time.sleep(10) 确保接收端就绪。

46.6 UDP 配置选项

高级配置通过 udp.config:

// 创建 UDP 配置
UdpConfig cfg = udp.config(0, "127.0.0.1")

// 设置标志
cfg.flags(udp.reuseAddr + udp.reusePort)
// reuseAddr:允许绑定 TIME_WAIT 状态的地址
// reusePort:允许多个套接字绑定同一端口(多播/负载均衡)
标志作用场景
udp.reuseAddr允许重用已绑定的地址服务快速重启
udp.reusePort多套接字共享端口多线程 UDP 服务、多播

46.7 UDP vs TCP 选型

选择 TCP 还是 UDP,取决于你的应用需求:

维度TCPUDP
连接面向连接(三次握手)无连接
可靠性保证到达、有序、不重复不保证
消息边界字节流(无边界)数据报(保留边界)
延迟较高(握手+确认)极低
开销20 字节头 + 状态维护8 字节头,无状态
拥塞控制有(自动降速)无
典型应用HTTP、文件传输、数据库游戏、视频、DNS、IoT

选型口诀:

  • 丢一个包就不能用 → TCP(文件下载、网页、API)
  • 丢一个包无所谓,迟到不如不到 → UDP(实时游戏、视频帧、传感器数据)
  • 需要极低延迟 → UDP
  • 需要与多个对端通信 → UDP(一个套接字服务所有客户端)

46.8 可靠 UDP 模式

UDP 本身不可靠,但你可以在应用层实现确认重传:

// === 发送方:带确认的可靠发送 ===
func reliableSend(UdpSocket sock, string host, int port, string data, int maxRetries) as bool
    int attempt = 0
    while attempt < maxRetries
        sock.send(host, uint16(port), data)

        // 等待 ACK(短超时)
        UdpPacket ack = UdpPacket(sock.recv(1000))
        if ack.text() == "ACK" then
            return true   // 对方确认收到
        end

        attempt += 1
        println("重传第 " + attempt.toString() + " 次")
    end
    return false   // 超过最大重试次数
end

// === 接收方:收到数据后回复 ACK ===
func receiveWithAck(UdpSocket sock, int senderPort) as string
    UdpPacket packet = UdpPacket(sock.recv(8000))
    string data = packet.text()

    // 回复确认
    sock.send("127.0.0.1", uint16(senderPort), "ACK")

    return data
end

这就是 QUIC、RUDP、KCP 等可靠 UDP 协议的基本思路:序列号 + 确认 + 重传 + 超时。完整的实现还需要处理乱序、去重、流控和拥塞控制。

46.9 MTU 与分片

UDP 数据报有实际大小限制:

  • 理论上限:65507 字节(IPv4)
  • 安全大小:512 字节(保证不分片)
  • 以太网 MTU:1500 字节(超过会被 IP 层分片)
  • 推荐:≤ 1400 字节(留余量给 IP/UDP 头)

分片的问题:任何一个分片丢失,整个数据报都要丢弃。因此在不可靠网络上,保持数据报小于 MTU 是最佳实践。如果你的消息可能超过 1400 字节,应该在应用层分块:

// 应用层分块发送
func sendLarge(UdpSocket sock, string host, int port, Buffer data, int chunkSize)
    int offset = 0
    int seq = 0
    while offset < data.len()
        int size = chunkSize
        if offset + size > data.len() then
            size = data.len() - offset
        end
        // 构建帧:[序列号 2 字节][数据]
        Buffer frame = Buffer()
        frame.appendByte(seq / 256)
        frame.appendByte(seq % 256)
        // ... 追加 data[offset..offset+size] ...
        sock.sendBytes(host, uint16(port), frame.data(), c_size(frame.len()))
        offset += size
        seq += 1
    end
end

46.10 实战:时间同步客户端

一个经典的 UDP 应用——NTP 风格的时间同步:

// === 时间服务器 ===
UdpSocket timeServer = UdpSocket(udp.bind(0))
int serverPort = timeServer.port()
println("时间服务器端口: " + serverPort.toString())

// 服务器线程:收到请求后回复当前时间戳
// (简化:用主线程模拟一次交互)

// === 客户端 ===
UdpSocket client = UdpSocket(udp.bind(0))

// 发送时间请求
int64 sendTime = time.now()
client.send("127.0.0.1", uint16(serverPort), "TIME?")

// 服务器收到并回复
UdpPacket req = UdpPacket(timeServer.recv(5000))
if req.text() == "TIME?" then
    string timestamp = time.now().toString()
    timeServer.send("127.0.0.1", uint16(client.port()), timestamp)
end

// 客户端收到时间
UdpPacket resp = UdpPacket(client.recv(5000))
int64 recvTime = time.now()
string serverTime = resp.text()

// 计算往返延迟
int64 rtt = recvTime - sendTime
println("服务器时间: " + serverTime)
println("往返延迟: " + rtt.toString() + " ms")
println("估算单向延迟: " + (rtt / 2).toString() + " ms")

这就是 NTP(Network Time Protocol)的简化版:客户端记录发送时间,服务器回复服务器时间,客户端记录接收时间,通过 RTT/2 估算时钟偏差。

深入细节

深入 1:UDP 的"尽力而为"语义

UDP 不保证:① 数据报到达(可能被路由器丢弃);② 到达顺序(可能走不同路径);③ 只到达一次(重传可能导致重复);④ 数据报大小(超过 MTU 可能被分片,任一分片丢失则整体丢失)。你的应用层协议必须处理所有这些情况——或者接受它们(如视频帧丢一帧无所谓)。

深入 2:UDP 套接字的双向性

与 TCP 的"服务器 accept 得到新连接"不同,UDP 的一个套接字可以同时与 N 个对端通信。收到数据报后通过 UdpPacket 的来源地址识别发送者,回复时向该地址 send。这使得 UDP 天然适合"一对多"场景(如游戏服务器)。

深入 3:recv 超时与数据报丢失不可区分

UDP 的 recv 超时返回空——但你无法区分"数据报还在路上"和"数据报已丢失"。在可靠 UDP 实现中,超时后重传是唯一选择。但要注意:重传可能导致对端收到重复数据报,因此需要序列号去重。

⚠️ 常见错误

❌ 错误 1:发送超过 MTU 的数据报

发送 10KB 的 UDP 数据报,在以太网上会被 IP 层分成 7 个分片。任何一个分片丢失,整个 10KB 数据报全部作废。保持数据报 ≤ 1400 字节,大消息在应用层分块。

❌ 错误 2:假设 UDP 数据报一定到达

UDP send 返回 true 只表示数据报已交给操作系统网络栈,不代表对端收到了。如果你的协议需要可靠性,必须实现应用层确认。

❌ 错误 3:不处理数据报乱序

UDP 不保证顺序。发送 1、2、3 三个数据报,对端可能收到 1、3、2 或 3、1、2。如果消息有顺序依赖,必须加序列号并在接收端排序。

❌ 错误 4:忘记 uint16 转换端口

send("127.0.0.1", 9000, "data") 会编译报错——端口参数类型是 uint16。必须写 uint16(9000)。这是 xlang 类型安全的体现。

本章小结

主题核心要点
创建UdpSocket(udp.bind(port));端口 0 自动分配;无 listen/accept
发送send(host, uint16(port), data);sendBytes 二进制版本
接收recv(timeout) 返回 point;UdpPacket 包装后 .text()/.bytes()
异步sendAsync 返回 future<bool>;recvAsync 返回 future<point>
配置udp.config + flags(reuseAddr/reusePort)
消息边界UDP 保留边界——一次 send 对应一次 recv,无粘包
选型要可靠选 TCP;要低延迟/一对多/容忍丢失选 UDP
可靠模式序列号 + ACK + 超时重传 + 去重
MTU保持 ≤ 1400 字节;大消息应用层分块
双向性一个套接字与多个对端通信;通过 packet 来源识别发送者

复习题

  1. UDP 和 TCP 在"连接"概念上的根本区别是什么?
  2. udp.bind(0) 和 tcp.listen(0) 有什么共同点?
  3. 为什么 UDP 的 send 需要每次指定目标地址,而 TCP 不需要?
  4. UDP 保留消息边界意味着什么?与 TCP 的字节流有什么不同?
  5. send 返回 true 是否意味着对端一定收到了数据?为什么?
  6. 为什么推荐 UDP 数据报不超过 1400 字节?超过会怎样?
  7. 可靠 UDP 需要哪些机制?为什么 UDP 本身不提供这些?
  8. 什么场景下应该选 UDP 而不是 TCP?举三个例子。
  9. recvAsync 超时和"数据报丢失"能区分吗?如何处理?
  10. 一个 UDP 套接字能同时与多个对端通信吗?如何识别不同发送者?

编程练习

练习 1:UDP 聊天

实现一个简单的 UDP 聊天程序:两个 UdpSocket 互相发送消息。用户输入(硬编码模拟)后 send 给对方,同时 recv 对方的消息。实现 3 轮对话,打印每轮收发内容。

参考思路:两个 socket 分别 bind(0);循环中 send + recv;用 time.sleep 模拟用户思考时间。

练习 2:可靠传输

实现 46.8 节的确认重传机制:发送方发送带序列号的数据报,等待 ACK;超时 1 秒未收到 ACK 则重传,最多 3 次。接收方收到后回复 "ACK:seq"。模拟丢包(第一次不回复 ACK),验证重传成功。

参考思路:发送方 while 循环 send + recv(1000);接收方第一次故意不回复,第二次回复 ACK。

练习 3:UDP 广播发现

实现服务发现:客户端向 255.255.255.255:9999 发送 "DISCOVER",多个服务器(模拟 2 个 socket)收到后回复自己的标识。客户端收集 2 秒内的所有回复,打印发现的服务器列表。

参考思路:服务器 bind(9999, reusePort);客户端 send 广播地址;循环 recv(2000) 收集回复直到超时。