程云来 / 杭州
返回文章·计算机网络
·约 14 分钟

网络面试(十二):一次 send 为什么不对应一次 recv?

从 PING 与 STATUS 的真实 TCP 接收轨迹出发,用长度前缀恢复消息边界,验证残缺与超长帧,再区分 Nagle、延迟 ACK 和应用分帧。


收到 GST,是哪条消息?

A 要依次向 S 发送两条命令:PING 和 STATUS。它调用两次写入,S 却不能理所当然地调用两次读取,然后把每次返回值当成一条命令。

在本章的本地 TCP 实验里,A 写入 4 字节与 6 字节,S 每次调用 recv(3)。本机观察到:

text
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):

text
4 字节无符号大端整数 N | N 字节消息体

长度只计算消息体的字节数,不含这 4 字节头。单帧上限设为 4096 字节,允许长度 0 的空消息。文本消息体使用 UTF-8,但分帧器先处理字节,完整消息出来之后再解码文本。

改用两条消息 PING 与 中文,线上字节为:

消息消息体长度4 字节长度头(十六进制)消息体(十六进制)
PING400 00 00 0450 49 4E 47
中文600 00 00 06E4 B8 AD E6 96 87

中文 是两个汉字,但 UTF-8 编码占 6 字节。若头部写 2,接收方就会错误地只提取前两字节,剩下部分还会破坏下一帧的解析。

发送端先编码,再拼接长度头;本实验唯一实现位于 examples/network-interview/core/framing.py:

python
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 保存不完整的下一帧。核心过程如下,完整类与关闭状态检查见实验文件:

python
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

这个循环对应三个等待位置:

  1. 头不足 4 字节,保留数据,等待下一块。
  2. 头已完整,先校验长度;消息体不足 N 字节,继续等待。
  3. 一帧完整就提取;剩余字节可能还含下一帧,所以继续循环。

例如整体 18 字节一次交给 feed,它会返回两条消息。若逐字节交给它,头部与中文编码都被拆开,解析器仍等到各自消息完整后才交付。

真实接收入口把同一个解析器接到 socket 上,而不是每次读取都新建一个:

python
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+ 标准库,无第三方依赖。在仓库根目录运行:

bash
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 与 中文。

稳定结果包括:

text
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 字节
尚未完成的缓存:0 字节

无残留

已恢复消息:0 条

暂无完整消息

预设字节切分的教学解析,不是真实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 选项。