socket套接字的黏包问题

一, socket缓冲区

  • 每个socket被创建后都会分配两个缓冲区,输入缓冲区和输出缓冲区.write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器.一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情.TCP协议独立于write()/send()函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况,当前线程是否空闲等诸多因素,不由程序员控制.read()/recv()函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取

  • 这些I/O缓冲区特性可整理如下:

    • I/O缓冲区在每个TCP套接字中单独存在
    • I/O缓冲区在创建套接字时自动生成
    • 即使关闭套接字也会继续传送输出缓冲区中遗留的数据
    • 关闭套接字将丢失输入缓冲区中的数据
  • 输入输出缓冲区的默认大小一般都是8 K

    import socket
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)  # 重用ip地址和端口
    server.bind(('127.0.0.1',8010))
    server.listen(3)
    print(server.getsockopt(socket.SOL_SOCKET,socket.SO_SNDBUF))  # 输出缓冲区大小
    print(server.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF))  # 输入缓冲区大小

二, 黏包的原因

  • 只有TCP有粘包现象,UDP永远不会粘包

  • 发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因.而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的.

  • 怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区

  • 例如基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始,在何处结束

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

  • 此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段.若连续几次需要send的数据都很少,通常TCP会根据优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据

  • TCP(transport control protocol 传输控制协议)是面向连接的,面向流的,提供高可靠性服务.收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包.这样,接收端,就难于分辨出来了,必须提供科学的拆包机制.即面向流的通信是无消息保护边界的.

  • UDP(user datagram protocol 用户数据报协议)是无连接的,面向消息的,提供高效率服务.不会使用块的合并优化算法, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了.即面向消息的通信是有消息保护边界的.

  • tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头.

  • udp的recvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendinto(y),收完了x个字节的数据就算完成,若是y>x数据就丢失,这意味着udp根本不会粘包,但是会丢数据,不可靠.

  • tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容.数据是可靠的,但是会粘包.

  • 两种情况下会发生粘包:

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

      # 服务端
      import socket
      import subprocess
      phone = socket.socket()
      phone.bind(('127.0.0.1', 8080))
      phone.listen(5)
      
      while 1:
          conn, client_addr = phone.accept()
          while 1:
              try:
                  cmd = conn.recv(1024)
                  ret = subprocess.Popen(cmd.decode('utf-8'),
                                         shell=True,
                                         stdout=subprocess.PIPE,
                                         stderr=subprocess.PIPE)
                  correct_msg = ret.stdout.read()
                  error_msg = ret.stderr.read()
                  conn.send(correct_msg + error_msg)
              except ConnectionResetError:
                  break
          conn.close()
      phone.close()
      ---------------------------------------------------
      # 客户端
      import socket
      phone = socket.socket() 
      phone.connect(('127.0.0.1', 8080))
      
      while 1:
          cmd = input('>>>')
          phone.send(cmd.encode('utf-8'))
      
          from_server_data = phone.recv(1024)
      
          print(from_server_data.decode('gbk'))
      
      phone.close() 
      # 由于客户端发的命令获取的结果大小已经超过1024,那么下次在输入命令,会继续取上次残留到缓存区的数据
    • 发送数据时间间隔很短,数据也很小,会合到一起,产生粘包

      # 服务端
      import socket
      
      phone = socket.socket()
      phone.bind(('127.0.0.1', 8080))
      phone.listen(5)
      conn, client_addr = phone.accept()
      
      frist_data = conn.recv(1024)
      print('1:', frist_data.decode('utf-8'))  # 1: helloworld
      second_data = conn.recv(1024)
      print('2:', second_data.decode('utf-8'))
      
      conn.close()
      phone.close()
      --------------------------------------------------
      # 客户端
      import socket
      
      phone = socket.socket()  
      phone.connect(('127.0.0.1', 8080)) 
      
      phone.send(b'hello')
      phone.send(b'world')
      
      phone.close()  
      
      # 两次返送信息时间间隔太短,数据小,造成服务端一次收取

三, 黏包的解决方案

  1. struct模块: 该模块可以把一个类型,如数字,转成固定长度的bytes

    import struct
    # 将一个数字转化成等长度的bytes类型。
    ret = struct.pack('i', 183346)
    print(ret, type(ret), len(ret))
    
    # 通过unpack反解回来
    ret1 = struct.unpack('i', ret)[0]
    print(ret1, type(ret1), len(ret1))
    
    # 但是通过struct处理不能处理太大的数字
    ret = struct.pack('l', 4323241232132324)
    print(ret, type(ret), len(ret))  # 报错
  2. 解决方案一:

    问题的根源在于,接收端不知道发送端将要传送的字节流的长度,所以解决粘包的方法就是围绕,如何让发送端在发送数据前,把自己将要发送的字节流总数按照固定字节发送给接收端后面跟上总数据,然后接收端先接收固定字节的总字节流,再来一个死循环接收完所有数据

    # 服务端
    import socket
    import subprocess
    import struct
    
    phone = socket.socket()
    phone.bind(('127.0.0.1', 8080))
    phone.listen(5)
    
    while 1:
        conn, client_addr = phone.accept()
        while 1:
            try:
                cmd = conn.recv(1024)
                ret = subprocess.Popen(cmd.decode('utf-8'),
                                       shell=True,
                                       stdout=subprocess.PIPE,
                                       stderr=subprocess.PIPE)
                correct_msg = ret.stdout.read()
                error_msg = ret.stderr.read()
                msg = correct_msg + error_msg
                # 1 制作固定报头
                total_size = len(msg)
                header = struct.pack('i', total_size)
    
                # 2 发送报头
                conn.send(header)
    
                # 发送真实数据:
                conn.send(msg)
            except ConnectionResetError:
                break
    
        conn.close()
    phone.close()
    ------------------------------------------------------
    # 客户端
    import socket
    import struct
    
    phone = socket.socket()
    phone.connect(('127.0.0.1', 8080))
    
    while 1:
        cmd = input('>>>').strip()
        if not cmd:
            continue
        phone.send(cmd.encode('utf-8'))
    
        # 1,接收固定报头
        header = phone.recv(4)
        # 2,解析报头
        total_size = struct.unpack('i', header)[0]
        # 3,根据报头信息,接收真实数据
        recv_size = 0
        res = b''
        while recv_size < total_size:
            recv_data = phone.recv(1024)
            res += recv_data
            recv_size += len(recv_data)
        print(res.decode('gbk'))
    phone.close()
    
    # 但是第一版本有问题:
    # 1,报头不只有总数据大小,而是还应该有MD5数据,文件名等等一些数据。
    # 2,通过struct模块直接数据处理,不能处理太大。
  3. 优化版:可自定制报头版

    可以把报头做成字典,字典里包含将要发送的真实数据的描述信息(大小啊之类的), 然后json序列化,然后用struck将序列化后的数据长度打包成4个字节.(我们在网络上传输的所有数据 都叫做数据包,数据包里的所有数据都叫做报文,报文里面不止有你的数据,还有ip地址,mac地址,端口号等等,其实所有的报文都有报头,这个报头是协议规定的)

    发送时:
    先发报头长度
    再编码报头内容然后发送
    最后发真实内容

    接收时:
    先收报头长度,用struct取出来
    根据取出的长度收取报头内容,然后解码,反序列化
    从反序列化的结果中取出待取数据的描述信息,然后去取真实的数据内容

    # 服务端
    import socket
    import json
    import struct
    import hashlib
    import subprocess
    
    my_cmd = socket.socket()
    my_cmd.bind(('192.168.137.1', 2019))
    my_cmd.listen(3)
    
    while 1:
        conn, addr = my_cmd.accept()
        while 1:
            try:
                f_c = conn.recv(1024).decode('utf-8')
                cmd_f_c = subprocess.Popen(f_c,
                                           shell=True,
                                           stdout=subprocess.PIPE,
                                           stderr=subprocess.PIPE
                                           )
                data = cmd_f_c.stdout.read() + cmd_f_c.stderr.read()
                data = data.decode('gbk').encode('utf-8')
                md5 = hashlib.md5()
                md5.update(data)
                md5_data = md5.hexdigest()
                # 1.制作报头
                head_dic = {'md5': md5_data,
                            'file_name': '小马',
                            'file_size': len(data)}
                # 2.将报头转化成json并转化成bytes类型
                head_dic_json_bytes = json.dumps(head_dic).encode('utf-8')
                # 3.获取bytes类型报头的长度并转换为4个字节长度的bytes类型
                len_head_dic_bytes = struct.pack('i', len(head_dic_json_bytes))
                # 4.发送报头的长度
                conn.send(len_head_dic_bytes)
                # 5.发送报头
                conn.send(head_dic_json_bytes)
                # 6.发送原数据
                conn.send(data)
            except Exception:
                break
        conn.close()
    my_cmd.close()
    -------------------------------------------------------
    # 客户端
    import socket
    import struct
    import json
    
    my_cmd = socket.socket()
    my_cmd.connect(('192.168.137.1', 2019))
    
    while 1:
        cmd = input('请输入命令:').strip()
        if not cmd:
            continue
        my_cmd.send(cmd.encode('utf-8'))
        # 1.获取bytes类型报头的长度
        len_head_dic_bytes = my_cmd.recv(4)
        len_head_dic = struct.unpack('i', len_head_dic_bytes)[0]
        # 2.获取报头并反转回来
        head_dic_json_bytes = my_cmd.recv(len_head_dic)
        head_dic_json = head_dic_json_bytes.decode('utf-8')
        head_dic = json.loads(head_dic_json)
        print(f'文件名:{head_dic["file_name"]}\t文件的MD5:{head_dic["md5"]}\t文件长度:{head_dic["file_size"]}')
        # 3.从报头中取得原数据的bytes长度
        len_data = head_dic['file_size']
        # 4.根据原数据的bytes长度循环取得原数据
        data = b''
        while len(data) < len_data:
            data += my_cmd.recv(1024)
        print(data.decode('utf-8'))
    my_cmd.close()
12-23 11:26