1. 程式人生 > >TCP粘包、拆包與通訊協議

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大小,如下:

[[email protected] 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)。