日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

TCP 协议如何解决粘包、半包问题

發布時間:2024/4/18 编程问答 35 豆豆
生活随笔 收集整理的這篇文章主要介紹了 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 每次是不知道應該把收到的數據中多少字節作為一個有效的數據包的。而規定每次把多少數據當成一個包就是協議格式規范的內容之一。

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

發送端:

//...省略創建socket,建立連接等部分不相關的邏輯... char buf[] = "the quick brown fox jumps over a lazy dog."; int n = send(socket, buf, strlen(buf), 0); //...省略出錯處理邏輯...

接收端:

//省略創建socket,建立連接等部分不相關的邏輯... char recvBuf[50] = { 0 }; int n = recv(socket, recvBuf, 50, 0); //省略出錯處理邏輯... printf("recvBuf: %s", recvBuf);

為了專注問題本身的討論,我這里省略掉了建立連接和部分錯誤處理的邏輯。上述代碼中發送端給接收端發送了一串字符”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() 方法來對數據包解包。

這三種包格式,希望讀者能在理解其原理和優缺點的基礎上深入掌握。

三 解包與處理

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

假設我們的包頭格式如下:

//強制一字節對齊 #pragma pack(push, 1) //協議頭 struct msg { int32_t bodysize; //包體大小 }; #pragma pack(pop)

那么上面的流程實現代碼如下:

//包最大字節數限制為10M #define MAX_PACKAGE_SIZE 10 * 1024 * 1024void ChatSession::OnRead(const std::shared_ptr<TcpConnection>& conn, Buffer* pBuffer, Timestamp receivTime) {while (true){//不夠一個包頭大小if (pBuffer->readableBytes() < (size_t)sizeof(msg)){//LOGI << "buffer is not enough for a package header, pBuffer->readableBytes()=" << pBuffer->readableBytes() << ", sizeof(msg)=" << sizeof(msg);return;}//取包頭信息msg header;memcpy(&header, pBuffer->peek(), sizeof(msg));//包頭有錯誤,立即關閉連接if (header.bodysize <= 0 || header.bodysize > MAX_PACKAGE_SIZE){//客戶端發非法數據包,服務器主動關閉之LOGE("Illegal package, bodysize: %lld, close TcpConnection, client: %s", header.bodysize, conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}//收到的數據不夠一個完整的包if (pBuffer->readableBytes() < (size_t)header.bodysize + sizeof(msg))return;pBuffer->retrieve(sizeof(msg));//inbuf用來存放當前要處理的包std::string inbuf;inbuf.append(pBuffer->peek(), header.bodysize);pBuffer->retrieve(header.bodysize); //解包和業務處理if (!Process(conn, inbuf.c_str(), inbuf.length())){//客戶端發非法數據包,服務器主動關閉之LOGE("Process package error, close TcpConnection, client: %s", conn->peerAddress().toIpPort().c_str());conn->forceClose();return;} }// end while-loop }

上述流程代碼的處理過程和流程圖中是一致的,pBuffer 這里是一個自定義的接收緩沖區,這里的代碼,已經將收到的數據放入了這個緩沖區,所以判斷當前已收取的字節數目只需要使用這個對象的相應方法即可。上述代碼有些細節我需要強調一下:

  • 取包頭時,你應該拷貝一份數據包頭大小的數據出來,而不是從緩沖區 pBuffer 中直接將數據取出來(即取出來的數據從 pBuffer 中移除),這是因為倘若接下來根據包頭中的字段得到包體大小時,如果剩余數據不夠一個包體大小,你又得把這個包頭數據放回緩沖區。為了避免這種不必要的操作,只有緩沖區數據大小夠整個包的大小(代碼中:header.bodysize + sizeof(msg))你才需要把整個包大小的數據從緩沖區移除,這也是這里的 pBuffer->peek() 方法 peek 單詞的含義(中文可以翻譯成“瞟一眼”或者“偷窺”)。
  • 通過包頭得到包體大小時,你一定要對 bodysize 的數值進行校驗,我這里要求 bodysize 必須大于 0 且不大于 10?1024?1024(即 10 M)。當然,實際開發中,你可以根據你自己的需求要決定 bodysize 的上下限(包體大小是 0 字節的包在某些業務場景下是允許的)。記住,一定要判斷這個上下限,因為假設這是一個非法的客戶端發來的數據,其 bodysize 設置了一個比較大的數值,例如 1?1024?1024 * 1024(即 1 G),你的邏輯會讓你一直緩存該客戶端發來的數據,那么很快你的服務器內存將會被耗盡,操作系統在檢測到你的進程占用內存達到一定閾值時會殺死你的進程,導致服務不能再正常對外服務。如果你檢測了 bodysize 字段的是否滿足你設置的上下限,對于非法的 bodysize,直接關閉這路連接即可。這也是服務的一種自我保護措施,避免因為非法數據包帶來的損失。
  • 不知道你有沒有注意到整個判斷包頭、包體以及處理包的邏輯放在一個 while 循環里面,這是必要的。如果沒有這個 while 循環,當你一次性收到多個包時,你只會處理一個,下次接著處理就需要等到新一批數據來臨時再次觸發這個邏輯。這樣造成的結果就是,對端給你發送了多個請求,你最多只能應答一個,后面的應答得等到對端再次給你發送數據時。這就是對粘包邏輯的正確處理。

以上邏輯和代碼是最基本的粘包和半包處理機制,也就是所謂的技術上的解包處理邏輯(業務上的解包處理邏輯后面章節再介紹)。希望讀者能理解他們,在理解了他們的基礎之上,我們可以給解包拓展很多功能,例如,我們再給我們的協議包增加一個支持壓縮的功能,我們的包頭變成下面這個樣子:

#pragma pack(push, 1) //協議頭 struct msg {char compressflag; //壓縮標志,如果為1,則啟用壓縮,反之不啟用壓縮int32_t originsize; //包體壓縮前大小int32_t compresssize; //包體壓縮后大小char reserved[16]; //保留字段,用于將來拓展 }; #pragma pack(pop)

修改后的代碼如下:

void ChatSession::OnRead(const std::shared_ptr<TcpConnection>& conn, Buffer* pBuffer, Timestamp receivTime) {while (true){//不夠一個包頭大小if (pBuffer->readableBytes() < (size_t)sizeof(msg)){//LOGI << "buffer is not enough for a package header, pBuffer->readableBytes()=" << pBuffer->readableBytes() << ", sizeof(msg)=" << sizeof(msg);return;}//取包頭信息msg header;memcpy(&header, pBuffer->peek(), sizeof(msg));//數據包壓縮過if (header.compressflag == PACKAGE_COMPRESSED){//包頭有錯誤,立即關閉連接if (header.compresssize <= 0 || header.compresssize > MAX_PACKAGE_SIZE ||header.originsize <= 0 || header.originsize > MAX_PACKAGE_SIZE){//客戶端發非法數據包,服務器主動關閉之LOGE("Illegal package, compresssize: %lld, originsize: %lld, close TcpConnection, client: %s", header.compresssize, header.originsize, conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}//收到的數據不夠一個完整的包if (pBuffer->readableBytes() < (size_t)header.compresssize + sizeof(msg))return;pBuffer->retrieve(sizeof(msg));std::string inbuf;inbuf.append(pBuffer->peek(), header.compresssize);pBuffer->retrieve(header.compresssize);std::string destbuf;if (!ZlibUtil::UncompressBuf(inbuf, destbuf, header.originsize)){LOGE("uncompress error, client: %s", conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}//業務邏輯處理if (!Process(conn, destbuf.c_str(), destbuf.length())){//客戶端發非法數據包,服務器主動關閉之LOGE("Process error, close TcpConnection, client: %s", conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}}//數據包未壓縮else{//包頭有錯誤,立即關閉連接if (header.originsize <= 0 || header.originsize > MAX_PACKAGE_SIZE){//客戶端發非法數據包,服務器主動關閉之LOGE("Illegal package, compresssize: %lld, originsize: %lld, close TcpConnection, client: %s", header.compresssize, header.originsize, conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}//收到的數據不夠一個完整的包if (pBuffer->readableBytes() < (size_t)header.originsize + sizeof(msg))return;pBuffer->retrieve(sizeof(msg));std::string inbuf;inbuf.append(pBuffer->peek(), header.originsize);pBuffer->retrieve(header.originsize);//業務邏輯處理if (!Process(conn, inbuf.c_str(), inbuf.length())){//客戶端發非法數據包,服務器主動關閉之LOGE("Process error, close TcpConnection, client: %s", conn->peerAddress().toIpPort().c_str());conn->forceClose();return;}}// end else}// end while-loop }

這段代碼先根據包頭的壓縮標志字段判斷包體是否有壓縮,如果有壓縮,則取出包體大小去解壓,解壓后的數據才是真正的業務數據。整個程序執行流程圖如下:

?

?

總結

以上是生活随笔為你收集整理的TCP 协议如何解决粘包、半包问题的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。