Day30 Socket原理和粘包

一. Socket收发数据原理

数据收发操作是从应用程序调用 write 将要发送的数据交给协议栈开始的,协议栈收到数据之后执行发送操作。

协议栈不会关心应用程序传输过来的是什么数据,因为这些数据最终都会转换为二进制序列,协议栈在收到数据之后并不会马上把数据发送出去,而是会将数据放在发送本地缓冲区,再等待应用程序发送下一条数据。接收方接收后同样把数据放在本地缓冲区中,等待应用程序接收。

为什么收到数据包不会直接发送出去,而是放在缓冲区中呢?

因为只要一旦收到数据就会发送,就有可能发送大量的小数据包,导致网络效率下降。所以协议栈需要将数据积攒到一定数量才能将其发送出去。

基于TCP套接字改进

服务器端

import socket

# 监听本机网卡,端口
ip_port = ('127.0.0.1', 9002)
# 收发消息长度
BUFSIZE = 1024
backlog = 5

# 获取TCP套接字
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 把地址绑定到套接字
s.bind(ip_port)
# 监听链接
s.listen(backlog)

# 一直在进行连接
while True:
     # 接收客户端链接, 返回元组类型数据
     conn, addr = s.accept()
     print('接到clien %s 的请求' %addr[0])

     # 可以一直收发消息
     while True:
     # 接收客户端消息
          # 有可能客户端突然断开连接,服务端直接报错,所以要进行异常处理
          try:
               msg = conn.recv(BUFSIZE)
               print(msg, type(msg))
 # 有可能客户端正常断开连接,如设置了quit,windows操作系统下会一直发空消息
               # if len(msg) == 0:
                    # break     
               
               replyment = 'OK'
               # 发送给客户端消息
               conn.send(replyment.encode('utf-8'))
          except Exception:
               break

# 关闭客户端套接字
conn.close()

客户端

import socket

# 监听本机网卡,端口
ip_port = ('127.0.0.1', 9002)
# 收发消息长度
BUFSIZE = 1024

# 获取客户套接字
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 尝试链接服务器
s.connect_ex(ip_port)

# 可以一直收发消息
while True:
     msg = input('>>: ').strip()
     # 发送消息给服务端
     if not msg:
          continue
     s.send(msg.encode('utf-8'))

     feedback = s.recv(BUFSIZE)
     # 接收服务端消息
     print(feedback.decode('utf-8'))

# 关闭客户端套接字
s.close()

二. UDP套接字

UDP是无连接的,先启动哪一端都可以,而TCP必须先启动服务器端。

服务端

ss = socket()   #创建一个服务器的套接字
ss.bind()       #绑定服务器套接字
inf_loop:       #服务器无限循环
    cs = ss.recvfrom()/ss.sendto() # 对话(接收与发送)
ss.close()                         # 关闭服务器套接字

客户端

cs = socket()   # 创建客户套接字
comm_loop:      # 通讯循环
    cs.sendto()/cs.recvfrom()   # 对话(发送/接收)
cs.close()                      # 关闭客户套接字

UDP套接字实例

服务端

import socket
ip_port = ('127.0.0.1', 9000)
BUFSIZE = 1024
# SOCK_DGRAM表示UDP数据包
udp_server_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

udp_server_client.bind(ip_port)

while True:
     # 返回一个元组,(信息,(源IP地址,源端口号))
     msg,addr = udp_server_client.recvfrom(BUFSIZE)
     print(msg, addr)
     udp_server_client.sendto(msg.upper(), addr)

客户端

import socket
ip_port = ('127.0.0.1', 9000)
BUFSIZE = 1024
udp_server_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

while True:
    msg = input('>>: ').strip()
    if not msg:continue

    udp_server_client.sendto(msg.encode('utf-8'), ip_port)
    # 同样可以调用recv()来接收数据
    back_msg,addr = udp_server_client.recvfrom(BUFSIZE)
    print(back_msg.decode('utf-8'), addr)

服务器绑定UDP端口和TCP端口互不冲突,也就是说,UDP的9999端口和TCP的9999端口可以各自绑定

三.粘包

基于TCP实现远程执行命令

服务端

from socket import *
import subprocess

ip_port=('127.0.0.1',9001)
BUFSIZE=1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(BUFSIZE)

# 连接循环
while True:
     conn, addr = tcp_server.accept()
     print('来自{}的连接'.format(addr))
     
     # 通信循环
     while True:
          # 接收客户端发来的命令
          cmd = conn.recv(BUFSIZE)
          if len(cmd) == 0: break

          res = subprocess.Popen(cmd.decode('utf-8'), shell=True,
                                 stdout=subprocess.PIPE,
                                 stdin=subprocess.PIPE,
                                 stderr=subprocess.PIPE)

          stderr = res.stderr.read()
          stdout = res.stdout.read()
          conn.send(stderr)
          conn.send(stdout)

conn.close()
tcp_server.close()

 

客户端

import socket

ip_port=('127.0.0.1',9001)
BUFSIZE=1024

tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_client.connect_ex(ip_port)

while True:
    msg = input(">>: ").strip()
    if len(msg) == 0: continue
    if msg == 'qiut': break

    tcp_client.send(msg.encode('utf-8'))
    act_res = tcp_client.recv(BUFSIZE)

    print(act_res.decode('gbk'))

tcp_client.close()

输出

>>: dir
 驱动器 C 中的卷是 Windows

 卷的序列号是 4CE9-C6B9

 C:\Users\XXXXX\Desktop 的目录

2023-06-08  17:02    <DIR>          .
2023-06-08  17:02    <DIR>          ..
2023-05-30  22:27               852 a.txt
2022-05-30  17:40               987 Anaconda Navigator (anaconda3).lnk
2022-04-19  21:53               908 Clash for Windows.lnk
2023-06-10  15:33               392 client.py
2023-06-08  20:11               355 client_11.py
2023-05-14  17:40    <DIR>          GHelper
2023-04-16  17:03             2,708 GitHub.lnk
2022-03-22  19:39             1,230 matlab.lnk
2022-11-12  16:11               795 NoteExpress.lnk
2023-06-10  15:33               877 server.py
2023-05-14  20:46               962 test.py
2021-05-11  14:20               846 YY语音.lnk
2017-05-18  13:16             3,291 一键处理.bat
2023-05-26  16:08                 0 初稿.docx
2021-06-01  21:07             2,342 摩尔庄园.lnk
2020-12-31  21:40               607 有道云笔记.lnk
2022-10-29  23:02               319 校园网账

>>: ipconfig
号密码.txt

2023-05-24  13:02         4,762,477 汇报05.09.pptx
2022-12-17  13:26               611 百度网盘.lnk
2023-05-15  10:59             1,301 迅雷.lnk
2022-11-12  16:06             1,231 迅雷影音.lnk
              20 个文件      4,783,091 字节
               3 个目录  6,198,165,504 可用字节

可以看到,在输入命令ipconfig的时候依然输出了上一次dir命令的还未完全输出的信息,即产生了粘包,且此时程序卡住了。命令执行成功可能没有返回值,导致接收缓冲区一直为空,进程卡住,所以也要加入相关逻辑判断

基于UDP实现远程执行命令

服务端

from socket import *
import subprocess

ip_port=('127.0.0.1',9001)
BUFSIZE=1024

udp_server = socket(AF_INET, SOCK_DGRAM)
udp_server.bind(ip_port)

# 通信循环
while True:
     # 接收客户端发来的命令
     cmd, addr = udp_server.recvfrom(BUFSIZE)
     print('接收用户命令:', cmd)

     res = subprocess.Popen(cmd.decode('utf-8'), shell=True,
                            stdout=subprocess.PIPE,
                            stdin=subprocess.PIPE,
                            stderr=subprocess.PIPE)

     stderr = res.stderr.read()
     stdout = res.stdout.read()
     
     udp_server.sendto(stderr, addr)
     udp_server.sendto(stdout, addr)


udp_server.close()

客户端

import socket

ip_port=('127.0.0.1',9001)
BUFSIZE=1024

udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)


while True:
    msg = input(">>: ").strip()

    udp_client.sendto(msg.encode('utf-8'), ip_port)
    act_res= udp_client.recv(BUFSIZE)

    print(act_res.decode('gbk'))

udp_client.close()

输出

>>: dir
 驱动器 C 中的卷是 Windows
 卷的序列号是 4CE9-C6B9

 C:\Users\MichelinMessi\Desktop 的目录

2023-06-08  17:02    <DIR>          .
2023-06-08  17:02    <DIR>          ..
2023-05-30  22:27               852 a.txt
2022-05-30  17:40               987 Anaconda Navigator (anaconda3).lnk
2022-04-19  21:53               908 Clash for Windows.lnk
2023-06-10  15:57               329 client.py
2023-06-08  20:11               355 client_11.py
2023-05-14  17:40    <DIR>          GHelper
2023-04-16  17:03             2,708 GitHub.lnk
2022-03-22  19:39             1,230 matlab.lnk
2022-11-12  16:11               795 NoteExpress.lnk
2023-06-10  15:44               713 server.py
2023-05-14  20:46               962 test.py
2021-05-11  14:20               846 YY语音.lnk
2017-05-18  13:16             3,291 一键处理.bat
2023-05-26  16:08                 0 初稿.docx
2021-06-01  21:07             2,342 摩尔庄园.lnk
2020-12-31  21:40               607 有道云笔记.lnk
2022-10-29  23:02               319 校园网账号密码.txt
2023-05-24  13:02         4,762,477 汇报05.09.pptx
2022-12-17  13:26               611 百度网盘.lnk
2023-05-15  10:59             1,301 迅雷.lnk
2022-11-12  16:06             1,231 迅雷影音.lnk
              20 个文件      4,782,864 字节
               3 个目录  6,965,923,840 可用字节

可以看到dir命令的结果一次性输出完全,不会发生粘包。这个程序有可能会在windows下出错。

四.粘包原理分析

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

TCP应用程序产生数据并传递到传输层时会被切片成一个个数据包,数据包的长度是MSS(Maximum Segment Size,只包含TCP Payload)

而网络接口层(数据链路层)提供给网络层的接口也限制大小,即为MTU(1500),如果大于这个长度就必须要进行切片

TCP是基于字节流的协议,也就是0101的这样的数据,这些0101之间没有任何边界,所以说当这些数据可能被切割和组装成各种数据包,而接收端接收数据包后没能正确还原回原始消息时,就会出现粘包现象

UDP不会产生粘包原理

UDP不同于基于字节流的TCP,它是基于数据报的通信协议。应用层交给UDP多长的报文,UDP都会一次发送,即使长度过长,也不考虑,因为IP层会出手。UDP只会保留这些报文的边界,不会对其进行拆分合并。

总的来说就是,TCP 发送端发 10 次字节流数据,而这时候接收端可以分 100 次去取数据,每次取数据的长度可以根据处理能力作调整;但 UDP 发送端发了 10 次数据报,那接收端就要在 10 次收完,且发了多少,就取多少,确保每次都是一个完整的数据报。

图1 IP协议报头 

 

图2 UDP协议报头  

 

图3 TCP协议报头  

 

IP、UDP报头都指示有数据的长度,都可以以此为数据边界用来切割数据,而TCP因为不保证自己一次传输的数据是一个完整数据,所以是没必要添加长度字段。

这样一看其实UDP和TCP并不需要有什么长度字段就可以知道数据的长度,因为ip报头中就有长度字段,那么可以知道数据的长度就是

len(data) = len(IP) - len(IP Header) - len(UDP/TCP Header)

那么为什么UDP还需要添加一个长度字段?

在前面也讲到TCP把数据发过去的时候,接收端可能没来得及接收导致数据粘连,类似的情况也会发生在UDP身上,但如果UDP添加了一个长度字段,等于有了一个数据边界,就不会产生粘包了。

IP不会产生粘包原理

如果消息过长,IP层会按 MTU 长度把消息分成 N 个切片,每个切片带有自身在包里的位置(offset)和同样的IP头信息。

各个切片在网络中进行传输。每个数据包切片可以在不同的路由中流转,然后在最后的终点汇合后再组装。

在接收端收到第一个切片包时会申请一块新内存,创建IP包的数据结构,等待其他切片分包数据到位。

等消息全部到位后就把整个消息包给到上层(传输层)进行处理。

可以看出整个过程,IP 层从按长度切片到把切片组装成一个数据包的过程中,都只管运输,都不需要在意消息的边界和内容,都不在意消息内容了,那就不会有粘包一说了。

Nagle算法

在早期网络没有那么强大时,TCP发送的小数据并不是立马发送,而是组装多个小数据一起发送,也就是Nagle算法优化

Nagle 算法开启的状态下,数据包在以下两个情况会被发送:

  • 如果包长度达到MSS(或含有Fin包),立刻发送,否则等待下一个包到来;如果下一包到来后两个包的总长度超过MSS的话,就会进行拆分发送;
  • 等待超时(一般为200ms),第一个包没到MSS长度,但是又迟迟等不到第二个包的到来,则立即发送。

当开启Nagle算法时,字节流数据在传输的时候就可能会出现多个数据一起传输,而这样的0101数据没有边界,接收端就可能把它当作一个完整消息进行处理,就导致了粘包现象

但关闭Nagle算法也并不能解决粘包问题,即使数据包一个一个分开发送,但传输到接收端的缓冲区时,如果接收端没有及时对接踵而来的一个一个数据包进行处理,同样会产生粘包

解决粘包

综上所述,粘包出现的原因是在于接收端不能对这些数据包进行正确切割,因为接收端并不知道这些0101字节流的分界在哪,切多切少都会产生粘包,所以说解决办法的思路就是让接收端可以根据其他信息来识别出数据的边界,从而正确还原回原始消息

  • 加入特殊标识:加入头标注之类的标识,接收端在读取到这些标识后就知道这是一个新消息的开始从而进行切割

也有可能在数据中就会出现这些特殊标识,可以通过添加冗余消息CRC——校验字段,检查切割下来的消息是否完整

  • 加入消息长度信息:接收端读取到这个信息之后就会知道,后面这么长的数据就是一个完整消息,如果没有一次性获取到这么长的消息,那么就说明还有数据没传输到,接收端会进行等待。

通过在字节流上添加头部信息来解决粘包

服务端

from socket import *
import struct
import json
import subprocess

ip_port=('127.0.0.1',9000)

tcp_socket_server = socket(AF_INET,SOCK_STREAM)
# 重用地址-端口
tcp_socket_server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)

tcp_socket_server.bind(ip_port)
tcp_socket_server.listen(5)


# 连接循环
while True:
     conn,addr = tcp_socket_server.accept()
     # 通信循环
     while True:
          cmd = conn.recv(1024)
          if not cmd: break
          print('接收客户端命令: ', cmd)

          res = subprocess.Popen(cmd.decode('utf-8'),
                                 shell=True,
                                 stdout=subprocess.PIPE,
                                 stdin=subprocess.PIPE,
                                 stderr=subprocess.PIPE)
          err = res.stderr.read()
          print(err)
          # 如果没有错误消息,则说明正常输出
          if err:
               back_msg = err
          else:
               back_msg = res.stdout.read()

          head_json = json.dumps(headers)
          head_json_bytes = bytes(head_json, encoding='utf-8')

          # 先发报头长度,再发报头,最后发消息
          conn.send(struct.pack('i', len(head_json_bytes)))
          conn.send(head_json_bytes)
          conn.sendall(back_msg)   # 相当于循环的send(),直到发完

conn.close()
tcp_socket_server.close()

 

客户端

import socket
import struct
import json

ip_port=('127.0.0.1',9000)
BUFSIZE=1024

tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_client.connect(ip_port)

while True:
    msg = input(">>: ").strip()
    if len(msg) == 0: continue
    tcp_client.send(msg.encode('utf-8'))

    # 头部长度经过struct后固定为4字节
    head = tcp_client.recv(4)
    head_json_len = struct.unpack('i', head)[0]
    head_json = json.loads(tcp_client.recv(head_json_len).decode('utf-8'))
    data_len = head_json['data_size']

    recv_size = 0
    recv_data = b''
    while recv_size < data_len:
         recv_data += tcp_client.recv(BUFSIZE)
         recv_size = len(recv_data)
    
    print(recv_data.decode('gbk'))

tcp_client.close()

输出

>>: dir
 驱动器 C 中的卷是 Windows

 卷的序列号是 4CE9-C6B9

 C:\Users\XXXXX\Desktop 的目录

2023-06-11  13:42    <DIR>          .
2023-06-11  13:42    <DIR>          ..
2023-05-30  22:27               852 a.txt
2022-05-30  17:40               987 Anaconda Navigator (anaconda3).lnk
2022-04-19  21:53               908 Clash for Windows.lnk
2023-06-11  17:27               776 client.py
2023-06-11  16:08             1,196 client_11.py
2023-05-14  17:40    <DIR>          GHelper
2023-04-16  17:03             2,708 GitHub.lnk
2022-03-22  19:39             1,230 matlab.lnk
2022-11-12  16:11               795 NoteExpress.lnk
2023-06-11  17:26             1,474 server.py
2023-05-14  20:46               962 test.py
2021-05-11  14:20               846 YY语音.lnk
2017-05-18  13:16             3,291 一键处理.bat
2023-06-10  23:21           491,717 初稿.docx
2021-06-01  21:07             2,342 摩尔庄园.lnk
2020-12-31  21:40               607 有道云笔记.lnk
2022-10-29  23:02               319 校园网账号密码.txt
2023-06-11  13:42         4,921,820 汇报05.09.pptx
2022-12-17  13:26               611 百度网盘.lnk
2023-05-15  10:59             1,301 迅雷.lnk
2022-11-12  16:06             1,231 迅雷影音.lnk
              20 个文件      5,435,973 字节
               3 个目录  6,727,237,632 可用字节

原理

把报头做成字典,字典里包含将要发送的真实数据的详细信息,然后json序列化,然后用struck将序列化后的数据长度打包成4个字节

发送时

先发报头长度

再编码报头内容然后发送

最后发真实内容

接收时

先收报头长度,用struct取出来

根据取出的长度收取报头内容,然后解码,反序列化

从反序列化的结果中取出待取数据的详细信息,然后去取真实的数据内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值