【Socket】解決UDP丟包問題

changwan發表於2024-06-07

一、介紹

UDP是一種不可靠的、無連線的、基於資料包的傳輸層協議。相比於TCP就比較簡單,像寫信一樣,直接打包丟過去,就不用管了,而不用TCP這樣的反覆確認。所以UDP的優勢就是速度快,開銷小。但是隨之而來的就是不穩定,面向無連線的,無法確認資料包。會導致丟包問題。

whiteboard_exported_image.png

二、丟包原因

1、服務未啟動或出現故障,但是資料包依然傳送出去,目標地址和埠沒有任何程序在監聽,這些資料包將被丟棄。

2、緩衝區滿,資料包溢位丟失。在實際情況中,如果處理的速度比較慢,會導致資料包堆積在緩衝區,當緩衝區滿時,傳送的資料無處存放就會丟失。另一種情況是傳送的資料包非常大時,可能這個資料包直接超出了緩衝區的大小,也會導致資料丟失。最後一種情況和第一種差不多,由於傳送的速率過快,導致處理不及時。

Client

import socket
import time

def main():
    server_host = "127.0.0.1"
    server_port = 8888

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client_sock:
        i = 0
        while True:
            message = b"Hello, server!"
            client_sock.sendto(message, (server_host, server_port))
            i = i + 1
            time.sleep(0.001)
            if i == 100000:
                break

if __name__ == "__main__":
    main()

Serevr

import socket
import time

def main():
    host = "127.0.0.1"
    port = 8888
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server_sock:
        server_sock.bind((host, port))
        i= 0
        while True:
            data, client_addr = server_sock.recvfrom(1024)
            print("接收來自", client_addr, "的訊息:", data.decode())
            if i==0:
                time.sleep(10)
            i+=1
            print(i)

if __name__ == "__main__":
    main()

這裡的客戶端傳送了100000個資料包,在服務端特意設定處理第一個資料包後停止10秒模擬資料處理時間。在這種情況下,就會因為速度過快,緩衝區滿而導致資料包丟失。服務端最後的列印為

可以看到只接收到了96521個資料包,後面的因為緩衝區滿的原因全部丟失。這裡不會像TCP一樣堆積資料包會粘包,UDP不會,而是會一次取一個,按順序取。不同的設定的緩衝區大容量不同。

三、避免丟包

既然我們知道了丟包的原因,那麼在好實際開發中我們應儘量避免丟包問題。

1、在接收端人為建立緩衝區,也即是說,如果一個資料包處理的時間很長,那麼我們可以將接收和處理分開,將接收的資料儲存到程式碼層面。

2、再遇見資料包很大時,可以採用分片多次傳輸,最後將資料在接收端彙總處理,避免資料堆積。

3、解決方案:接收處理分離

這裡使用多程序來處理資料,與接收資料使用不同的執行緒,互不影響,這樣不會導致資料包的接收速度,所以緩衝區不會堆積,避免資料包的丟失。手動建立了一個本地資料緩衝區,使用一個列表將接收的資料儲存,使用多程序不斷處理。這裡相當於佇列是一個本地緩衝區,可以避免資料丟包,但是需要注意的是本地緩衝區不能也不能超過大小。

Client

import socket
import time

def main():
    server_host = "127.0.0.1"
    server_port = 8888

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client_sock:
        i = 0
        while True:
            message = b"Hello, server!"
            client_sock.sendto(message, (server_host, server_port))
            i = i + 1
            time.sleep(0.001)
            if i == 100000:
                break

if __name__ == "__main__":
    main()

Server

from multiprocessing import Queue
import socket
import time
from  multiprocessing import Process

def task(data_list:Queue):
    '''模擬處理處理'''
    while True:
        data = data_list.get()
        time.sleep(10)
     
def main():
    host = "127.0.0.1"
    port = 8888
    data_list = Queue()
    i= 0
    work = Process(target=task, args=(data_list,))
    work.daemon = True
    work.start()    
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server_sock:
        server_sock.bind((host, port))
        while True:
            data, _ = server_sock.recvfrom(1024)
            data_list.put(data)
            i+=1
            print(i)

if __name__ == "__main__":
    main()

四、解決丟包

1、回覆機制

Server

import socket

def main():
    host = "127.0.0.1"
    port = 8888
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server_sock:
        server_sock.bind((host, port))
        while True:
            data, client_addr = server_sock.recvfrom(1024)
            print("接收到來自", client_addr, "的訊息:", data.decode())
            ack_message = "ACK".encode()
            server_sock.sendto(ack_message, client_addr)

if __name__ == "__main__":
    main()

Client

import socket
import time

def main():
    server_host = "127.0.0.1"
    server_port = 8888
    message = ["Hello, server!"]*10
    timeout = 2 
    i = 0

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client_sock:
        client_sock.settimeout(timeout)
        while i<len(message):
            try:
                client_sock.sendto(message[i].encode(), (server_host, server_port))
                print(f"傳送訊息: {message[i]}--{i}")
                ack, _ = client_sock.recvfrom(1024)
                if ack.decode() == "ACK":
                    print("接收到確認訊息: ACK")
                    i+=1
                    continue
            except socket.timeout:
                print(f"未接收到確認訊息,重傳資料包")
            time.sleep(1)

if __name__ == "__main__":
    main()

這裡透過回傳機制確定資料正常到達,服務端接收到資料必須在指定時間內給予回覆,否則預設資料包丟失,將上一次訊息重發,這樣可以解決資料丟包。(注意服務端必須給予回覆,否則將會一直收到重複訊息。)

2、奇偶檢驗

用於檢測資料包是否錯誤,這裡指的是資料包破損,導致資料包是不完整的,這時候使用回覆機制無法找到錯誤,這裡使用奇偶檢驗就可以解決這個問題。客戶端除了在指定時間內需要接收資料外,還要根據回覆的訊息判斷資料包是否破損。

Server

import socket

def verify_and_correct(data):
    '''檢驗奇偶檢驗碼'''
    received_data = data[:-1]
    received_parity = data[-1]
    calculated_parity = sum(bytearray(received_data)) % 256

    if calculated_parity == received_parity:
        return received_data.decode(), True
    else:
        return None, False
def main():
    host = "127.0.0.1"
    port = 8888
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server_sock:
        server_sock.bind((host, port))
        while True:
            data, client_addr = server_sock.recvfrom(1024)
            message, is_correct = verify_and_correct(data)
            if is_correct:
                print("接收到來自", client_addr, "的訊息:", message)
                ack_message = "True".encode()
                server_sock.sendto(ack_message, client_addr)                   
            else:
                print("接收到來自", client_addr, "的錯誤訊息")
                ack_message = "False".encode()
                server_sock.sendto(ack_message, client_addr)                

if __name__ == "__main__":
    main()

Client

import socket

def calculate_parity(data):
    '''計算奇偶檢驗碼'''
    parity = sum(bytearray(data)) % 256
    return parity

def main():
    server_host = "127.0.0.1"
    server_port = 8888
    message = "Hello, server!"  
    message_bytes = message.encode()
    parity_byte = calculate_parity(message_bytes)
    packet = message_bytes + parity_byte

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client_sock:
        while True:
            client_sock.sendto(packet, (server_host, server_port))
            print(f"傳送訊息: {message}")
            client_sock.settimeout(3)
            try:
                ack, _ = client_sock.recvfrom(1024)
                if ack.decode() == "True":
                    print("資料已成功接收")
                    break
                else:
                    print("資料破損,重傳中...")
            except socket.timeout:
                print("超時,重傳中...")

if __name__ == "__main__":
    main()

3、前向糾錯

這種情況比較複雜,是透過更復雜的編碼方案規則,在資料中新增冗餘資料用於資料糾錯。根據自己定義的一套規則,將判斷規則需要的資料,新增到資料包中,冗餘資料用於來糾錯。例如海明碼(這裡不做具體舉例,因為比較複雜)

五、總結

UDP(使用者資料包協議)是一種無連線的傳輸層協議,因其不保證資料包的順序到達和不具備內建重傳機制,導致在網路擁塞、接收緩衝區溢位或傳送頻率過快等情況下容易出現丟包現象。為應對這些問題,可以在應用層實現重傳機制、使用前向糾錯碼等方法。這些方法在一定程度上可以緩解UDP通訊中的丟包問題,提高資料傳輸的可靠性和效率。

相關文章