TCP是字节流协议,无消息边界,recv()返回内核缓冲区当前数据,导致半包或粘包;需用定长头(如4字节大端长度)+变长体方式帧界定,分阶段读取并循环处理缓冲区。

为什么 recv() 会读到不完整的包或多个包拼在一起
根本原因是 TCP 是字节流协议,没有消息边界概念。recv() 返回的是当前内核缓冲区里「恰好有的数据」,可能少于一个完整业务包(半包),也可能包含两个及以上包(粘包)。这和你的 send() 调用次数、对方发送节奏、网络延迟、Nagle 算法、TCP MSS、接收方调用 recv() 的时机都有关,不是 bug,是设计如此。
常见错误现象包括:JSON 解析失败(JSONDecodeError: Expecting value)、协议头字段读取错位(比如把第二个包的前 2 字节当成本包 length 字段)、服务端 panic 或无限等待。
- 不要依赖「发一次就收一次」——
send()和recv()完全不对等 - 不要在应用层假设包长固定(除非你强制规定且双方严格遵守)
- 不要用
recv(1024)这类固定长度读取来解析变长协议
怎么用定长头 + 变长体定义最简可靠协议
最常用也最易落地的方式:前 4 字节存整个包体(不含头部)的长度,小端或大端需双方约定一致(推荐大端/!I,网络字节序);后续字节为实际 payload。例如:b'\x00\x00\x00\x0c{"cmd":"ping"}' 表示 12 字节 JSON。
关键实操点:
- 发送端必须先
struct.pack('!I', len(payload)),再send()头部 + payload - 接收端要分两阶段读:
recv(4)得到长度字段 →recv(n)读满 n 字节 → 拼接解析 - 必须处理
recv()返回0(对端关闭)或None(非阻塞模式下无数据)的情况 - 不能假设
recv(4)一次就返回 4 字节——它可能只返回 1、2 或 3,需循环补全
示例片段(Python 阻塞 socket):
def recv_full(sock, n):
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionResetError("connection closed unexpectedly")
data += chunk
return data
<h1>接收一个完整包</h1><p>header = recv_full(sock, 4)
body_len = struct.unpack('!I', header)[0]
body = recv_full(sock, body_len)
msg = json.loads(body.decode())如何在 asyncio 或 epoll 场景下避免阻塞读导致协程卡死
在异步环境中直接调用 recv() 会阻塞整个 event loop。必须改用基于 buffer 的增量解析,而不是「等够了再读」。
核心思路:维护一个接收缓冲区(self._buffer: bytes),每次从 socket 收到数据就追加进去;然后在缓冲区里反复查找完整包,提取后切掉已处理部分,剩余未解析数据留在 buffer 中等待下次数据到来。
- 每次收到新数据后,检查
len(buffer) >= 4:若不够,先存着;若够,尝试解出 length 字段 - 解出
n后,检查len(buffer) >= 4 + n:若不够,继续等;若够,截取完整包,更新buffer = buffer[4+n:] - 务必用
while循环处理 buffer,因为一次可能粘了多个包 - buffer 增长无上限?需设最大包长限制(如
MAX_PACKET_SIZE = 4 * 1024 * 1024),超限则断连防攻击
这是真正贴合生产环境的做法——它不依赖系统调用语义,而是把 TCP 流当作无界字节序列来严谨切分。
为什么 Protocol Buffers 或 MessagePack 不能自动解决粘包
它们只是序列化格式,不定义传输层边界。即使你用 person.SerializeToString() 发送,TCP 仍可能把它切成两段,或和下一条消息粘在一起。反序列化函数(如 ParseFromString())要求输入是「完整、干净」的二进制块,传入半包会直接抛 DecodeError。
所以必须前置做帧界定(framing):要么加定长头,要么用分隔符(如 \n,仅适用于纯文本协议),要么用更重的方案如 HTTP/2 或 gRPC 的内置 framing(但那是另一层封装了)。
容易被忽略的一点:Protobuf 的 SerializeToString() 输出不含长度信息,你不能靠它自己判断哪里是结尾;而 SerializeDelimitedToBytes()(Python)虽会 prepend 长度前缀,但它用的是 varint 编码,解析逻辑比固定 4 字节更复杂,且不是所有语言 runtime 都默认支持——跨语言时建议统一用显式 !I 头更可控。


















