網路程式設計之粘包問題
粘包問題的引入
基於tcp協議遠端執行命令的程式碼
服務端:
from socket import * import subprocess server = socket(AF_INET, SOCK_STREAM) server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) server.bind(('127.0.0.1', 6666)) server.listen(5) while True: conn, client_addr = server.accept() while True: try: cmdView Code= conn.recv(1024) if len(cmd) == 0: break # subprocess.Popen類用於在一個新程序中執行一個子程式 res = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) stdout_res = res.stdout.read() stderr_res = res.stdout.read() conn.send(stdout_res) conn.send(stderr_res) except Exception: break conn.close()
客戶端:
from socket import * client = socket(AF_INET, SOCK_STREAM) client.connect((View Code'127.0.0.1', 6666)) while True: msg = input('請輸入命令 >>: ').strip() if len(msg) == 0: continue client.send(msg.encode()) cmd_res = client.recv(1024) # 本次接收, 最大接收1024Bytes print(cmd_res.decode())
可以發現, 服務端傳送的訊息, 在客戶端接收訊息中, 主要是通過recv()函式接收訊息, 該函式接收一個引數, 該引數指定接收訊息的大小, 單位為位元組。
如果服務端第一次傳送的訊息超過了客戶端接收訊息的規定的位元組數, 那麼第一次客戶端也只會接收該位元組數大小的訊息, 那麼剩下的服務端傳送的訊息去哪了?
服務端第二次傳送訊息的時候, 客戶端接收訊息會接收到第一次剩下的訊息和第二次的訊息(不超過規定位元組數的訊息)。
這就產生了網路通訊中的粘包問題
粘包問題的產生
socket收發訊息的原理如下:
傳送端可以是一K一K地傳送資料,而接收端的應用程式可以兩K兩K地提走資料,當然也有可能一次提走3K或6K資料,或者一次只提走幾個位元組的資料,也就是說,應用程式所看到的資料是一個整體,或說是一個流(stream),一條訊息有多少位元組對應用程式是不可見的,
TCP協議是面向流的協議,這也是容易出現粘包問題的原因。
而UDP是面向訊息的協議,每個UDP段都是一條訊息,應用程式必須以訊息為單位提取資料,不能一次提取任意位元組的資料,這一點和TCP是很不同的。
怎樣定義訊息呢?可以認為對方一次性write/send的資料為一個訊息,需要明白的是當對方send一條資訊的時候,無論底層怎樣分段分片,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時才會清除緩衝區內容。資料是可靠的,但是會粘包。
粘包問題主要是在兩種情況下產生:
- 傳送端需要等緩衝區滿才傳送出去,造成粘包(傳送資料時間間隔很短,資料了很小,會合到一起,產生粘包)
- 接收方不及時接收緩衝區的包,造成多個包接收(客戶端傳送了一段資料,服務端只收了一小部分,服務端下次再收的時候還是從緩衝區拿上次遺留的資料,產生粘包)
粘包問題的解決
接收端不知道傳送端將要傳送的位元組流的長度,所以解決粘包的方法就是圍繞,如何讓傳送端在傳送資料前,把自己將要傳送的位元組流總大小讓接收端知曉,然後接收端來一個死迴圈接收完所有資料
解決問題的思路為:
1. 服務端先發頭資訊(固定長度的bytes): 對資料的描述 2. 客戶端先收固定長度的頭資訊: 解析出資料的描述資訊, 拿到資料的總大小total_size 3. 服務端再發真實的資料 4. 客戶端迴圈接收, 直到接收完固定長度的資訊
實現方法一: 使用固定長度
服務端
from socket import * import subprocess import struct server = socket(AF_INET, SOCK_STREAM) server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) server.bind(('127.0.0.1', 6666)) server.listen(5) while True: conn, client_addr = server.accept() while True: try: cmd = conn.recv(1024) if len(cmd) == 0: break # subprocess.Popen類用於在一個新程序中執行一個子程式 res = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) stdout_res = res.stdout.read() stderr_res = res.stdout.read() total_size = len(stdout_res) + len(stderr_res) # 1. 先發頭資訊(固定長度的bytes): 對資料的描述 header = struct.pack('i', total_size) conn.send(header) # 2. 再發真實的資料 conn.send(stdout_res) conn.send(stderr_res) except Exception: break conn.close()View Code
客戶端
from socket import * import struct client = socket(AF_INET, SOCK_STREAM) client.connect(('127.0.0.1', 6666)) while True: msg = input('請輸入命令 >>: ').strip() if len(msg) == 0: continue client.send(msg.encode()) # 1. 先收固定長度的頭資訊: 解析出資料的描述資訊, 拿到資料的總大小total_size header = client.recv(4) total_size = struct.unpack('i', header)[0] # 2. recv_size=0, 迴圈接收, 每接收一次, recv_size+=接收長度 # 3. 直到recv_size=total_size recv_size = 0 cmd_res = b'' while recv_size < total_size: recv_data = client.recv(1024) recv_size += len(recv_data) print(recv_data.decode('utf-8'), end='') else: print('\n') print(cmd_res.decode())View Code
實現方法二: 使用truct模組
服務端
from socket import * import subprocess import struct import json server = socket(AF_INET, SOCK_STREAM) server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) server.bind(('127.0.0.1', 6666)) server.listen(5) while True: conn, client_addr = server.accept() while True: try: cmd = conn.recv(1024) if len(cmd) == 0: break # subprocess.Popen類用於在一個新程序中執行一個子程式 res = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) stdout_res = res.stdout.read() stderr_res = res.stdout.read() total_size = len(stdout_res) + len(stderr_res) # 1. 製作頭資訊 header_dic = { 'filename': 'aaa.text', 'total_size': total_size, 'md5': '123123123' } json_str = json.dumps(header_dic) json_str_bytes = json_str.encode('utf-8') # 2. 先把頭資訊的長度發過去 header_size = struct.pack('i', len(json_str_bytes)) conn.send(header_size) # 3. 傳送頭資訊 conn.send(json_str_bytes) # 4. 再發真實的資料 conn.send(stdout_res) conn.send(stderr_res) except Exception: break conn.close()View Code
客戶端
from socket import * import struct import json client = socket(AF_INET, SOCK_STREAM) client.connect(('127.0.0.1', 6666)) while True: msg = input('請輸入命令 >>: ').strip() if len(msg) == 0: continue client.send(msg.encode()) # 先接收4個位元組, 從中提取接下來要接收的頭的長度 header = client.recv(4) header_len = struct.unpack('i', header)[0] # 然後接收頭資訊, 並進行解析 json_str_bytes = client.recv(header_len) json_str = json_str_bytes.decode('utf-8') header_dict = json.loads(json_str) print(header_dict) total_size = header_dict['total_size'] recv_size = 0 cmd_res = b'' while recv_size < total_size: recv_data = client.recv(1024) recv_size += len(recv_data) print(recv_data.decode('utf-8'), end='') else: print('\n') print(cmd_res.decode())View Code