TCP 協議如何解決粘包、半包問題

                            TCP 協議如何解決粘包、半包問題

 

一 TCP 協議是流式協議

很多讀者從接觸網絡知識以來,應該聽說過這句話:TCP 協議是流式協議。那麼這句話到底是什麼意思呢?所謂流式協議,即協議的內容是像流水一樣的字節流,內容與內容之間沒有明確的分界標誌,需要我們人爲地去給這些協議劃分邊界。

舉個例子,A 與 B 進行 TCP 通信,A 先後給 B 發送了一個 100 字節和 200 字節的數據包,那麼 B 是如何收到呢?B 可能先收到 100 字節,再收到 200 字節;也可能先收到 50 字節,再收到 250 字節;或者先收到 100 字節,再收到 100 字節,再收到 200 字節;或者先收到 20 字節,再收到 20 字節,再收到 60 字節,再收到 100 字節,再收到 50 字節,再收到 50 字節……

 

不知道讀者看出規律沒有?規律就是 A 一共給 B 發送了 300 字節,B 可能以一次或者多次任意形式的總數爲 300 字節收到。假設 A 給 B 發送的 100 字節和 200 字節分別都是一個數據包,對於發送端 A 來說,這個是可以區分的,但是對於 B 來說,如果不人爲規定多長爲一個數據包,B 每次是不知道應該把收到的數據中多少字節作爲一個有效的數據包的。而規定每次把多少數據當成一個包就是協議格式規範的內容之一。

經常會有新手寫出類似下面這樣的代碼:

發送端:

接收端:

 

爲了專注問題本身的討論,我這裏省略掉了建立連接和部分錯誤處理的邏輯。上述代碼中發送端給接收端發送了一串字符”the quick brown fox jumps over a lazy dog.“,接收端收到後將其打印出來。

 

類似這樣的代碼在本機一般會工作的很好,接收端也如期打印出來預料的字符串,但是一放到局域網或者公網環境就出問題了,即接收端可能打印出來字符串並不完整;如果發送端連續多次發送字符串,接收端會打印出來的字符串不完整或出現亂碼。不完整的原因很好理解,即對端某次收到的數據小於完整字符串的長度,recvBuf 數組開始被清空成 0,收到部分字符串後,該字符串的末尾仍然是 0,printf 函數尋找以 0 爲結束標誌的字符結束輸出;亂碼的原因是如果某次收入的數據不僅包含一個完整的字符串,還包含下一個字符串部分內容,那麼 recvBuf 數組將會被填滿,printf 函數輸出時仍然會尋找以 0 爲結束標誌的字符結束輸出,這樣讀取的內存就越界了,一直找到爲止,而越界後的內存可能是一些不可讀字符,顯示出來後就亂碼了。

 

我舉這個例子希望你明白 能對TCP 協議是流式協議有一個直觀的認識。正因爲如此,所以我們需要人爲地在發送端和接收端規定每一次的字節流邊界,以便接收端知道從什麼位置取出多少字節來當成一個數據包去解析,這就是我們設計網絡通信協議格式的要做的工作之一。

 

二 如何解決粘包問題

網絡通信程序實際開發中,或者技術面試時,面試官通常會問的比較多的一個問題是:網絡通信時,如何解決粘包?

 

有的面試官可能會這麼問:網絡通信時,如何解決粘包、丟包或者包亂序問題?這個問題其實是面試官在考察面試者的網絡基礎知識,如果是 TCP 協議,在大多數場景下,是不存在丟包和包亂序問題的,TCP 通信是可靠通信方式,TCP 協議棧通過序列號和包重傳確認機制保證數據包的有序和一定被正確發到目的地;如果是 UDP 協議,如果不能接受少量丟包,那就要自己在 UDP 的基礎上實現類似 TCP 這種有序和可靠傳輸機制了(例如 RTP協議、RUDP 協議)。所以,問題拆解後,只剩下如何解決粘包的問題。

 

先來解釋一下什麼是粘包,所謂粘包就是連續給對端發送兩個或者兩個以上的數據包,對端在一次收取中可能收到的數據包大於 1 個,大於 1 個,可能是幾個(包括一個)包加上某個包的部分,或者乾脆就是幾個完整的包在一起。當然,也可能收到的數據只是一個包的部分,這種情況一般也叫半包

無論是半包還是粘包問題,其根源是上文介紹中 TCP 協議是流式數據格式。解決問題的思路還是想辦法從收到的數據中把包與包的邊界給區分出來。那麼如何區分呢?目前主要有三種方法:

固定包長的數據包

顧名思義,即每個協議包的長度都是固定的。舉個例子,例如我們可以規定每個協議包的大小是 64 個字節,每次收滿 64 個字節,就取出來解析(如果不夠,就先存起來)。

這種通信協議的格式簡單但靈活性差。如果包內容不足指定的字節數,剩餘的空間需要填充特殊的信息,如 \0(如果不填充特殊內容,如何區分包裏面的正常內容與填充信息呢?);如果包內容超過指定字節數,又得分包分片,需要增加額外處理邏輯——在發送端進行分包分片,在接收端重新組裝包片(分包和分片內容在接下來會詳細介紹)。

 

以指定字符(串)爲包的結束標誌

這種協議包比較常見,即字節流中遇到特殊的符號值時就認爲到一個包的末尾了。例如,我們熟悉的 FTP協議,發郵件的 SMTP 協議,一個命令或者一段數據後面加上"\r\n"(即所謂的 CRLF)表示一個包的結束。對端收到後,每遇到一個”\r\n“就把之前的數據當做一個數據包。

這種協議一般用於一些包含各種命令控制的應用中,其不足之處就是如果協議數據包內容部分需要使用包結束標誌字符,就需要對這些字符做轉碼或者轉義操作,以免被接收方錯誤地當成包結束標誌而誤解析。

包頭 + 包體格式

這種格式的包一般分爲兩部分,即包頭和包體,包頭是固定大小的,且包頭中必須含有一個字段來說明接下來的包體有多大。

struct msg_header
{
  int32_t bodySize;
  int32_t cmd;
};

這就是一個典型的包頭格式,bodySize 指定了這個包的包體是多大。由於包頭大小是固定的(這裏是 size(int32_t) + sizeof(int32_t) = 8 字節),對端先收取包頭大小字節數目(當然,如果不夠還是先緩存起來,直到收夠爲止),然後解析包頭,根據包頭中指定的包體大小來收取包體,等包體收夠了,就組裝成一個完整的包來處理。在有些實現中,包頭中的 bodySize可能被另外一個叫 packageSize 的字段代替,這個字段的含義是整個包的大小,這個時候,我們只要用 packageSize 減去包頭大小(這裏是 sizeof(msg_header))就能算出包體的大小,原理同上。

 

在使用大多數網絡庫時,通常你需要根據協議格式自己給數據包分界和解析,一般的網絡庫不提供這種功能是出於需要支持不同的協議,由於協議的不確定性,因此沒法預先提供具體解包代碼。當然,這不是絕對的,也有一些網絡庫提供了這種功能。在 Java Netty 網絡框架中,提供了FixedLengthFrameDecoder 類去處理長度是定長的協議包,提供了 DelimiterBasedFrameDecoder 類去處理按特殊字符作爲結束符的協議包,提供 ByteToMessageDecoder 去處理自定義格式的協議包(可用來處理包頭 + 包體 這種格式的數據包),然而在繼承 ByteToMessageDecoder  子類中你需要根據你的協議具體格式重寫 decode() 方法來對數據包解包。

 

三 解包與處理

在理解了前面介紹的數據包的三種格式後,我們來介紹一下針對上述三種格式的數據包技術上應該如何處理。其處理流程都是一樣的,這裏我們以包頭 + 包體 這種格式的數據包來說明。處理流程如下:

參考文獻

https://mp.weixin.qq.com/s/5irSrrMl3zMXZTTabE0bDg

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