1、粘包及其成因

1.1、粘包产生

先来看一个案例,单进程启动一个tcp socket通信,从服务端发送两次数据到客户端。

  • 服务端tcp_socket_server1.py
import socket

sk = socket.socket()
sk.bind(("127.0.0.1", 8888))
sk.listen()

conn, address = sk.accept()

conn.send(b'ab')
conn.send(b'cd')

上面的服务端代码创建了一个socket对象,监听的ip地址是本地的127.0.0.1,端口是6666,并且在程序启动并正常和客户端连接进行阻塞后,分别发送了abcd两次数据。

  • 客户端tcp_socket_client1.py
import socket
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8888))
time.sleep(5)

bs1 = sk.recv(1024)
bs2 = sk.recv(1024)
print(bs1)
print(bs2)

上面的客户端代码创建了一个socket对象,连接的ip地址是127.0.0.1,端口是6666,并且在程序启动并正常和客户端连接后,让程序暂停了5秒钟,然后分别接收了bs1bs2两次数据,每次接收的数据大小是1024个字节。

启动server端的程序,在终端查看本地端口情况,server端启动正常并且持续保持着监听

启动client端程序,查看终端的输出,检查server端发送过来的数据

可以发现,在client的终端界面打印出来的两个数据包,第二个数据为空,而第一个打印的是两个数据合在一起的效果,很奇妙,明明我们发了两次,接收的也是两次。
为了证明事故现场的真实性,我调取了相关“监控”,没错,抓包,如下所示

通过上图可以发现,在一次tcp通信中包含了正常的三次握手(前3个数据包)和四次挥手(后4个数据包),其中第158号数据包的作用简单来说就是用于连接上对等双方之间的流控制。具体可参考此文章,第159160号数据包是第一次双方数据通信,第161162号数据包是第二次双方数据通信。两次数据包中分别包含了abcd两个data。但是通过追踪数据流可以发现,两次的数据abcd被合并到了一起,因此接收方第一次就一次性收到了所有的数据,这与上面程序控制台的输出情况是吻合的。

1.2、粘包产生的原因

1.发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据很小,会合到一起,产生粘包),这样接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。

2.接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)

粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。

3.udp通信不会产生粘包现象。原因如下:

  • UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。
    不会使用块的合并优化算法, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。
  • 对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮你封装上消息头发送过去。
  • 不可靠不黏包的udp协议:udprecvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendinto(y),收完了x个字节的数据就算完成,若是y;x数据就丢失,这意味着udp根本不会粘包,但是会丢数据,不可靠。

2、尝试解决粘包

粘包问题的现象及成因如上所示,如何解决上面的黏包问题?其实也很简单,之所以出现黏包就是因为数据没有边界,直接把两个包混合成了一个包,接收端不知道发送端将要传送的字节流的长度。所以解决粘包问题最先能想到的就是在发送正式的数据之前先发送一个包,这个包的内容就是告诉客户端将要发送的正式数据包的长度是多少(这个数据包有多大), 客户端接收数据的时候,先读取该数据包的大小,然后再按照这个大小读取一定长度的数据,最终就不会产生黏包了。

2.1、指定数据包的长度

按照上述思路,对文章开始给出的案例进行改造,在发送数据包之前先发一个包,包的内容是声明正式数据包(下一个数据包)的长度。

  • 服务端tcp_socket_server2.py
import socket

sk = socket.socket()
sk.bind(("127.0.0.1", 8888))
sk.listen()

conn, address = sk.accept()

conn.send(b'2')   # 预先告诉对方下一个数据包的长度
conn.send(b'ab')
conn.send(b'cd')

上面的服务端代码在发送数据包的开始先发送了一个数据包,内容是2,占1个字节,目的是预先告诉对方下一个数据包的长度。然后再分别发送两次数据包abcd

  • 客户端tcp_socket_client2.py
import socket
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8888))
time.sleep(5)

bs = sk.recv(1)  # 先接收到下一个数据包的长度
len = int(bs.decode("utf-8"))
bs1 = sk.recv(len)  # 接受对应长度的字节
bs2 = sk.recv(1024)
print(bs1)
print(bs2)

上面的客户端代码在正式接收数据包之前,先接收了大小为1个字节的数据包,表示接收服务端发来的第一个字节的数据包,此数据包对应的数据正是表示的服务端将要发送的正式数据包的长度,由于发送过来的是bytes类型,需要进行解码以及转换为int类型,转换后的数值就是下一个数据包的长度,然后分别接收了bs1bs2两次数据,第一次接收的数据包长度是前面收到的长度,第二次接收的数据大小是1024个字节。

按照先后启动server端程序和client端程序,检查server端发送过来的数据,可以发现,本次收到的服务端发送过来的两次数据正常接收,没有了粘包现象。

2.2、固定数据包的长度

上一步提到的指定数据包长度的做法,解决了粘包问题。但是,不难想象,如果我们发送的数据包有多次,那么每发送一次数据包之前都需要发送一个声明着下一数据包长度的包,才能让客户端正常接收。有没有可以改进的方案呢,当然是有的。数据包的长度是可变的,很容易想到的一个解决方案就是我把表示正式数据包(下一个数据包)长度的这个数据包的长度固定下来。那么对服务端来说:首先发送固定长度的数据包来表示下一个数据包的长度,然后下一个数据包发送数据;对客户端来说,首先接收的第一个包是固定长度的,然后接收的第二个包就是正式的数据包。

  • 服务端tcp_socket_server3.py
import socket

sk = socket.socket()
sk.bind(("127.0.0.1", 8888))
sk.listen()

conn, address = sk.accept()
msg = input("请输入你要发送的数据>>>:")

bs = msg.encode('utf-8')
chang = len(bs)
chang_str = format(chang, "04d")
conn.send(chang_str.encode("utf-8"))

conn.send(bs)

上面的服务端代码通过运行时接收用户输入来计算数据包的长度并且将表示数据包长度的这个数据包编码以及格式化成4个字节发送个客户端,然后再发送接收到的数据给客户端。

  • 客户端tcp_socket_client3.py
import socket
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8888))
time.sleep(5)

bs = sk.recv(4)
chang = int(bs.decode("utf-8"))
msg = sk.recv(chang)
print(msg.decode("utf-8"))

上面的客户端代码先接收了4个字节的数据包,然后通过对数据解码得到了其中的内容,这个内容就是表示正式数据包(下一个数据包)的长度,然后接收这个长度的数据包。

按照先后启动server端程序和client端程序,检查server端发送过来的数据,效果如下。

2.3、用函数实现多次调用发送数据

通过上面的小小改进,就可以在发送第一个数据包是固定长度,客户端接收的时候也是固定的长度,但是要想轻松的发送多次数据包,这种办法需要每次都执行一遍,更为友好的解决办法是可以将服务端发送数据包的代码以及客户端接收数据包的代码各自提取出一个函数,然后利用函数一次定义、多次调用的好处,每次发送数据,通过函数传参就可以了。此时不管发送多少次,代码只需要写一次就好了。

  • 服务端tcp_socket_server4.py
import socket

sk = socket.socket()
sk.bind(("127.0.0.1", 8888))
sk.listen()
conn, address = sk.accept()


def my_send(msg):
    bs = msg.encode('utf-8')
    chang = len(bs)
    chang_str = format(chang, "04d")
    conn.send(chang_str.encode("utf-8"))
    conn.send(bs)


my_send(input(">>>"))
my_send(input(">>>"))
my_send(input(">>>"))
  • 客户端tcp_socket_client4.py
import socket
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8888))
time.sleep(5)


def my_recv():
    bs = sk.recv(4)
    chang = int(bs.decode("utf-8"))
    msg = sk.recv(chang)
    print(msg.decode("utf-8"))


my_recv()
my_recv()
my_recv()

按照先后启动server端程序和client端程序,检查server端发送过来的数据,效果如下。

服务端

客户端

3、解决粘包问题的正确姿势

按照第2章节中解决和升级解决粘包问题的方案,其实还是有不足的地方,虽然最终写成了函数,发送数据的时候调用就好了,但是程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗。

事实上python提供了一个很好用的模块struct来帮我们解决这个问题,这个模块可以把要发送的数据长度转换成固定长度的字节。这样客户端每次接收消息之前只要先接受这个固定长度字节的内容看一看接下来要接收的信息大小,那么最终接受的数据只要达到这个值就停止,就能刚好不多不少的接收完整的数据了。

3.1、struct模块功能示例

struct模块用来解决bytes和其他二进制数据类型的转换。更多关于struct模块的说明,可以参考此文章

  • 把数字打包成字节

下面的程序先对字符串abcdefg进行了编码成为了字节bytes类型,然后对这个bytes类型的字符串计算了长度,并且按照struct模块把表示长度的数字打包转换成了固定长度的字节,终端输出字节的长度是4,这个4打包时指定的参数i控制,i表示是int类型,而一个int类型占4个字节,每个字节占8bit比特位,因此总共可以表示的数字大小是2^32,也就是4GB大小的长度,而装这个长度是根据发送的信息来计算的。因此,在打包参数为i的情况下,最多能够表示一次发送的数据是4GB,通常情况下,我们一次发送的数据包长度是不会超过4GB的。

import struct

msg = "abcdefg"
bs = msg.encode('utf-8')
bs_struct_len = struct.pack("i", len(bs))
print(bs_struct_len) # 输出 b'\x07\x00\x00\x00'
print(len(bs_struct_len))  # 输出 4
  • 把字节还原成数字

下面的程序把上面通过struct进行打包得到的4个字节的bytes类型数据进行了解包,解包出来返回的值是一个元祖,通过取元祖的第0个元素,最终得到了上面最开始的字符串的长度7

bs = b'\x07\x00\x00\x00'

tuple = struct.unpack("i", bs)
print(tuple)  # 输出 (7,)
num = struct.unpack("i", bs)[0]
print(num)  # 输出 7
print(type(num)) # 输出 int

通过struct可以把数字转换成定长的字节类型数据,也可以把这个转换后的定长字节类型数据还原成数字。对于粘包问题来说,就可以利用这个功能,来表示数据包的长度,服务端先将表示数据包长度的这个数据包转换成字节类型,发送给客户端,客户端再通过解包还原成数字。

3.2、struct优雅的解决粘包问题

struct模块的功能如上所示,因此可以将前面发送固定描述数据包长度的那个数据包用struct来代替。

  • 服务端struct_server1.py
import socket
import struct


sk = socket.socket()
sk.bind(("127.0.0.1", 8999))

sk.listen()
conn, address = sk.accept()

msg = input(">>>:")
bs = msg.encode("utf-8")
msg_len_bs = struct.pack("i", len(bs))
conn.send(msg_len_bs)
conn.send(bs)

msg = input(">>>:")
bs = msg.encode("utf-8")
msg_len_bs = struct.pack("i", len(bs))
conn.send(msg_len_bs)
conn.send(bs)

上面的服务端代码,计算得到用户输入内容的长度,然后把这个数据包通过struct打包发送给了客户端,最后发送数据包内容,程序连续执行了两次。

  • 客户端struct_client1.py
import socket
import struct
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8999))

time.sleep(5)

bs_msg_len = sk.recv(4)
bs_len = struct.unpack("i", bs_msg_len)[0]
bs = sk.recv(bs_len)
print(bs.decode("utf-8"))


bs_msg_len = sk.recv(4)
bs_len = struct.unpack("i", bs_msg_len)[0]
bs = sk.recv(bs_len)
print(bs.decode("utf-8"))

上面的客户端代码,先接收了长度的数据包,然后将这个数据包通过struct解包,然后再接收前面解包得到的数字大小的数据包,程序连续执行了两次。

按照先后启动server端程序和client端程序,检查server端发送过来的数据,效果如下。

服务端

客户端

3.3、struct模块功能函数化

通过上面的struct功能分别连续发送和接收了两次数据,可以将struct打包后发送以及解包后接收的功能写成函数来实现,以后程序发送数据包时只需要调用函数名并传入一个参数(数据包内容)即可。

函数体struct_func.py

import struct

def my_send(sk, msg):
    msg_bs = msg.encode("utf-8")
    msg_struct_len = struct.pack("i", len(msg_bs))

    sk.send(msg_struct_len)
    sk.send(msg_bs)


def my_recv(sk):
    msg_struct_len = sk.recv(4)
    msg_len = struct.unpack("i", msg_struct_len)[0]
    data = sk.recv(msg_len)
    return data.decode("utf-8")

服务端发送数据struct_server2.py

import socket
import struct_func as msu

sk = socket.socket()

sk.bind(("127.0.0.1", 8123))

sk.listen()
conn, addr = sk.accept()

msu.my_send(conn, "abcdefg")
msu.my_send(conn, "1234567")

客户端接收数据struct_client2.py

import socket
import struct_func as msu
import time

sk = socket.socket()
sk.connect(("127.0.0.1", 8123))

time.sleep(5)

print(msu.my_recv(sk))
print(msu.my_recv(sk))

有了公共的函数体,每次发送和接收数据时只需要调用函数进行传参即可,终端输入输出效果同上。

3.4、证实粘包问题被解决

最后,我们启动上面通过struct模块,并调用函数实现发数据和接收数据的方式的程序,再来抓一次包,彻底证明上面的粘包问题得到了解决。

通过上图可以发现,在一次tcp通信中包含了正常的三次握手(前3个数据包)和四次挥手(后4个数据包),第119120号数据包是第一次双方数据通信,第121122号数据包是第二次双方数据通信。两次数据包中分别包含了一个占4字节的数据和一个占18字节的数据。第一次的四字节大小的数据正式通过struct打包的表示长度的数据包,第二次的18字节组成依次是:7个字节的adcdefg数据、4个字节的表示长度的数据、7个字节的1234567数据。加起来正好18个字节,整个数据流依次是:长度数据、真实数据、长度数据、真实数据。

至此,一步步分析和解决python粘包问题的过程就完成啦✌️✌️✌️

本文中涉及到的代码文件以及抓取的数据包地址:
https://github.com/Hargeek/python-nianbao-struct
部分描述参考来源:
https://www.cnblogs.com/Eva-J/articles/8244551.html