本章导读
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 套接字不维护"对端"信息。每次 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"
与 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> |
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,取决于你的应用需求:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 保证到达、有序、不重复 | 不保证 |
| 消息边界 | 字节流(无边界) | 数据报(保留边界) |
| 延迟 | 较高(握手+确认) | 极低 |
| 开销 | 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 估算时钟偏差。
深入细节
UDP 不保证:① 数据报到达(可能被路由器丢弃);② 到达顺序(可能走不同路径);③ 只到达一次(重传可能导致重复);④ 数据报大小(超过 MTU 可能被分片,任一分片丢失则整体丢失)。你的应用层协议必须处理所有这些情况——或者接受它们(如视频帧丢一帧无所谓)。
与 TCP 的"服务器 accept 得到新连接"不同,UDP 的一个套接字可以同时与 N 个对端通信。收到数据报后通过 UdpPacket 的来源地址识别发送者,回复时向该地址 send。这使得 UDP 天然适合"一对多"场景(如游戏服务器)。
UDP 的 recv 超时返回空——但你无法区分"数据报还在路上"和"数据报已丢失"。在可靠 UDP 实现中,超时后重传是唯一选择。但要注意:重传可能导致对端收到重复数据报,因此需要序列号去重。
⚠️ 常见错误
发送 10KB 的 UDP 数据报,在以太网上会被 IP 层分成 7 个分片。任何一个分片丢失,整个 10KB 数据报全部作废。保持数据报 ≤ 1400 字节,大消息在应用层分块。
UDP send 返回 true 只表示数据报已交给操作系统网络栈,不代表对端收到了。如果你的协议需要可靠性,必须实现应用层确认。
UDP 不保证顺序。发送 1、2、3 三个数据报,对端可能收到 1、3、2 或 3、1、2。如果消息有顺序依赖,必须加序列号并在接收端排序。
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 来源识别发送者 |
复习题
- UDP 和 TCP 在"连接"概念上的根本区别是什么?
- udp.bind(0) 和 tcp.listen(0) 有什么共同点?
- 为什么 UDP 的 send 需要每次指定目标地址,而 TCP 不需要?
- UDP 保留消息边界意味着什么?与 TCP 的字节流有什么不同?
- send 返回 true 是否意味着对端一定收到了数据?为什么?
- 为什么推荐 UDP 数据报不超过 1400 字节?超过会怎样?
- 可靠 UDP 需要哪些机制?为什么 UDP 本身不提供这些?
- 什么场景下应该选 UDP 而不是 TCP?举三个例子。
- recvAsync 超时和"数据报丢失"能区分吗?如何处理?
- 一个 UDP 套接字能同时与多个对端通信吗?如何识别不同发送者?
编程练习
实现一个简单的 UDP 聊天程序:两个 UdpSocket 互相发送消息。用户输入(硬编码模拟)后 send 给对方,同时 recv 对方的消息。实现 3 轮对话,打印每轮收发内容。
参考思路:两个 socket 分别 bind(0);循环中 send + recv;用 time.sleep 模拟用户思考时间。
实现 46.8 节的确认重传机制:发送方发送带序列号的数据报,等待 ACK;超时 1 秒未收到 ACK 则重传,最多 3 次。接收方收到后回复 "ACK:seq"。模拟丢包(第一次不回复 ACK),验证重传成功。
参考思路:发送方 while 循环 send + recv(1000);接收方第一次故意不回复,第二次回复 ACK。
实现服务发现:客户端向 255.255.255.255:9999 发送 "DISCOVER",多个服务器(模拟 2 个 socket)收到后回复自己的标识。客户端收集 2 秒内的所有回复,打印发现的服务器列表。
参考思路:服务器 bind(9999, reusePort);客户端 send 广播地址;循环 recv(2000) 收集回复直到超时。