Python网络编程之socket粘包问题

网络编程中,每次客户端与服务端约定俗成发送包的大小为1024字节,最大为8192字节。但是即使你设置了8192,硬件网卡每次也只能收到1500字节,这个是由硬件网卡的MTU值(最大传输单元,单位字节)决定的。一般千兆网卡的MTU值是1500字节。当一端给另一端发包超过1024字节,另一端没有循环接收消息,就会产生粘包的问题

粘包的问题看上图

客户端给服务端发送了一个命令请求,服务端的返回体大小为11024字节,但是客户端每次只收1024字节,所以客户端的第一次请求之后,服务端还有10000字节的内容还没给客户端发

当客户端发给第二个请求时,第二个请求的返回体大小为100字节,但是队列中还有10000字节的内容没有发送给客户端,所以此时服务端会将上次剩余的10000字节中再拿出1024字节来发送给客户端

解决粘包问题

解决粘包问题的关键点在于我们能否知道每次需要发送的内容总共有多大,如果我们能知道总共有多大,相应的对端就可以算出我需要循环几次可以吧内容全部读完

先来看看原始会发生粘包情况的代码:

  • server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import socket
import subprocess

ip_port = ('127.0.0.1', 5555)

# step 1: 创建socket套接字, 里面封装了通信协议
s = socket.socket()

# step 2: 绑定IP及端口号
s.bind(ip_port)

# step 3: 监听绑定的端口
s.listen(5)

# 把接收客户端连接请求的操作循环起来就可以接受多个用户的请求啦
# 注意:同一时间只能处理一个客户端的请求,其他连接上的用户会排队等待
# 最后支持多少个用户排队等待,是由listen中的参数决定的(连接池)
while True:

# 等待客户端的连接(阻塞函数)
# step 4: 接受客户端的连接
conn, addr = s.accept()
# conn对象里封装了连接过来的这个客户端的通信线路信息
# 后期跟这个客户端的通信与交互都需要在conn这条通信线路上进行

while True:

# step 5: 接收消息(在conn通道没有被关闭的情况下是阻塞的函数,一旦conn被客户端关闭,该函数将不会阻塞)
recv_data = conn.recv(1024)

# 如果conn通道被客户端主动关闭,recv函数将不再阻塞,recv_data将接收到空字符串
# 通过判断recv_data为空字符串来退出服务端的连接
if len(recv_data) == 0: break

# step 6:处理消息
cmd = str(recv_data, encoding='utf-8')
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
res = p.stdout.read()
send_data = str(res, encoding='utf-8')

# step 7: 发送消息
conn.send(bytes(send_data, encoding='utf-8'))

# step 8: 断开连接
conn.close()
  • client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import socket

ip_port = ('127.0.0.1', 5555)

# step 1: 创建socket套接字, 里面封装了通信协议
s = socket.socket()

# step 2: 连接服务端
s.connect(ip_port)

while True:

# step 3: 发送消息
send_data = input("> ").strip()

# 如果客户端输入了exit,退出循环,主动close掉与服务端的连接
if send_data == "exit": break

# 如果什么也没有输入,重新循环接收输入
if len(send_data) == 0: continue

# 这里注意,和服务端不同的是,服务端找到对端是通过conn对象,而客户端是s对象
# 在Python3.x中,socket对象发送对象必须是字节类型(2.7中可以是字符串)
s.send(bytes(send_data, encoding='utf-8'))

# step 4: 收消息
recv_data = s.recv(1024)
print(str(recv_data, encoding='utf-8'))

# step 5: 断开连接
s.close()

要解决上面粘包的问题,首先用白话来描述一下解决的过程

  • 客户端不知道服务端会返回多少内容,所以客户端接收信息的代码需要循环起来
  • 由于客户端接收循环不知道何时停止,前提需要先知道服务端总共会有多少字节的内容发过来
  • 服务端在发送实际数据之前,先计算要发送的数据有多大,把这个信息提前告知客户端,让客户端计算出需要几次循环才能把我服务端本次产生的数据接收完毕
  • 客户端在接收到服务端发来的总长度的信息时,计算出循环的次数,此时应告知服务器端,可以发送数据了

下面我们就按照上面的思路来实现这段代码

  • server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import socket
import subprocess

ip_port = ('127.0.0.1', 5556)

# step 1: 创建socket套接字, 里面封装了通信协议
s = socket.socket()

# step 2: 绑定IP及端口号
s.bind(ip_port)

# step 3: 监听绑定的端口
s.listen(5)

# 把接收客户端连接请求的操作循环起来就可以接受多个用户的请求啦
# 注意:同一时间只能处理一个客户端的请求,其他连接上的用户会排队等待
# 最后支持多少个用户排队等待,是由listen中的参数决定的(连接池)
while True:

# 等待客户端的连接(阻塞函数)
# step 4: 接受客户端的连接
conn, addr = s.accept()
# conn对象里封装了连接过来的这个客户端的通信线路信息
# 后期跟这个客户端的通信与交互都需要在conn这条通信线路上进行

while True:

# step 5: 接收消息(在conn通道没有被关闭的情况下是阻塞的函数,一旦conn被客户端关闭,该函数将不会阻塞)
recv_data = conn.recv(1024)

# 如果conn通道被客户端主动关闭,recv函数将不再阻塞,recv_data将接收到空字符串
# 通过判断recv_data为空字符串来退出服务端的连接
if len(recv_data) == 0: break

# step 6:处理消息
cmd = str(recv_data, encoding='utf-8')
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
res = p.stdout.read()
send_data = str(res, encoding='utf-8') # utf-8 ---> str ---> utf-8

# ------------与客户端沟通数据大小并发送数据的流程------------
# I. Server ---> Client
# 由于发送数据时必须是字节类型,所以计算长度时,应该先将字符串转为字节类型再计算长度
send_data = bytes(send_data, encoding='utf-8')
# 计算字节长度,因为后面发送信息的时候需要做字符串拼接,所以这里先转成字符类型
length_data = str(len(send_data))
# 通知客户端要要发送数据的总长度
conn.send(bytes('length_data:'+length_data, encoding='utf-8'))

# III. Server <--- Client
# 接收客户端发送的确认开始传输的命令
feedback = str(conn.recv(1024), encoding='utf-8')
if feedback == "confirm":
# IV. Server ---> Client
# 开始向客户端传输数据
conn.send(send_data)

# step 7: 发送消息
conn.send(send_data)

# step 8: 断开连接
conn.close()
  • client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import socket

ip_port = ('127.0.0.1', 5556)

# step 1: 创建socket套接字, 里面封装了通信协议
s = socket.socket()

# step 2: 连接服务端
s.connect(ip_port)

while True:

# step 3: 发送消息
send_data = input("> ").strip()

# 如果客户端输入了exit,退出循环,主动close掉与服务端的连接
if send_data == "exit": break

# 如果什么也没有输入,重新循环接收输入
if len(send_data) == 0: continue

# 这里注意,和服务端不同的是,服务端找到对端是通过conn对象,而客户端是s对象
# 在Python3.x中,socket对象发送对象必须是字节类型(2.7中可以是字符串)
s.send(bytes(send_data, encoding='utf-8'))

# step 4: 收消息
# ------------与服务端沟通数据大小并发送数据的流程------------
# 服务端首先发来的信息是即将发送数据的总长度(字节长度)
length_msg = str(s.recv(1024), encoding='utf-8') # 接收的格式 length_data:11024
if length_msg.startswith("length_data"):
# 取得服务端统计的数据总长度
length_data = int(length_msg.split(":")[1])

# II. Server <--- Client
# 客户端已经接收到数据的总长度,回复服务端可以发送数据
s.send(bytes("confirm", encoding='utf8'))

# V. 接收服务端发送过来的数据
# 累计下载字节统计
recv_total_size = 0
# 累计下载数据
recv_total_data = b''
# 进入循环下载
while recv_total_size < length_data:
recv_data = s.recv(1024)
recv_total_data += recv_data
recv_total_size += len(recv_data)
print("传输总大小为: %s ; 当前已传输大小为: %s" % (length_data, recv_total_size))
# 打印完整的数据
print(str(recv_total_data, encoding='utf-8'))

# step 5: 断开连接
s.close()
```

- <font color="orange">执行结果</font>

```bash
> ifconfig
传输总大小为: 1857 ; 当前已传输大小为: 1024
传输总大小为: 1857 ; 当前已传输大小为: 2048
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=3<RXCSUM,TXCSUM>
inet6 ::1 prefixlen 128
inet 127.0.0.1 netmask 0xff000000
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
nd6 options=1<PERFORMNUD>
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
stf0: flags=0<> mtu 1280
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether 60:03:08:a5:3b:9e
inet6 fe80::6203:8ff:fea5:3b9e%en0 prefixlen 64 scopeid 0x4
inet 192.168.1.103 netmask 0xffffff00 broadcast 192.168.1.255
nd6 options=1<PERFORMNUD>
media: autoselect
status: active
en1: flags=963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX> mtu 1500
options=60<TSO4,TSO6>
ether 72:00:01:f7:dd:70
media: autoselect <full-duplex>
status: inactive
en2: flags=963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX> mtu 1500
options=60<TSO4,TSO6>
ether 72:00:01:f7:dd:71
media: autoselect <full-duplex>
status: inactive
p2p0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 2304
ether 02:03:08:a5:3b:9e
media: autoselect
status: inactive
awdl0: flags=8943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1484
ether 0e:80:6c:2f:ac:25
inet6 fe80::c80:6cff:fe2f:ac25%awdl0 prefixlen 64 scopeid 0x8
nd6 options=1<PERFORMNUD>
media: autoselect
status: active
bridge0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether 62:03:08:5a:2d:00
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x2
member: en1 flags=3<LEARNING,DISCOVER>
ifmaxaddr 0 port 5 priority 0 path cost 0
member: en2 flags=3<LEARNING,DISCOVER>
ifmaxaddr 0 port 6 priority 0 path cost 0
nd6 options=1<PERFORMNUD>
media: <unknown type>
status: inactive
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=3<RXCSUM,TXCSUM>
inet6 ::1 prefixlen 128
inet 127.0.0.1 netmask 0xff000000
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1

>

解决粘包问题实例: