应用连续发送编号 0 到 4 的五条消息,接收端为什么可能得到 0,3,2,4,4?这既包含缺失,也包含乱序和重复。若每次接收仍得到一条完整消息,是否就说明传输可靠?数据报边界与交付保证是两个不同的问题。

第 02 篇解释了 TCP 字节流需要应用划分消息,第 09 篇说明大小还受路径 MTU 约束。本篇使用本机 UDP socket 和一个专用应用代理,主动改变转发序列。实验发送真实 UDP 数据报,但故障来自代理代码,不代表内核自然丢包或公网故障概率。

一次发送留下什么边界

RFC 768定义 UDP 数据报服务。UDP 首部含源端口、目的端口、长度和校验和,每项为十六位;长度包含八字节首部与数据。端口必须放在目的 IP 地址等上下文中解释,不能把一个端口号视为全球唯一的服务身份。

1
2
IP 头部 | 源端口  目的端口  UDP长度  校验和 | 应用数据
<---------- UDP 首部8字节 ---------->

发送两个数据报,并不等于向接收端提供一段可以任意切分的连续字节流。接收接口一次取一个数据报的内容,不把后一个数据报拼到前一个尾部。反过来,保留边界也不保证每个发送的数据报都能到达,更不保证到达顺序与发送顺序相同。

Python 的 recvfrom 返回数据和来源地址。应用应同时检查内容与来源,不能只因某个 socket 收到字节就认定预期对端完成了协议。来源地址本身也不是密码学身份认证;本篇用它核对自建实验端点,不将其当作安全认证机制。

缓冲不足不是下一次继续读取

若发来一个十六字节数据报,应用只提供四字节接收空间,不能沿用 TCP 的读满循环,期望下一次再取剩下十二字节。数据报接收的截断行为受平台接口约束;在本篇检查的 Unix 接口上,被截去的尾部不会作为下一次数据报继续返回。

Python recvmsg 还返回消息标志,可用于检查平台提供的 MSG_TRUNC。Linux udp(7)明确说明一次接收一个数据报以及接收缓冲不足的截断标志。Apple 的 recvfrom/recvmsg 历史手册也说明该标志代表尾部被丢弃。这份归档不代表当前系统实测,跨平台结论仍须分别验证。

因此,“收到四字节”有两个不同解释:发送者本来只发四字节,或者接收缓冲只保留了四字节。只记录返回长度无法区分它们。实际协议还可以在应用消息头中携带声明长度,并与接收结果一起核对;本篇先直接观察消息标志和下一次读取。

空数据报也应单独处理。UDP 允许零字节应用载荷,此时 UDP 长度仍为八字节。接收得到 b'' 可以是合法的空消息,不能直接搬用 TCP 的 EOF 判断。区分传输类型,比复用一段看似通用的接收循环更重要。

校验与可靠性解决不同问题

UDP 校验和覆盖伪首部、UDP 首部和数据。伪首部把源地址、目的地址、协议及长度等信息纳入计算,校验输入不只是应用载荷。需要补齐奇数字节时,补零用于计算,不是给应用消息追加业务数据。

十六位反码加法遇到最高位进位时,把进位加回低十六位。例如 0xfff0 + 0x0020 = 0x10010,折回后为 0x0011,再按十六位取反得到 0xffee。这只是两个字的算术示例,不是一个完整 UDP 包的校验结果;完整计算还必须包含正确伪首部和首部字段。

RFC 768 规定,计算得到的校验结果若为零,在线上用 0xffff 表示;线上校验字段为 0x0000 才表示发送者未生成校验。不能把“计算为零”误写成“关闭校验”。

IPv6 的通常要求更严格:RFC 8200 §8.1要求发送方生成 UDP 校验,接收方丢弃零校验数据报,同时保留满足特定条件的 UDP 隧道例外。普通应用不能把这种例外当成任意关闭校验的许可。本篇没有构造坏校验报文,也没有验证隧道例外。

校验通过不提供重传、排序或去重,也不是加密认证。一个数据报完整地重复到达两次,两次校验都可能成立;内容保持不变地换了交付顺序,校验也不能替应用恢复原顺序。

用固定故障序列隔离因果

拓扑只有三个本机 socket,全部绑定 127.0.0.1,端口由系统分配:

1
2
3
4
发送端 → 注入代理 → 接收端
丢1
暂存2,先发3再发2
发4两次

编号属于实验应用载荷,UDP 首部没有为这组消息自动提供这样的业务序号。代理先接收真实数据报,再根据编号决定是否重新发送。接收端看到的来源是代理端点,而不是最初发送端;这一点是代理拓扑的实际结果。

预期接收序列 0,3,2,4,4 来自明确的注入规则,仍须与真实接收内容比较。只打印代理“准备发送”日志不能证明接收端已拿到数据。每一阶段都需要保存实际字节、来源地址和发送返回值,才能识别故障发生在注入规则还是本机传输中。

这组实验不靠等待偶然丢包制造结果,因此可检查每一个丢失、暂存和重复的原因。它也因此不能用来估计自然网络的丢包率;一条由程序故意丢弃的消息,不是一次随机网络测量。

应用需要补哪些状态

如果消息必须按顺序且只处理一次,应用至少需要分辨消息、保存已处理状态,并决定怎样处理缺口。若只把接收字节直接追加,重复的 4 会被处理两次,缺失的 1 也不会自动补齐。对实时媒体而言,迟到内容还可能已经失去价值,是否重传取决于业务时限。

RFC 8085 §3.1–3.3讨论 UDP 应用的拥塞控制、消息大小与可靠性责任。重试同样产生网络负载,不能因为没有 TCP 就无限快速重发。消息大小也应考虑路径 MTU,不能把某个局域网下的载荷尺寸当作跨网络通用安全值。

下一篇构造有界的教学可靠传输,把序号、确认、计时器和去重组合起来。那是一份只实现约定功能的教学协议,不能称为 TCP,也不会因通过固定故障序列就自动具有公网可用性。

本机运行与记录

下载 实验脚本实际运行记录环境说明,在允许本机回环 socket 的环境执行:

1
2
3
python3 udp.py --help
python3 udp.py > actual.json
python3 udp.py --unknown

脚本需要 Python 3.11 或更新版本,不安装依赖。前两条分别显示帮助和执行实验;最后一条应以状态码 2 拒绝未知参数。不要使用 -O,否则内置断言会被关闭。每个接收调用设置两秒超时,脚本只处理固定数量的小消息,结束后关闭自己创建的 socket。

2026 年 9 月 20 日,本次环境为 macOS 27.0、arm64、Python 3.14.4。接收端实际得到 0,3,2,4,4,来源均匹配代理;记录包含五次发送端返回、五次代理接收、五次注入动作、五次代理转发返回和五次最终接收。这里五次接收与五次最初发送数目相同,却仍存在一个缺失和一个重复,仅比较计数会漏掉错误。

两个独立对照结果如下:

对照 实际接收 能证明什么
发送空载荷,再发送 after-empty 先为零字节,随后收到完整 after-empty 空消息没有结束后续数据报接收
发送 abcdefghij,只提供四字节空间 收到 abcd,返回标志含 MSG_TRUNC 此次接收发生截断
截断后再发送 NEXT 下一次收到 NEXT 原消息尾部没有继续返回

本次 MSG_TRUNC 常量和返回标志都为 16,程序通过按位检查常量判断,不将这个整数硬编码成跨平台规则。运行记录中的临时端口由系统分配,下次可以不同;应比较来源关系和消息内容,而不是要求两份 JSON 逐字节一致。

首次在受限执行沙箱中运行时,绑定回环端口被拒绝,尚未发送数据;获得本机运行权限后才完成上述实验。这次环境拒绝不计作 UDP 丢包,也没有为此关闭系统防护或修改宿主路由。

验证边界

已验证实际本机 UDP 发送和接收、应用代理的固定丢失/乱序/重复、空数据报,以及当前 macOS 接口的截断结果。算术示例另外用整数运算核对,但没有构造完整 UDP 校验字段。

没有抓取线上 UDP 首部,没有注入坏校验报文,没有验证 IPv6 零校验例外,也没有测量公网丢包率、吞吐、内核自然重排或生产可靠性。程序刻意安排每次转发后的读取顺序,因此结果不涉及并发接收竞态或队列溢出。缺失编号 1 的原因由代理动作日志证明,不能推广为任意接收缺口都能仅凭接收端日志定位。

练习

第一题:发送两条内容相同但业务上不同的消息,都为 b'pay'。接收端仅按字节内容去重,会发生什么?

校验:第二条合法消息可能被误删。消息内容相等不等于同一操作重传,需要有明确的消息身份和有效期。反例也说明单纯对接收内容做集合去重不足以定义可靠交付。

第二题:十六字节数据报被四字节缓冲截断,随后又发来 b'next'。下一次读取是否应拼出原报文尾部?

校验:不能这样假设。数据报尾部被截去后,下一次读取针对下一条消息,应独立解释 next。读满循环适用于字节流中的应用帧,不能用它恢复已丢弃的数据报尾部。

参考资料