TCP粘包、拆包與通信協議

在TCP編程中,通常Sever端與Client通信時的消息都有着固定的消息格式,稱之爲協議(protocol),例如FTP協議、Telnet協議等,有的公司也會自己開發協議。

那麼協議到底是幹什麼的呢?說白了,協議了就是定義了數據通信的格式。主要是爲了解決TCP編程中的粘包和半包問題。

由於TCP(transport control protocol,傳輸控制協議)是面向連接的,面向流的,提供高可靠性服務。收發兩端(客戶端和服務器端)都要有一一成對的socket,因此,發送端爲了將多個發往接收端的包,更有效的發到對方,使用了優化方法(Nagle算法),將多次間隔較小且數據量小的數據,合併成一個大的數據塊,然後進行封包。這樣,接收端,就難於分辨出來了,必須提供科學的拆包機制。即面向流的通信是無消息保護邊界的。

UDP(user datagram protocol,用戶數據報協議)是無連接的,面向消息的,提供高效率服務。不會使用塊的合併優化算法,由於UDP支持的是一對多的模式,所以接收端的skbuff(套接字緩衝區)採用了鏈式結構來記錄每一個到達的UDP包,在每個UDP包中就有了消息頭(消息來源地址,端口等信息),這樣,對於接收端來說,就容易進行區分處理了。 即面向消息的通信是有消息保護邊界的。

由於TCP無消息保護邊界, 需要在消息接收端處理消息邊界問題,也就是我們所說的粘包、拆包問題;而UDP通信則不需要考慮此問題。

TCP粘包、拆包圖解

假設客戶端分別發送了兩個數據包D1和D2給服務端,由於服務端一次讀取到字節數是不確定的,故可能存在以下四種情況:

  • 服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包
  • 服務端一次接受到了兩個數據包,D1和D2粘合在一起,稱之爲TCP粘包
  • 服務端分兩次讀取到了數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩餘內容,這稱之爲TCP拆包
  • 服務端分兩次讀取到了數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩餘部分內容D1_2和完整的D2包。

特別要注意的是,如果TCP的接受滑窗非常小,而數據包D1和D2比較大,很有可能會發生第五種情況,即服務端分多次才能將D1和D2包完全接受,期間發生多次拆包。

粘包、拆包發生原因

粘包、拆包問題的產生原因有以下3種:

  • socket緩衝區與滑動窗口
  • MSS/MTU限制
  • Nagle算法

socket緩衝區與滑動窗口

先明確一個概念:每個TCP socket在內核中都有一個發送緩衝區(SO_SNDBUF )和一個接收緩衝區(SO_RCVBUF),TCP的全雙工的工作模式以及TCP的滑動窗口便是依賴於這兩個獨立的buffer以及此buffer的填充狀態。SO_SNDBUF和SO_RCVBUF 在windows操作系統中默認情況下都是8K。

SO_SNDBUF

進程發送的數據的時候(假設調用了一個send方法),最簡單情況(也是一般情況),將數據拷貝進入socket的內核發送緩衝區之中,然後send便會在上層返回。換句話說,send返回之時,數據不一定會發送到對端去(和write寫文件有點類似),send僅僅是把應用層buffer的數據拷貝進socket的內核發送buffer中。

SO_RCVBUF

把接受到的數據緩存入內核,應用進程一直沒有調用read進行讀取的話,此數據會一直緩存在相應socket的接收緩衝區內。再囉嗦一點,不管進程是否讀取socket,對端發來的數據都會經由內核接收並且緩存到socket的內核接收緩衝區之中。read所做的工作,就是把內核緩衝區中的數據拷貝到應用層用戶的buffer裏面,僅此而已。

滑動窗口

TCP鏈接在三次握手的時候,會將自己的窗口大小(window size)發送給對方,其實就是SO_RCVBUF指定的值。之後在發送數據的時,發送方必須要先確認接收方的窗口沒有被填充滿,如果沒有填滿,則可以發送。

每次發送數據後,發送方將自己維護的對方的window size減小,表示對方的SO_RCVBUF可用空間變小。

當接收方處理開始處理SO_RCVBUF 中的數據時,會將數據從socket 在內核中的接受緩衝區讀出,此時接收方的SO_RCVBUF可用空間變大,即window size變大,接受方會以ack消息的方式將自己最新的window size返回給發送方,此時發送方將自己的維護的接受的方的window size設置爲ack消息返回的window size。

此外,發送方可以連續的給接受方發送消息,只要保證對方的SO_RCVBUF空間可以緩存數據即可,即window size>0。當接收方的SO_RCVBUF被填充滿時,此時window size=0,發送方不能再繼續發送數據,要等待接收方ack消息,以獲得最新可用的window size。

現在來看一下SO_RCVBUF和滑動窗口是如何造成粘包、拆包的?

粘包:假設發送方的每256 bytes表示一個完整的報文,接收方由於數據處理不及時,這256個字節的數據都會被緩存到SO_RCVBUF中。如果接收方的SO_RCVBUF中緩存了多個報文,那麼對於接收方而言,這就是粘包。

拆包:考慮另外一種情況,假設接收方的window size只剩了128,意味着發送方最多還可以發送128字節,而由於發送方的數據大小是256字節,因此只能發送前128字節,等到接收方ack後,才能發送剩餘字節。這就造成了拆包。

MSS和MTU分片

MSS是MSS是Maximum Segement Size的縮寫,表示TCP報文中data部分的最大長度,是TCP協議在OSI五層網絡模型中傳輸層(transport layer)對一次可以發送的最大數據的限制。

MTU最大傳輸單元是Maxitum Transmission Unit的簡寫,是OSI五層網絡模型中鏈路層(datalink layer)對一次可以發送的最大數據的限制。

當需要傳輸的數據大於MSS或者MTU時,數據會被拆分成多個包進行傳輸。由於MSS是根據MTU計算出來的,因此當發送的數據滿足MSS時,必然滿足MTU。歸根結底:限制一次可發送數據大小的是MTU,MSS只是TCP協議在MTU基礎限制的傳輸層一次可傳輸的數據的大小。

爲了更好的理解,我們先介紹一下在5層網絡模型中應用通過TCP發送數據的流程: 

對於應用層來說,只關心發送的數據DATA,將數據寫入socket在內核中的緩衝區SO_SNDBUF即返回,操作系統會將SO_SNDBUF中的數據取出來進行發送。

傳輸層會在DATA前面加上TCP Header,構成一個完整的TCP報文。

當數據到達網絡層(network layer)時,網絡層會在TCP報文的基礎上再添加一個IP Header,也就是將自己的網絡地址加入到報文中。

到數據鏈路層時,還會加上Datalink Header和CRC。

當到達物理層時,會將SMAC(Source Machine,數據發送方的MAC地址),DMAC(Destination Machine,數據接受方的MAC地址 )和Type域加入。

可以發現數據在發送前,每一層都會在上一層的基礎上增加一些內容,下圖演示了MSS、MTU在這個過程中的作用。

MTU是以太網傳輸數據方面的限制,每個以太網幀都有最小的大小64bytes最大不能超過1518bytes。刨去以太網幀的幀頭 (DMAC目的MAC地址48bit=6Bytes+SMAC源MAC地址48bit=6Bytes+Type域2bytes)14Bytes和幀尾 CRC校驗部分4Bytes(這個部分有時候大家也把它叫做FCS),那麼剩下承載上層協議的地方也就是Data域最大就只能有1500Bytes這個值 我們就把它稱之爲MTU。

由於MTU限制了一次最多可以發送1500個字節,而TCP協議在發送DATA時,還會加上額外的TCP Header和Ip Header,因此刨去這兩個部分,就是TCP協議一次可以發送的實際應用數據的最大大小,也就是MSS。
 

MSS長度=MTU長度-IP Header-TCP Header

TCP Header的長度是20字節,IPv4中IP Header長度是20字節,IPV6中IP Header長度是40字節,因此:在IPV4中,以太網MSS可以達到1460byte;在IPV6中,以太網MSS可以達到1440byte。

需要注意的是MSS表示的一次可以發送的DATA的最大長度,而不是DATA的真實長度。發送方發送數據時,當SO_SNDBUF中的數據量大於MSS時,操作系統會將數據進行拆分,使得每一部分都小於MSS,這就是拆包,然後每一部分都加上TCP Header,構成多個完整的TCP報文進行發送,當然經過網絡層和數據鏈路層的時候,還會分別加上相應的內容。

細心的讀者會發現,通過wireshark抓包工具的抓取的記錄中,TCP在三次握手中的前兩條報文中都包含了MSS=65495的字樣。這是因爲我們的抓包案例的client和server都運行在本地,不需要走以太網,所以不受到以太網MTU=1500的限制。

MSS(65495)=MTU(65535)-IP Header(20)-TCP Header(20)。  

linux服務器上輸入ifconfig命令,可以查看不同網卡的MTU大小,如下:

[root@www tianshouzhi]# ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3E:02:0E:EA 
          inet addr:10.144.211.78  Bcast:10.144.223.255  Mask:255.255.240.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:266023788 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1768555 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:12103832054 (11.2 GiB)  TX bytes:138231258 (131.8 MiB)
          Interrupt:164


lo        Link encap:Local Loopback 
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65535  Metric:1
          RX packets:499956845 errors:0 dropped:0 overruns:0 frame:0
          TX packets:499956845 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:86145804231 (80.2 GiB)  TX bytes:86145804231 (80.2 GiB)

 

可以看到,默認情況下,與外部通信的網卡eth0的MTU大小是1500個字節。而本地迴環地址的MTU大小爲65535,這是因爲本地測試時數據不需要走網卡,所以不受到1500的限制。

MTU的大小可以通過類似以下命令修改:

ip link set eth0 mtu 65535

其中eth0是網卡的名字。

Nagle算法

TCP/IP協議中,無論發送多少數據,總是要在數據(DATA)前面加上協議頭(TCP Header+IP Header),同時,對方接收到數據,也需要發送ACK表示確認。

即使從鍵盤輸入的一個字符,佔用一個字節,可能在傳輸上造成41字節的包,其中包括1字節的有用信息和40字節的首部數據。這種情況轉變成了4000%的消耗,這樣的情況對於重負載的網絡來是無法接受的。

爲了儘可能的利用網絡帶寬,TCP總是希望儘可能的發送足夠大的數據。(一個連接會設置MSS參數,因此,TCP/IP希望每次都能夠以MSS尺寸的數據塊來發送數據)。Nagle算法就是爲了儘可能發送大塊數據,避免網絡中充斥着許多小數據塊。

Nagle算法的基本定義是任意時刻,最多只能有一個未被確認的小段。 所謂“小段”,指的是小於MSS尺寸的數據塊,所謂“未被確認”,是指一個數據塊發送出去後,沒有收到對方發送的ACK確認該數據已收到。

Nagle算法的規則:

  • 如果SO_SNDBUF中的數據長度達到MSS,則允許發送;
  • 如果該SO_SNDBUF中含有FIN,表示請求關閉連接,則先將SO_SNDBUF中的剩餘數據發送,再關閉;
  • 設置了TCP_NODELAY=true選項,則允許發送。TCP_NODELAY是取消TCP的確認延遲機制,相當於禁用了Negale 算法。正常情況下,當Server端收到數據之後,它並不會馬上向client端發送ACK,而是會將ACK的發送延遲一段時間(假一般是40ms),它希望在t時間內server端會向client端發送應答數據,這樣ACK就能夠和應答數據一起發送,就像是應答數據捎帶着ACK過去。當然,TCP確認延遲40ms並不是一直不變的,TCP連接的延遲確認時間一般初始化爲最小值40ms,隨後根據連接的重傳超時時間(RTO)、上次收到數據包與本次接收數據包的時間間隔等參數進行不斷調整。另外可以通過設置TCP_QUICKACK選項來取消確認延遲。
  • 未設置TCP_CORK選項時,若所有發出去的小數據包(包長度小於MSS)均被確認,則允許發送;

上述條件都未滿足,但發生了超時(一般爲200ms),則立即發送。

粘包、拆包問題的解決方案:定義通信協議

粘包、拆包問題給接收方的數據解析帶來了麻煩。例如SO_RCVBUF中存在了多個連續的完整包(粘包),因爲每個包可能都是一個完整的請求或者響應,那麼接收方需要能對此進行區分。如果存在不完整的數據(拆包),則需要繼續等待數據,直至可以構成一條完整的請求或者響應。

這個問題可以通過定義應用的協議(protocol)來解決。協議的作用就定義傳輸數據的格式。這樣在接受到的數據的時候,如果粘包了,就可以根據這個格式來區分不同的包,如果拆包了,就等待數據可以構成一個完整的消息來處理。目前業界主流的協議(protocol)方案可以歸納如下:

1 定長協議:假設我們規定每3個字節,表示一個有效報文,如果我們分4次總共發送以下9個字節:

+—+—-+——+—-+ 
| A | BC | DEFG | HI | 
+—+—-+——+—-+ 
那麼根據協議,我們可以判斷出來,這裏包含了3個有效的請求報文

+—–+—–+—–+ 
| ABC | DEF | GHI | 
+—–+—–+—–+

2 特殊字符分隔符協議:在包尾部增加回車或者空格符等特殊字符進行分割 。

例如,按行解析,遇到字符\n、\r\n的時候,就認爲是一個完整的數據包。對於以下二進制字節流:

+————–+ 
| ABC\nDEF\r\n | 
+————–+ 
那麼根據協議,我們可以判斷出來,這裏包含了2個有效的請求報文

+—–+—–+ 
| ABC | DEF | 
+—–+—–+ 
3 長度編碼:將消息分爲消息頭和消息體,消息頭中用一個int型數據(4字節),表示消息體長度的字段。在解析時,先讀取內容長度Length,其值爲實際消息體內容(Content)佔用的字節數,之後必須讀取到這麼多字節的內容,才認爲是一個完整的數據報文。

header body 
+——–+———-+ 
| Length | Content | 
+——–+———-+ 
總的來說,通信協議就是通信雙方約定好的數據格式,發送方按照這個數據格式來發送,接受方按照這個格式來解析。因此發送方和接收方要完成的工作不同,發送方要將發送的數據轉換成協議規定的格式,稱之爲編碼(encode);接收方需要根據協議的格式,對二進制數據進行解析,稱之爲解碼(decode)。
--------------------- 
版權聲明:本文爲CSDN博主「lhrimperial」的原創文章,遵循CC 4.0 by-sa版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/u013857458/article/details/82686275

 

 

 

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