TT Lab
开始
学习 学习路径 课程

TCP 包裹碎成几段才送到

找回消失的消息边界

在 TT Lab 中继续学习

一句话总结

TCP 保证字节的顺序,但不会替你把自己定义的消息边界也一并运送过来。

为什么需要它

向包裹服务器发送了“猫零食”和“充电线”两个订单,结果一个只看到一半,另外两个订单又粘在了一起。人们很容易断定是网络丢了字节。然而发送方的两次 send 和接收方的两次 recv 并不是一一对应的。根据操作系统缓冲区在那一刻的状态,同样的数据可能以不同的块送达。在简短的本地测试中碰巧一次就收齐,并不能证明消息契约成立。

本课程中的包裹是教学用的字节消息。它不是真实的订单、支付系统或经过认证的服务,也不需要开放外部网络。我们要做的是带长度头的帧,以及把这个帧还原出来的小程序。这适合了解字符串、类、循环、异常的 Python 学习者。

工作原理

信封前面的 4 个字节以 unsigned big-endian 表示正文长度。b"cat" 是 00 00 00 03 后面跟着 63 61 74。整个帧是 7 个字节,但头部的值是 3。韩语字符串要先转换成 UTF-8,再计算 len。字符有 2 个,和要传输的字节有 2 个,并不是一回事。接收端的解析器不会过早地解释字符,而是保留 bytes。即使 chunk 恰好在 UTF-8 字符中间断开,只要收集完整个正文再解释就可以。

Decoder 反复询问三个问题。有 4 个字节的头部吗?有该头部所要求的全部正文吗?取出完整的消息之后,还有下一个帧吗?如果第一个问题的答案是否,就保留剩余数据并返回。第二个也一样。第三个不是只检查一次的 if,而是需要循环。一个 chunk 里可能有两个完整的帧,外加第三个头部的一半。

feed 1: [길이 헤더 앞 2바이트]             → []
feed 2: [헤더 뒤 2바이트][본문 앞부분]      → []
feed 3: [나머지 본문][다음 프레임 전체]    → [본문1, 본문2]

空列表表示还没有完成的消息。[b""] 表示完成了一个长度为 0 的消息。如果把这两者当成一回事处理,就会丢失心跳这样的空消息。feed(b"") 也只是“这次没有输入”,并不定义为连接结束。在网络上 recv 返回 b"" 时,才算知道了 EOF,并把这个事件传给 finish。

头部只剩一半就结束了连接,并不是没有下一个订单的正常结束。对方开始了一个订单,却没有完成。承诺了 8 个字节的正文却只发了 5 个字节,也是一样。区分没有剩余数据的结束和未完成的结束,可以防止应用程序处理不完整的订单。不过,协议解析成功并不能代替对订单本身有效性的验证。

解析器每个连接一个。如果把不同客户端的剩余数据汇集到全局 buffer 中,A 的头部后面就会接上 B 的正文。这就是为什么要把它做成实例的属性,而不是文件的全局变量。反过来,如果在同一个连接上每次都创建新实例,尚未完成的头部每次调用都会消失。状态放在哪里,就决定了数据的所有权。

在现场相遇的样子

RPC、设备通信、游戏服务器使用的帧格式各不相同,但都有“只收到了一部分”这个共同的问题。HTTP 和 WebSocket 已经有各自的分帧规则,所以并不是必须无条件加上本课程的 4 字节头部。如果库提供了以消息为单位的 API,请先确认由哪一层处理边界。陷阱在于,以为 TCP 有可靠性,应用层的消息边界就会自动出现。

把长度为 1 的 A 与长度为 2 的 BC 连起来的字节序列是 00 00 00 01 41 00 00 00 02 42 43。第一次 feed 只给前 6 个字节,会返回一个 A,并保留下一个头部的第一个字节 00。再给后 5 个字节,剩下的 3 个头部字节和正文 BC 就完成了。把同样的字节序列一次性给出,应该得到 [b"A", b"BC"]。如果只是改了分割位置,结果就变了,请先怀疑解析器的状态转换,而不是网络。

创建连接 A 和 B 的两个 Decoder,只给 A 提供头部的前半部分,给 B 提供完整的帧,这样的测试也很有用。如果 B 的完成改变了 A 的剩余数据,就说明状态被共享了。这种交叉输入即使没有并发执行,也能暴露所有权错误。测试的目的不是重现操作系统调度器的偶然性,而是系统地检查代码所允许的输入顺序。

这个教学用的实现采用的是删除 bytearray 前部的简单方式。在大流量下,复制和移动的成本会变得重要,可以考虑读取游标、环形缓冲区之类的替代方案。先证明能够正确还原消息,然后再通过性能分析来优化。还要区分一点:一个长度上限,并不会同时限制总连接数或一次提供的 chunk 的大小。

下一项检查要做什么

在紧接着的测验中,区分边界、空消息和 EOF。在最后一个模块的实验中,要让逐字节切断输入的确定性测试,以及把多个帧打包输入的测试通过。不会等待真实的 TCP 中碰巧出现某种分割。只要原文相同,无论怎样拆分提供,还原出的消息列表都应该一样。