一,黏包现象
我们通过一段简单程序来看看黏包现象:
import socketsk=socket.socket()sk.bind(('127.0.0.1',8090))sk.listen()conn,addr=sk.accept()while True: cmd=input(">>>") if cmd=='q': conn.send(b'q') break conn.send(cmd.encode('gbk')) res=conn.recv(1024).decode('gbk') print(res)conn.close()sk.close()
imsocketimport subprocesssk=socket.socket()sk.connect(('127.0.0.1',8090))while True: cmd=sk.recv(1024).decode('gbk') if cmd=='q': break res=subprocess.Popen(cmd,shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) sk.send(res.stdout.read()) sk.send(res.stderr.read())sk.close()
同时执行多条命令之后,得到的结果很可能只有一部分,在执行其他命令时又会接到之前执行的另外一部分结果,这种就是黏包。
只有tcp有黏包现象,udp不会黏包。
二,黏包成因
tcp协议中的数据传递
tcp协议的拆包机制
当发送端缓冲区的长度大于网卡的MTU时,tcp会将这次发送的数据拆成几个数据包发送出去。 MTU是Maximum Transmission Unit的缩写。意思是网络上传送的最大数据包。MTU的单位是字节。 大部分网络设备的MTU都是1500。 如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片,增加丢包率,降低网络速度。
面向流的通信特点和Nagle(优化)算法
TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。 对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮你封装上消息头发送过去。 可靠黏包的tcp协议:tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容。数据是可靠的,但是会粘包。
基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始, 在何处结束此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少, 通常TCP会根据优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。
三,会发生黏包的两种情况
1,发送方的缓存机制:发送端需要等缓冲区满才发送出去,造成黏包(发送数据时间间隔很短,数据很小,会合到一起,产生黏包)
例:连续send两次且数据很小
import socketsk = socket.socket()sk.connect(('127.0.0.1',8090))sk.send(b'hello')sk.send(b'egg')sk.close()
import socketsk = socket.socket()sk.bind(('127.0.0.1',8090))sk.listen()conn,addr = sk.accept()ret2 = conn.recv(10)print(ret2)conn.close()sk.close()
2,接收方的缓存机制:接收不及时接收缓冲区的包,造成多个包接收(客户端发送一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿走上次剩余的数据,产生黏包。)
例:连续recv两次且第一个recv接收的数据小
import socketsk = socket.socket()sk.connect(('127.0.0.1',8090))sk.send(b'hello,egg')sk.close()
import socketsk = socket.socket()sk.bind(('127.0.0.1',8090))sk.listen()conn,addr = sk.accept()ret = conn.recv(2)ret2 = conn.recv(10)print(ret)print(ret2)conn.close()sk.close()
总结:
1,表面上看,黏包问题主要是因为发送端和接收端的缓存机制、tcp协议面向流通信的特点。
2,实际上,主要还是因为接收端不知道消息之间的界限,不知道一次性提取多少次字节的数据所造成的。
四,黏包的解决
1,将要发送的字节流总大小发给接收端,然后接收端来一个循环接收完所有数据。
import socketsk = socket.socket()sk.bind(('127.0.0.1',8080))sk.listen()conn,addr = sk.accept()while True: cmd = input('>>>') if cmd == 'q': conn.send(b'q') break conn.send(cmd.encode('gbk')) num = conn.recv(1024).decode('utf-8') # 2048 conn.send(b'ok') res = conn.recv(int(num)).decode('gbk') print(res)conn.close()sk.close()
import socketimport subprocesssk = socket.socket()sk.connect(('127.0.0.1',8080))while True: cmd = sk.recv(1024).decode('gbk') if cmd == 'q': break res = subprocess.Popen(cmd,shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) std_out = res.stdout.read() std_err = res.stderr.read() sk.send(str(len(std_out)+len(std_err)).encode('utf-8')) #2000 sk.recv(1024) # ok sk.send(std_out) sk.send(std_err)sk.close()
问题:程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用send去发送该字节流长度,这种方式会放大网路延迟带来的性能损耗。send sendto在超过一定范围的时候会报错
2,进阶方案
struck模块,该模块可以把一个类型,例,数字,转换成固定的长度bytes类型
import structs=struct.pack('i',23) # i ,整数类型,不管数字有多大 ,都转换成4个字节长度print(s) #b'\x84\x1a\x00\x00's1=struct.unpack('i',s) #取出数字print(s1) #元组(23,)
import json,struct#假设通过客户端上传1T:1073741824000的文件a.txt#为避免粘包,必须自定制报头header={ 'file_size':1073741824000,'file_name':'/a/b/c/d/e/a.txt','md5':'8f6fbf8347faa4924a76856701edb0f3'} #1T数据,文件路径和md5值#为了该报头能传送,需要序列化并且转为byteshead_bytes=bytes(json.dumps(header),encoding='utf-8') #序列化并转成bytes,用于传输#为了让客户端知道报头的长度,用struck将报头长度这个数字转成固定长度:4个字节head_len_bytes=struct.pack('i',len(head_bytes)) #这4个字节里只包含了一个数字,该数字是报头的长度#客户端开始发送conn.send(head_len_bytes) #先发报头的长度,4个bytesconn.send(head_bytes) #再发报头的字节格式conn.sendall(文件内容) #然后发真实内容的字节格式#服务端开始接收head_len_bytes=s.recv(4) #先收报头4个bytes,得到报头长度的字节格式x=struct.unpack('i',head_len_bytes)[0] #提取报头的长度head_bytes=s.recv(x) #按照报头长度x,收取报头的bytes格式header=json.loads(json.dumps(header)) #提取报头#最后根据报头的内容提取真实的数据,比如real_data_len=s.recv(header['file_size'])s.recv(real_data_len)
使用struck解决黏包:
把报头做成字典,字典里要包含将要发送真实数据的详细信息,然后json序列化,然后用struck将序列化后的数据长度打包成4个字节。
发送时:先发报头长度,再编码报头内容然后发送,最后发真实内容。
接收时:先收报头长度,用struct取出来,根据取出的长度收取报头内容,解码,反序列化,从反序列化的结果中取出待取数据的详细信息,然后去取真实的数据内容
# 发送端import osimport jsonimport structimport socketsk = socket.socket()sk.connect(('127.0.0.1',8090))buffer = 1024# 发送文件head = { 'filepath':r'F:\Program Files\feiq\Recv Files\day23', 'filename':r'04 python fullstack s9day23 组合2.mp4', 'filesize':None}file_path = os.path.join(head['filepath'],head['filename'])filesize = os.path.getsize(file_path)head['filesize'] = filesizejson_head = json.dumps(head) # 字典转成了字符串bytes_head = json_head.encode('utf-8') # 字符串转bytes# 计算head的长度head_len = len(bytes_head) # 报头的长度pack_len = struct.pack('i',head_len)sk.send(pack_len) # 先发报头的长度sk.send(bytes_head) # 再发送bytes类型的报头with open(file_path,'rb') as f: while filesize: print(filesize) if filesize >= buffer: content = f.read(buffer) # 每次读出来的内容 sk.send(content) filesize -= buffer else: content = f.read(filesize) sk.send(content) breaksk.close()
import jsonimport socketimport structsk = socket.socket()sk.bind(('127.0.0.1',8090))sk.listen()buffer = 1024conn,addr = sk.accept()# 接收head_len = conn.recv(4)head_len = struct.unpack('i',head_len)[0]json_head = conn.recv(head_len).decode('utf-8')head = json.loads(json_head)filesize = head['filesize']with open(head['filename'],'wb') as f: while filesize: print(filesize) if filesize >= buffer: content = conn.recv(buffer) f.write(content) filesize -= buffer else: content = conn.recv(filesize) f.write(content) breakconn.close()sk.close()
五,socket的更多方法
服务端套接字函数s.bind() 绑定(主机,端口号)到套接字s.listen() 开始TCP监听s.accept() 被动接受TCP客户的连接,(阻塞式)等待连接的到来客户端套接字函数s.connect() 主动初始化TCP服务器连接s.connect_ex() connect()函数的扩展版本,出错时返回出错码,而不是抛出异常公共用途的套接字函数s.recv() 接收TCP数据s.send() 发送TCP数据s.sendall() 发送TCP数据s.recvfrom() 接收UDP数据s.sendto() 发送UDP数据s.getpeername() 连接到当前套接字的远端的地址s.getsockname() 当前套接字的地址s.getsockopt() 返回指定套接字的参数s.setsockopt() 设置指定套接字的参数s.close() 关闭套接字面向锁的套接字方法s.setblocking() 设置套接字的阻塞与非阻塞模式s.settimeout() 设置阻塞套接字操作的超时时间s.gettimeout() 得到阻塞套接字操作的超时时间面向文件的套接字的函数s.fileno() 套接字的文件描述符s.makefile() 创建一个与该套接字相关的文件