TCP粘包问题成因及其解决办法

研究TCP粘包问题

近期和同学讨论了一个关于TCP粘包的问题,他在分布式机器学习的数据传输中发现TCP通信出现问题。因此也研究了一下自己之前项目中使用的代码 —— 是通过规定头部字段记录代码长度的方式来实现的。

这里总结一下查到的资料。

引用博客

问题的成因

socket网络程序中,TCP和UDP分别是面向连接和非面向连接的。因此TCP的socket编程,收发两端(客户端和服务器端)都要有成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小、数据量小的数据,合并成一个大的数据块,然后进行封包。

这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。
对于UDP,不会使用块的合并优化算法,这样,实际上目前认为,是由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样对于接收端来说,就容易进行区分处理了,所以UDP不会出现粘包问题。

实际上TCP是面向流的传输协议,而UDP是面向消息的传输协议,如何保护消息边界的问题是解决粘包问题的关键。

产生粘包的两种情况

  • 1发送端需要等缓冲区满才发送出去,造成粘包
  • 2接收方不及时接收缓冲区的包,造成多个包接收

    具体点:
    (1)发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。
    (2)接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。
    粘包情况有两种,一种是粘在一起的包都是完整的数据包,另一种情况是粘在一起的包有不完整的包。
    不是所有的粘包现象都需要处理,若传输的数据为不带结构的连续流数据(如文件传输),则不必把粘连的包分开(简称分包)。但在实际工程应用中,传输的数据一般为带结构的数据,这时就需要做分包处理。
    在处理定长结构数据的粘包问题时,分包算法比较简单;在处理不定长结构数据的粘包问题时,分包算法就比较复杂。特别是粘在一起的包有不完整的包的粘包情况,由于一包数据内容被分在了两个连续的接收包中,处理起来难度较大。实际工程应用中应尽量避免出现粘包现象。

解决方案

  • (1)发送固定长度的消息
  • (2)把消息的尺寸与消息一块发送
  • (3)使用特殊标记来区分消息间隔

之前的代码

之前的一种限定长度的代码为:

发送:

1
2
3
4
5
6
7
@classmethod
def encode_socket_data(cls, data: object) -> bytes:
"""Our protocol is: first 4 bytes signify msg length."""
def int_to_8bytes(a: int) -> bytes:
return binascii.unhexlify(f"{a:0{8}x}")
to_send = Utils.serialize(data).encode()
return int_to_8bytes(len(to_send)) + to_send

接收:

1
2
3
4
5
6
7
8
9
10
11
12
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(peer())
s.sendall(Utils.encode_socket_data(message))
logger.info(f'[p2p] succeed to send BlocksSyncReq to {peer}')
msg_len = int(binascii.hexlify(s.recv(4) or b'\x00'), 16)
data = b''
while msg_len > 0:
tdat = s.recv(1024)
data += tdat
msg_len -= len(tdat)
s.close()

规定开头四字节的长度即可在缓冲区中区分TCP数据包。