客户端先发送 one,再发送 two。服务端调用一次 recv(1024),应该得到哪一条消息?如果答案必须是其中一条,应用就已经假定 TCP 会保留写入边界。这个假定不成立:TCP 向应用提供有序字节流,应用必须自行规定消息从哪里开始、在哪里结束。

第 00 篇用固定十字节回显区分发送返回与对端读取。本篇把负载改成变长消息,设计一个四字节长度头协议,并用主动切分的输入检验解析器。实验同时包含真实回环连接,但不会把多次 sendall 当成必然产生相同次数 recv 的依据。

字节流中没有一次写入的标记

RFC 9293 §2.2定义了可靠、有序字节流服务。对一个方向,应用写入 abc 后再写入 def,接收端看到的字节顺序应为 abcdef;一次读取可能得到一个字节、三个字节或六个字节。这里讨论的是无错误、最终成功交付时的顺序,不表示读取永远不会超时或连接不会失败。

传输实现可以把应用数据分成 TCP 段,也可以把多个段接收的数据交给一次应用读取。应用、TCP 分段、IP 包和链路帧属于不同边界。抓包里看到两个 TCP 段,无法要求服务端恰好调用两次 recv 才读完它们。

TCP 的 PSH 标志同样不能作为消息结尾。RFC 9293 §3.9.1.2明确它不是记录标记。把一次 send、一个 TCP 段或一个 PSH 当成一条业务消息,都给应用协议增加了传输层没有提供的保证。

长度固定时,可以像第 00 篇那样读到指定字节数。长度变化时,常见方案包括分隔符、固定宽度字段和长度前缀。分隔符方案还需要处理内容中出现分隔符的情况。本篇选择长度前缀,因为它能直接暴露短读、越界声明和 EOF 的不同处理分支。

一份有界的帧格式

每条消息由四字节无符号大端长度 N 和紧随其后的 N 字节负载构成。长度只计算消息体,不包含四字节头;允许空负载,最大负载为 4096 字节。这个上限是教学协议的选择,不是 TCP 或 Python 的限制。

1
2
3
4
偏移    0          4                         4+N
+----------+-------------------------+
| N: !I | N 字节消息体 |
+----------+-------------------------+

例如 one 的编码是 00 00 00 03 6f 6e 65。连续发送 onetwo 时,编码结果为:

1
00 00 00 03 6f 6e 65 00 00 00 03 74 77 6f

接收端读满第一个四字节头,就知道第一条消息需要三个负载字节。第一条结束后,下一个字节属于第二个长度头。数据如何被分成接收块不会改变这份格式。

字符串先编码,再计算长度。len("网") 是一个字符,而 UTF-8 编码 e7 bd 91 长三字节。编码器使用 len(payload),其中 payload 已经是 bytes。若把字符数写入长度头,接收端会在第一个 UTF-8 字节后错误地切断消息,余下字节还会污染下一条长度解析。

Python 的 struct.pack("!I", N) 使用网络大端、标准四字节无符号整数,不插入本机对齐填充。接收端也可以用 int.from_bytes(header, "big") 得到同一整数。不能使用本机默认格式后假定另一台机器采用相同字节序。

解析器保存的状态

解析器只有两类工作状态:等待完整四字节头,或者已经知道 N、正在等待 N 字节消息体。附件 Decoderexpected is None 表示前者,用整数表示后者;零长度是合法整数,不能用 if not expected 把它误认成没有读取长度。

每次 feed(chunk) 先保留新到字节。若头还不满四字节,返回空消息列表,等待下一块;头已满则解码 N,并立即检查 N 是否超过 4096。超限时抛出 FrameTooLarge,调用者应关闭连接并丢弃此解析器,不继续尝试对齐未知字节流。

合法长度已经取得、消息体尚未凑齐时,解析器保留状态并返回。消息体够长时,取出恰好 N 字节生成一条消息,清除该长度状态,再继续尝试解析缓冲区中的下一条。循环很重要:一块输入可能同时包含多条完整消息。

假设输入块是 00 00 00 03 6f,头宣布三字节负载,但当前只有 o 的一个字节。解析器不能先交付这个 o;收到 6e 65 后才生成 one。反过来,如果一块里已有 one 和第二条消息的两个头字节,则先交付 one,保留半个头。

实现中的缓冲是可变状态,因为解析本来就需要跨调用累积字节。网络驱动每次只读七字节或五字节,单帧上限限制未完成消息的累积量。feed 的公共调用仍要求调用方限制单块大小;把任意巨大的 bytes 一次传入,函数会先扩展缓冲,不能宣称这个接口本身具备任意输入下的常量内存保证。

本实验的服务端为便于断言,会把有限消息列表收集到输入 EOF 后再回显。单帧上限因此也不等于整条连接的总内存上限。如果无限持续发送合法小帧,这个演示驱动仍会积累消息。真实服务还需要总字节数、请求数量、处理队列和截止时间约束,第 29 篇再处理持续连接与慢客户端。

短读、短写与 EOF

recv(n) 中的 n 是本次最多接收多少字节,不是必须收齐多少。Python Socket HOWTO指出,连长度字段自身都可能需要多次接收,连续消息又可能进入同一个接收块。因此,读取头和读取体都需要能够跨调用保留状态。

send(data) 返回这次发送的字节数,应用要对未发送部分继续处理。附件使用 sendall 完成有限 bytes 的发送,避免把短写循环和解析状态混在一起;但它没有增强对端处理保证。发生异常后,sendall 不提供此次已经发送多少字节的总数,应用重试不能默认整条消息完全没有到达。

对于 n 大于零的 TCP recv(n),空字节串表示接收方向的 EOF。EOF 出现在完整消息边界时,允许结束输入;出现在头只有一至三字节时,表示截断头;已经读满头却未收齐消息体时,表示截断体。附件的 finish() 对后两类情况抛出 TruncatedFrame

一个零长度消息在字节流中仍然有四字节零头。解码后得到的 b'' 是消息内容,不是 socket 返回 EOF。这两个空值处于不同接口:传输读取和消息解析。混淆接口会让空消息被误当成断开,或让真实 EOF 进入无限重试。

TCP 还允许一方结束发送方向后继续接收。实验客户端发完四条消息调用 shutdown(SHUT_WR),服务端因而能够读到输入 EOF、校验消息边界,然后沿反方向发送回显。这里使用半关闭只是给有限测试确定结束条件;完整关闭状态机留到第 16 篇。

UDP 的 socket 语义不同:它保留数据报边界,而不是把多次发送拼成一条字节流。接收缓冲不足时,剩余部分不能像 TCP 流那样靠下一次接收补齐同一个数据报;具体截断或报错行为依赖平台接口。本篇不运行 UDP 截断实验,也不把 TCP 的空读取解释搬到合法的 UDP 空数据报上,数据报责任在第 13 篇展开。

两条验证路径

解析器测试直接提供确定的 bytes 切片,真实 socket 测试则观察应用最终恢复出的消息。这两个验证覆盖不同问题。前者不受内核合并策略影响,适合锁定拆分边界;后者确认编码器、解析器和 socket 驱动能够在本机配合工作。

从仓库根目录执行以下命令,Python 会从脚本所在目录加载同目录的 framing.py

1
PYTHONDONTWRITEBYTECODE=1 python3 source/_posts/2026-09-19-计算机网络02-socket读写与消息边界/experiment.py

下载时需把 framing.pyexperiment.py 放在同一个目录;本次输出为 run.jsonl。仅使用 Python 标准库。实际运行环境是 Python 3.14.4、Darwin 27.0.0 arm64,服务只绑定 127.0.0.1 的系统分配端口。

主动切分的解析测试

编码后的四条消息为 alpha、UTF-8 的“网”、空消息和 omega。测试依次采用宽度 1、2、3、4、5、7 和整串长度 29 的切片,把每块送入同一解析器。所有分法都必须恢复相同的四条消息,顺序、字节内容和空消息都不能丢失。

宽度为一时,四字节头必然分四次输入,消息体也逐字节输入;整串一次输入时,解析器必须一次返回四条消息。另一个明确用例输入“一条完整消息加下一条的两个头字节”,随后补齐余量,检查第一条没有被扣留、第二条没有被丢弃。

截断测试对 alpha 的编码分别只提供 1、3、4 和 6 字节后调用 finish()。前两项是头不完整,四字节是已知需要五字节却没有消息体,六字节是只有两个负载字节。四项都观察到 TruncatedFrame

超限测试只送入声明 4097 的四字节头,不提供消息体。解析器立即抛出 FrameTooLarge,证明拒绝不需要等待超长消息体到齐。这个检查防止按不可信长度等待或分配大消息,但不覆盖前述无限合法小帧的总量问题。

回环回显

真实连接发送 one、UTF-8 的“网”、空消息和 4096 字节 x,合计四条消息、4102 字节负载。客户端每次最多向 sendall 提供三字节,服务端每次最多 recv(7),客户端读取回复每次最多 recv(5)

这些大小只是应用请求的上下界,不承诺内核每次实际交付正好七字节或五字节。验收断言只检查最终消息列表与原始列表一致。日志的 loopback_echo 记录四条消息和 4102 字节,断言在本次运行中通过。

实验没有随机等待来“制造粘包”,也不需要把 Nagle 算法或延迟 ACK 当作解析正确性的前提。任何合法切分下都应正确恢复消息,传输调优不能修复缺失的消息格式。

练习

练习一:手算状态。 第一块是 00 00,第二块是 00 03 61,第三块是 62 63 00 00 00 00。列出每次输入后交付的消息和未完成状态。

核对:第一块没有消息,保留半个头;第二块仍没有消息,已知长度三、已有 a;第三块先交付 abc,随后交付一个空消息,最后回到等待新头的边界。若第三块缺最后一字节,流结束时应报截断头,不能把剩余三个零当成合法空消息。

练习二:构造错误接收器的反例。 假设接收器先调用一次 recv(4) 得到头,再调用一次 recv(N) 得到体。给出一个不依赖 TCP 丢包的切片序列使它失败,并解释改成 recv(4096) 为什么仍不能解决。

核对:让头按一字节、三字节到达,第一次读取即可不足四字节。增加缓冲上限不能强制凑满,也不能告诉接收器一块数据包含几条消息。应保留解析状态或用循环精确读取,而不是假设网络恰好按应用希望切分。

验证边界与一手资料

本次确定性切片、截断与超限用例通过,真实回环连接的四消息字节一致性检查通过。教学协议没有版本协商、校验、认证、加密或请求标识;不适合作为公网服务协议直接部署。五秒 socket 超时仅限制单次阻塞,不是整条连接的总截止时间。

本篇没有验证 UDP 截断、TCP 重传、公网故障或长期慢速输入。通过解析器用例不能证明所有资源耗尽行为已被控制;回环成功也不能证明生产链路可用。第 03 篇继续观察报文与应用日志如何对应。

资料于 2026-09-19 当次核查。Python 使用 3.14 分支官方文档;在线补丁文档版本与本机 3.14.4 运行版本分开记录。