粘包問題及解決


一、什么是粘包?

注意:只有TCP有粘包現象,UDP永遠不會粘包,為何,且聽我娓娓道來。

首先需要掌握一個socket收發消息的原理

123-粘包問題-socket收發消息.png?x-oss-process=style/watermark

發送端可以是一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時才會清除緩沖區內容。數據是可靠的,但是會粘包。

二、TCP發送數據的四種情況

123-粘包問題-粘包可能.png?x-oss-process=style/watermark

假設客戶端分別發送了兩個數據包D1和D2給服務端,由於服務端一次讀取到的字節數是不確定的,故可能存在以下4種情況。

  1. 服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包;
  2. 服務端一次接收到了兩個數據包,D1和D2粘合在一起,被稱為TCP粘包;
  3. 服務端分兩次讀取到了兩個數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩余內容,這被稱為TCP拆包;
  4. 服務端分兩次讀取到了兩個數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩余內容D1_2和D2包的整包。

特例:如果此時服務端TCP接收滑窗非常小,而數據包D1和D2比較大,很有可能會發生第五種可能,即服務端分多次才能將D1和D2包接收完全,期間發生多次拆包。

三、粘包的兩種情況

1.發送端需要等緩沖區滿才發送出去,造成粘包(發送數據時間間隔很短,數據了很小,會合到一起,產生粘包)

3.1 服務端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
from socket import *
ip_port = ('127.0.0.1', 8080)

TCP_socket_server = socket(AF_INET, SOCK_STREAM)
TCP_socket_server.bind(ip_port)
TCP_socket_server.listen(5)

conn, addr = TCP_socket_server.accept()

data1 = conn.recv(10)
data2 = conn.recv(10)

print('----->', data1.decode('utf-8'))
print('----->', data2.decode('utf-8'))

conn.close()

3.2 客戶端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
import socket
BUFSIZE = 1024
ip_port = ('127.0.0.1', 8080)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
res = s.connect_ex(ip_port)

s.send('hello'.encode('utf-8'))
s.send('feng'.encode('utf-8'))

2.接收方不及時接收緩沖區的包,造成多個包接收(客戶端發送了一段數據,服務端只收了一小部分,服務端下次再收的時候還是從緩沖區拿上次遺留的數據,產生粘包)

3.3 服務端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
from socket import *
ip_port = ('127.0.0.1', 8080)

TCP_socket_server = socket(AF_INET, SOCK_STREAM)
TCP_socket_server.bind(ip_port)
TCP_socket_server.listen(5)

conn, addr = TCP_socket_server.accept()

data1 = conn.recv(2)  # 一次沒有收完整
data2 = conn.recv(10)  # 下次收的時候,會先取舊的數據,然后取新的

print('----->', data1.decode('utf-8'))
print('----->', data2.decode('utf-8'))

conn.close()

3.4 客戶端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
import socket
BUFSIZE = 1024
ip_port = ('127.0.0.1', 8080)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
res = s.connect_ex(ip_port)

s.send('hello feng'.encode('utf-8'))

123-粘包問題.jpg?x-oss-process=style/watermark

四、補充問題一:為何TCP是可靠傳輸,udp是不可靠傳輸

  • 基於TCP的數據傳輸請參考我的另一篇文章https://www.cnblogs.com/nickchen121/p/11027575.html,TCP在數據傳輸時,發送端先把數據發送到自己的緩存中,然后協議控制將緩存中的數據發往對端,對端返回一個ack=1,發送端則清理緩存中的數據,對端返回ack=0,則重新發送數據,所以TCP是可靠的
  • udp發送數據,對端是不會返回確認信息的,因此不可靠

五、補充問題二:send(字節流)和recv(1024)及sendall

  • recv里指定的1024意思是從緩存里一次拿出1024個字節的數據
  • send的字節流是先放入己端緩存,然后由協議控制將緩存內容發往對端,如果待發送的字節流大小大於緩存剩余空間,那么數據丟失,用sendall就會循環調用send,數據不會丟失

解決粘包問題

問題的根源在於,接收端不知道發送端將要傳送的字節流的長度,所以解決粘包的方法就是圍繞,如何讓發送端在發送數據前,把自己將要發送的字節流總大小讓接收端知曉,然后接收端來一個死循環接收完所有數據。

1.1 服務端

import socket, subprocess

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

server.bind(('127.0.0.1', 8000))
server.listen(5)

while True:
    conn, addr = server.accept()

    print('start...')
    while True:
        cmd = conn.recv(1024)
        print('cmd:', cmd)

        obj = subprocess.Popen(cmd.decode('utf8'),
                               shell=True,
                               stderr=subprocess.PIPE,
                               stdout=subprocess.PIPE)

        stdout = obj.stdout.read()

        if stdout:
            ret = stdout
        else:
            stderr = obj.stderr.read()
            ret = stderr

        ret_len = len(ret)

        conn.send(str(ret_len).encode('utf8'))

        data = conn.recv(1024).decode('utf8')

        if data == 'recv_ready':
            conn.sendall(ret)

    conn.close()

server.close()

1.2 客戶端

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

client.connect(('127.0.0.1', 8000))

while True:
    msg = input('please enter your cmd you want>>>').strip()

    if len(msg) == 0: continue

    client.send(msg.encode('utf8'))
    length = int(client.recv(1024))

    client.send('recv_ready'.encode('utf8'))

    send_size = 0
    recv_size = 0

    data = b''

    while recv_size < length:
        data = client.recv(1024)
        recv_size += len(data)

    print(data.decode('utf8'))

1.3 為何low

程序的運行速度遠快於網絡傳輸速度,所以在發送一段字節前,先用send去發送該字節流長度,這種方式會放大網絡延遲帶來的性能損耗

二、補充struct模塊

2.1 簡單使用

124-解決粘包問題-struct模塊參數.png?x-oss-process=style/watermark

import struct
import json

# 'i'是格式
try:
    obj = struct.pack('i', 1222222222223)
except Exception as e:
    print(e)
    obj = struct.pack('i', 1222)
print(obj, len(obj))
'i' format requires -2147483648 <= number <= 2147483647
b'\xc6\x04\x00\x00' 4
res = struct.unpack('i', obj)
print(res[0])
1222

三、解決粘包問題

解決粘包問題的核心就是:為字節流加上自定義固定長度報頭,報頭中包含字節流長度,然后一次send到對端,對端在接收時,先從緩存中取出定長的報頭,然后再取真實數據。

3.1 使用struct模塊創建報頭

import json
import struct

header_dic = {
    'filename': 'a.txt',
    'total_size':
    111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222223131232,
    'hash': 'asdf123123x123213x'
}

header_json = json.dumps(header_dic)

header_bytes = header_json.encode('utf-8')
print(len(header_bytes))

# 'i'是格式
obj = struct.pack('i', len(header_bytes))
print(obj, len(obj))
223
b'\xdf\x00\x00\x00' 4
res = struct.unpack('i', obj)
print(res[0])
223

3.2 服務端

from socket import *
import subprocess
import struct
import json

server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8000))
server.listen(5)

print('start...')
while True:
    conn, client_addr = server.accept()
    print(conn, client_addr)

    while True:
        cmd = conn.recv(1024)

        obj = subprocess.Popen(cmd.decode('utf8'),
                               shell=True,
                               stderr=subprocess.PIPE,
                               stdout=subprocess.PIPE)

        stderr = obj.stderr.read()
        stdout = obj.stdout.read()

        # 制作報頭
        header_dict = {
            'filename': 'a.txt',
            'total_size': len(stdout) + len(stderr),
            'hash': 'xasf123213123'
        }
        header_json = json.dumps(header_dict)
        header_bytes = header_json.encode('utf8')

        # 1. 先把報頭的長度len(header_bytes)打包成4個bytes,然后發送
        conn.send(struct.pack('i', len(header_bytes)))
        # 2. 發送報頭
        conn.send(header_bytes)
        # 3. 發送真實的數據
        conn.send(stdout)
        conn.send(stderr)

    conn.close()

server.close()

3.3 客戶端

from socket import *
import json
import struct

client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1', 8000))

while True:
    cmd = input('please enter your cmd you want>>>')

    if len(cmd) == 0: continue

    client.send(cmd.encode('utf8'))

    # 1. 先收4個字節,這4個字節中包含報頭的長度
    header_len = struct.unpack('i', client.recv(4))[0]

    # 2. 再接收報頭
    header_bytes = client.recv(header_len)

    # 3. 從包頭中解析出想要的東西
    header_json = header_bytes.decode('utf8')
    header_dict = json.loads(header_json)
    total_size = header_dict['total_size']

    # 4. 再收真實的數據
    recv_size = 0
    res = b''
    while recv_size < total_size:
        data = client.recv(1024)

        res += data
        recv_size += len(data)

    print(res.decode('utf8'))

client.close()

四、TCP協議粘包問題分析

1.nagle算法規定,TCP協議會將數據量較小、時間間隔短的數據合並為一條發送給客戶端

4.1 服務端

from socket import *

server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8080))
server.listen(5)

conn, addr = server.accept()

# 正確做法,客戶端制作報頭
# res1 = conn.recv(5)
# print('第一次;', res1)

# res2 = conn.recv(5)
# print('第二次;', res2)

# res3 = conn.recv(4)
# print('第三次;', res3)


# low方法+客戶端的睡眠
res1 = conn.recv(1024)
print('第一次;', res1)

res2 = conn.recv(1024)
print('第二次;', res2)

res3 = conn.recv(1024)
print('第三次;', res3)

4.2 客戶端

from socket import *
import time

client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1', 8080))

client.send(b'hello')
client.send(b'world')
client.send(b'nick')
# 服務端收到b'helloworldnick'

client.send(b'hello')
time.sleep(0.2)
# 服務端收到b'hello'

client.send(b'world')
time.sleep(0.2)
# 服務端收到b'world'

client.send(b'nick')
# 服務端收到b'nick'

2.接收方不及時接收緩沖區的包,造成多個包接收(客戶端發送了一段數據,服務端只收了一小部分,服務端下次再收的時候還是從緩沖區拿上次遺留的數據,產生粘包)

4.3 服務端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
from socket import *
ip_port = ('127.0.0.1', 8080)

TCP_socket_server = socket(AF_INET, SOCK_STREAM)
TCP_socket_server.bind(ip_port)
TCP_socket_server.listen(5)

conn, addr = TCP_socket_server.accept()

data1 = conn.recv(2)  # 一次沒有收完整
data2 = conn.recv(10)  # 下次收的時候,會先取舊的數據,然后取新的

print('----->', data1.decode('utf-8'))
print('----->', data2.decode('utf-8'))

conn.close()

4.4 客戶端

# _*_coding:utf-8_*_
__author__ = 'nickchen121'
import socket
BUFSIZE = 1024
ip_port = ('127.0.0.1', 8080)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
res = s.connect_ex(ip_port)

s.send('hello feng'.encode('utf-8'))


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM