基於TCP協議產生的粘包問題及解決辦法

須知: 只有TCP有粘包現象, UDP永遠不會粘包, 這是socket收發消息的原理導致的.
Socket收發消息原理

對於TCP協議發送端可以是一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協議會幫你封裝上消息頭,實驗略

有兩種情況會發生粘包問題

  • 發送端需要等緩衝區滿才發送出去,造成粘包(發送數據時間間隔很短,數據了很小,會合到一起,產生粘包)
  • 接收方不及時接收緩衝區的包,造成多個包接收(客戶端發送了一段數據,服務端只收了一小部分,服務端下次再收的時候還是從緩衝區拿上次遺留的數據,產生粘包)

解決粘包問題

我們通過上面的分析發現, 粘包問題出現的最根本原因就是接收方不知道該提取多少字節, 那麼想要解決粘包問題就變得很簡單了, 讓接收方知道提取多少字節, 粘包問題不就解決了. 那麼如何讓接收方知道應該提取多少字節呢? 且看下面分析
那麼我們可以很容易的就確定要發送的數據長度, 問題的關鍵在於, 長度不是固定的(栗子: 148bytes和10014bytes)類似這種的應該如何發送呢?
這時, 我們就要用到模塊來幫助我們實現了.
首先, 我們來學習一下struct模塊的使用
該模塊可以把一個數據類型,如數字,轉成固定長度的bytes
Struct模塊的模式表格
struct模塊可以將不定長度的數字轉換成定長的bytes類型, 那麼我們就可以通過struct模塊將我們要發送的數據長度打包成一個固定長度的bytes然後發送過去. 這樣接收方就只需要接收定長的bytes然後反解出要發送數據的長度就解決了粘包問題

代碼示例:

# 服務端
from socket import *
import subprocess
import struct

# 創建服務端套接字對象
server = socket(AF_INET, SOCK_STREAM)
# 綁定ip和端口號
server.bind(('127.0.0.1', 8080))
# 進入監聽狀態
server.listen(5)

# 循環接收客戶端連接請求
while True:
    conn, client_addr = server.accept()  # (連接對象,客戶端的ip和端口)
    print(client_addr)
    # 循環與客戶端進行通訊
    while True:
        try:
            cmd = conn.recv(1024)
            obj = subprocess.Popen(cmd.decode('utf-8'),
                                   shell=True,
                                   stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE
                                   )
            stdout = obj.stdout.read()
            stderr = obj.stderr.read()

            # 1、製作固定長度的報頭
            total_size = len(stdout) + len(stderr)
            header = struct.pack('i', total_size)

            # 2、發送報頭
            conn.send(header)

            # 3、發送真實的數據
            conn.send(stdout)
            conn.send(stderr)
        except ConnectionResetError:
            break
            
	# 回收資源
    conn.close()
server.close()

# 客戶端
from socket import *
import struct

# 創建客戶端套接字對象
client = socket(AF_INET, SOCK_STREAM)
# 連接服務端ip和端口號
client.connect(('127.0.0.1', 8080))

# 循環與服務端進行通訊
while True:
    cmd = input('>>>: ').strip()
    if not cmd: continue
    client.send(cmd.encode('utf-8'))
    # 1、先收固定長度的報頭
    header = client.recv(4)
    # 2、解析報頭
    total_size = struct.unpack('i', header)[0]
    print(total_size)  # 1025
    # 3、根據報頭內的信息,收取真實的數據
    recv_size = 0
    res = b''
    while recv_size < total_size:
        recv_data = client.recv(1024)
        res += recv_data
        recv_size += len(recv_data)

    print(res.decode('gbk'))
client.close()

至此, 粘包問題確實解決了, 但是解決的不夠完美, 我們只發送了數據長度, 讓接收方知道該接收多少數據, 那麼我們是不是可以把對數據的描述信息等一起發送過去呢?

解決粘包問題終極版

小二, 上代碼!

# 服務端
from socket import *
import subprocess
import struct
import json

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

while True:
    conn, client_addr = server.accept()  # (連接對象,客戶端的ip和端口)
    print(client_addr)
    while True:
        try:
            cmd = conn.recv(1024)
            obj = subprocess.Popen(cmd.decode('utf-8'),
                                   shell=True,
                                   stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE
                                   )
            stdout = obj.stdout.read()
            stderr = obj.stderr.read()

            # 1、製作報頭
            header_dic = {
                'total_size': len(stdout) + len(stderr),
                'md5': '123svsaef123sdfasdf',
                'filename': 'a.txt'
            }
            # 將字典形式的報頭轉換爲字符串形式
            header_json = json.dumps(header_dic)
            # 將字符串形式的報頭轉換成bytes格式
            header_bytes = header_json.encode('utf-8')

            # 2、先發送報頭的長度
            # 獲取bytes格式報頭的長度
            header_size = len(header_bytes)
            # 將長度打包成固定長度發送過去
            conn.send(struct.pack('i', header_size))

            # 3、發送報頭
            conn.send(header_bytes)

            # 4、發送真實的數據
            conn.send(stdout)
            conn.send(stderr)
        except ConnectionResetError:
            break

    conn.close()
server.close()

# 客戶端
from socket import *
import struct
import json

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

while True:
    cmd = input('>>>: ').strip()
    if not cmd: continue
    client.send(cmd.encode('utf-8'))
    # 1、先收報頭的長度
    header_size = struct.unpack('i', client.recv(4))[0]

    # 2、接收報頭
    header_bytes = client.recv(header_size)

    # 3、解析報頭
    # 將bytes類型的報頭轉換爲json字符串形式
    header_json = header_bytes.decode('utf-8')
    # 將json字符串形式的報頭轉爲字典形式
    header_dic = json.loads(header_json)
    total_size = header_dic['total_size']
    # 4、根據報頭內的信息,收取真實的數據

    recv_size = 0
    res = b''
    while recv_size < total_size:
        recv_data = client.recv(1024)
        res += recv_data
        recv_size += len(recv_data)
	
	# 這裏用gbk解碼的原因: subprocess模塊是將命令在cmd中執行, windows的cmd中, 默認字符編碼爲gbk, 所以應該用gbk解碼
    print(res.decode('gbk'))
client.close()

通過自定義報頭的形式可以完美解決粘包問題. 希望可以幫助到各位
如果有哪些看不懂的地方可以加我QQ: 526772254, 可以給各位詳細講解

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章