收到 GST,是哪条消息?
A 要依次向 S 发送两条命令:PING 和 STATUS。它调用两次写入,S 却不能理所当然地调用两次读取,然后把每次返回值当成一条命令。
在本章的本地 TCP 实验里,A 写入 4 字节与 6 字节,S 每次调用 recv(3)。本机观察到:
sendall sizes=[4, 6]
recv(3)=[b'PIN', b'GST', b'ATU', b'S']GST 中的 G 属于 PING,ST 属于 STATUS。两条命令的全部字节都按序到达了,问题在于接收方不知道怎样切开它们。
TCP 提供有序字节流,应用协议负责消息边界。 面试里常说的“粘包、拆包”,通常是在描述应用读取结果与预期消息边界不一致;它不是 TCP 把消息内容弄错了。
三种边界,不要混成一种
同一批字节经历三种划分:
| 边界 | 由什么决定 | 能作为业务消息边界吗? |
|---|---|---|
| 应用写入 | 程序调用 send / sendall 的位置 | 对端不能依赖这些调用位置 |
| TCP 分段 | MSS、发送状态、路径与实现等 | TCP 字节流接口不承诺保留它 |
| 应用读取 | 当时可读数据、缓冲上限、API 模式等 | 一次读取可能不足一条,也可能横跨多条 |
recv(3) 中的 3 是本次最多取多少字节,不是“等到凑齐三字节才一定返回”。本例故意设置很小的上限,足以观察读取拆分;它没有证明网络上有四个 TCP 数据段,也没有模拟丢包。Python 3.13 socket.recv
普通 send 还可能只接受部分待发送字节,调用方要处理返回值并继续发送。本实验使用 sendall 完成整段提交;调用成功也不代表对端应用已经处理,更不建立消息边界。Python 3.13 socket.send / sendall
增大读取缓冲、延长 sleep 或改变两次写入的间隔,可能让某一次运行“刚好分开”,但不能成为协议规则。PSH 也不是记录分隔符,不能用它表示一条业务消息结束。RFC 9293 §3.9.1.2
要复用一条连接,就必须在字节内容中提供双方一致的切分规则。
为 PING 和中文定义一个最小协议
本例选择长度前缀(Length Prefix):
4 字节无符号大端整数 N | N 字节消息体长度只计算消息体的字节数,不含这 4 字节头。单帧上限设为 4096 字节,允许长度 0 的空消息。文本消息体使用 UTF-8,但分帧器先处理字节,完整消息出来之后再解码文本。
改用两条消息 PING 与 中文,线上字节为:
| 消息 | 消息体长度 | 4 字节长度头(十六进制) | 消息体(十六进制) |
|---|---|---|---|
| PING | 4 | 00 00 00 04 | 50 49 4E 47 |
| 中文 | 6 | 00 00 00 06 | E4 B8 AD E6 96 87 |
中文 是两个汉字,但 UTF-8 编码占 6 字节。若头部写 2,接收方就会错误地只提取前两字节,剩下部分还会破坏下一帧的解析。
发送端先编码,再拼接长度头;本实验唯一实现位于 examples/network-interview/core/framing.py:
import struct
MAX_FRAME = 4096
def encode_frame(payload: bytes) -> bytes:
if len(payload) > MAX_FRAME:
raise ProtocolError("frame length exceeds 4096")
return struct.pack("!I", len(payload)) + payload这里 ProtocolError 是实验定义的 ValueError 子类,表示当前连接的分帧数据不合法。!I 表示网络字节序的 4 字节无符号整数;大端是双方协议约定,不能用各自机器的默认整数布局代替。Python 3.13 struct 字节序与大小
长度头本身也可能分成几次读取,接收端不能写成“recv 一次拿头,再 recv 一次拿体”。它需要保留尚未处理完的字节。
解析器读什么,保留什么,交付什么?
FrameDecoder.feed(chunk) 的输入是一次读取返回的任意字节块,输出是这次新完成的消息体列表;内部 buffer 保存不完整的下一帧。核心过程如下,完整类与关闭状态检查见实验文件:
self.buffer.extend(chunk)
messages = []
while len(self.buffer) >= 4:
length = struct.unpack("!I", self.buffer[:4])[0]
if length > MAX_FRAME:
self.closed = True
raise ProtocolError("frame length exceeds 4096")
end = 4 + length
if len(self.buffer) < end:
break
messages.append(bytes(self.buffer[4:end]))
del self.buffer[:end]
return messages这个循环对应三个等待位置:
- 头不足 4 字节,保留数据,等待下一块。
- 头已完整,先校验长度;消息体不足 N 字节,继续等待。
- 一帧完整就提取;剩余字节可能还含下一帧,所以继续循环。
例如整体 18 字节一次交给 feed,它会返回两条消息。若逐字节交给它,头部与中文编码都被拆开,解析器仍等到各自消息完整后才交付。
真实接收入口把同一个解析器接到 socket 上,而不是每次读取都新建一个:
def receive_frames(receiver, limit):
decoder = FrameDecoder()
chunks, messages = [], []
while True:
chunk = receiver.recv(limit)
if not chunk:
decoder.finish()
return chunks, messages
chunks.append(chunk)
messages.extend(decoder.feed(chunk))chunks 保存观察轨迹,messages 收集结果,用于这个十余字节实验的展示;服务程序可在 feed 返回完整消息时交给业务处理,不必等整个连接 EOF。
本例以 shutdown(SHUT_WR) 结束测试发送方向,让接收循环退出。长度头才是两条消息的边界,EOF 只是检查最后有没有残缺帧。长度 0 会产生 b'' 消息体;正数缓冲大小的 TCP recv 返回 b'' 则表示接收方向到了 EOF,二者处于不同层次。
运行实验,区分实测与主动切分
需要 Python 3.10+ 标准库,无第三方依赖。在仓库根目录运行:
python3 examples/network-interview/user_code/stream_framing.py端点只使用 127.0.0.1,监听系统随机端口,每次 socket 等待有 3 秒超时。实际验证为 Python 3.13.3 / macOS。先运行 user_code,再按 core/README.md 的符号映射阅读实现。
本机真实 TCP 读取恢复了两条消息。脚本还把独立写出的 18 字节字面数据交给解析器,枚举相邻字节间的所有切分:17 个位置各可切或不切,合计 2^17 = 131,072 种。它们都恢复 PING 与 中文。
稳定结果包括:
PASS loopback frames=['PING', '中文']
PASS all 131072 chunk partitions -> same two messages
PASS zero-length frame is a message, not EOF
PASS reject partial header: EOF in incomplete frame
PASS reject partial body: EOF in incomplete frame
PASS reject oversize header: frame length exceeds 4096
PASS all checks真实 recv 块大小可能随运行环境变化;全切分检查则是主动给解析器安排输入,覆盖整块、多帧、头部与消息体拆分。它验证分帧规则对切分方式不敏感,不宣称观察到了全部网络分段方式。
头部合法,也可能永远收不齐
长度前缀没有自动解决异常输入与资源占用。下面两条失败路径需要分别处理:
| 现象 | 原因 | 本例处理与验证 |
|---|---|---|
EOF 时缓存仍有 00 00,或头为 4 却只有一个消息体字节 | 头或消息体没有传完 | finish() 抛出残缺帧异常,不能把半条消息交给业务 |
| 头部声明 4097 字节 | 超过本例 4096 字节上限 | 读完头立即拒绝,不等到体收齐才判断 |
如果对方声明合法的 4096 字节,却每隔一会只发一个字节,单次 socket 超时未必会到期。部署时还要规定每条消息的完成期限、连接/全局内存预算,以及慢客户端处理策略。本例只有逐次等待超时和单帧限制,不把它写成已经具备这些能力的服务框架。
文本解码、消息类型校验与业务确认也属于后续层。字节帧完整,不代表其中的 UTF-8 合法,也不代表命令执行成功;本例正常消息的文本解码放在完整消息之后。
动手改变输入块,观察解析状态
分别用整块、三字节和逐字节输入走完同一份数据,看缓存何时等待、何时交付 PING 与中文。再试残缺、超限和空消息,区分消息边界与 EOF。
正常数据编码为 PING 与中文:4字节大端长度头 + 消息体。每步送入一个预设字节块。
尚未输入
已输入 0 / 18 字节无残留
暂无完整消息
消息在字节帧完整之后才解码。中文是2个字符、6个UTF-8字节;多帧可以在一次输入中恢复。
预设字节切分的教学解析,不是真实recv或网络分段;浏览器重放已输入前缀展示状态,正式Python实验仍在examples/network-interview/core/framing.py。每帧最多4096字节,分帧先于文本解码。此处只处理固定样本,不是生产解析器或任意输入校验器;不模拟超时、慢客户端、Nagle或业务执行。
长度前缀之外,什么时候用别的规则?
应用也可以按场景选择其他分帧方式。Python Socket HOWTO:Using a Socket
| 规则 | 例子 | 必须约定的细节 |
|---|---|---|
| 固定长度 | 每条状态记录恰好 16 字节 | 字段布局、填充、长度不足时继续读 |
| 分隔符 | 每条文本以换行结束 | 消息内换行怎样转义、最大行长、分隔符跨块处理 |
| 长度前缀 | 本例 4 字节长度 + 消息体 | 字节序、长度含义、上限、残缺处理 |
| 结束发送方向标记结束 | 该方向只传一个整体文档 | EOF 的语义及能否继续发送下一份 |
换行规则适合容易转义的文本,任意二进制负载则不能仅假定它不含分隔符。用 EOF 结束一份消息时,这个发送方向不能再继续写下一份;持续复用连接通常需要其他规则。更复杂的协议可以组合多种规则,但双方必须一致。
Nagle 与延迟 ACK,影响等待,不提供消息边界
为什么有时把请求头和请求体分两次小写入,响应迟迟不来?这可能涉及两个独立策略:
- Nagle 算法在发送端合并小数据。按经典描述,已有未确认数据时,较小的新数据可能等待确认或凑成完整数据段。
- **延迟 ACK(Delayed ACK)**在接收端暂缓部分确认,以减少纯 ACK,或让确认与反向数据一同发送。它有触发和时间限制,不是等应用读取之后才确认。
这是两个方向上的等待策略。RFC 9293 §3.7.4、§3.8.6.3
举一个条件性的请求轨迹:A 先发一小段请求头,该段尚未确认;再写一小段体,可能受 Nagle 暂缓。S 已读到头,但业务必须等体才响应,接收端又暂缓 ACK。后续 ACK 或相关触发出现之后,体才继续发出,于是增加了等待。
这不是所有实现必现的固定延时。协议要求延迟 ACK 的等待小于 0.5 秒,而不是默认一定等 500ms;具体栈的触发、定时与 Nagle 变体会改变结果。本章实验没有测量这种延时。RFC 9293 §3.8.6.3、附录 A.3
若测量证明小写入交互等待影响延迟,可先减少不必要的零碎写入,或按连接需求评估 TCP_NODELAY。它关闭该连接上的 Nagle 策略;不是保证立即到达的开关,也不会关闭接收方的延迟 ACK,更不会使一次写入对应一次读取。具体选项语义需绑定目标系统。OpenBSD tcp(4):TCP_NODELAY
30—60 秒参考回答
TCP 是字节流,不保留 send 的业务边界,一次 recv 可能只得到半条消息,也可能含多条消息的一部分。接收缓冲大小只是上限,所以不能靠 sleep 或增大缓冲解决分帧。应用要约定固定长度、分隔符或长度前缀;我会保留残缺输入,循环提取完整消息,并检查长度上限、EOF 残缺和消息完成期限。Nagle 与延迟 ACK 可以影响小数据等待,TCP_NODELAY 也不能代替消息边界规则。
四个追问及解析
1. 先 recv(4) 读长度,再 recv(N) 读消息体可以吗?
若把每次返回值当成已凑齐,仍会错。两处都可能短读,需要累计;同时必须留下已经读到的下一帧。本例统一缓存后循环解析,避免把读取次数写入协议假设。
2. JSON 能不能天然解决“粘包”?
JSON 定义内容语法,但把多个 JSON 文本直接拼在 TCP 上,仍需双方约定怎样识别边界。可以使用长度前缀、规定转义的逐行格式或具备连续解析能力的明确协议。仅写 json.loads(recv(...)) 并不能保证传入恰好一份文档。
3. 为什么不能每个 recv 块都直接 UTF-8 解码?
一个汉字的编码可能跨块,当前块可能只有部分字节。应先恢复完整消息体再解码,或使用能保存编码状态的增量解码器。消息长度计算以编码后的字节为准,不以字符个数为准。
4. TCP_NODELAY 能不能解决“粘包”?
不能。它改变发送端合并小数据的策略,不改变 TCP 字节流语义;读取缓冲、接收时间和分段仍可让边界不同。分帧是否正确,要用不同输入切分验证,而不是看一次读取刚好分开。
两个易错辨析
“出现粘包说明 Nagle 开着,关掉就行。” 即使关闭 Nagle,接收方仍可能一次读到来自多次写入的字节。Nagle 是可能影响发送节奏的因素,缺少应用分帧规则才是这里的解析问题。
“连接关闭了,缓存里剩多少就当最后一条。” 只有协议明确允许这种结束方式才可以。本例使用长度前缀,EOF 时有残缺必须报错;否则一条被截断的命令会被误当完整命令执行。
实验结束发送方向之后,另一方向是否还可以发?FIN 与 RST 对应用分别意味着什么?下一章沿着半关闭和连接状态解释 TCP 的结束过程。
本章核验资料
核验日期:2026-10-08。实验输出来自 Python 3.13.3 / macOS;官方 API 说明核对 Python 3.13 文档,协议机制核对 TCP 规范。
- RFC 9293:PSH、Nagle、延迟 ACK 及实现变体边界。
- Python socket:send、sendall、recv、shutdown 与超时。
- Python struct:固定宽度与网络字节序。
- Python Socket HOWTO:连续传输的应用分帧需求;HTTP 的现代连接复用细节以本册后续 HTTP 章节为准。
- OpenBSD tcp(4):该系统 TCP_NODELAY 选项。