和UDP這種“滾珠”式的協議不同(一份數據就是一個udp packet),TCP以報文段的方式傳遞數據,其大小受網絡鏈路的限制。在SYN報文段中互相通告最大報文段長(MSS)。所以業務層交付的數據,會被TCP拆分/合並為合適的報文段(這也就是為嘛TCP數據跟水流似的,沒有邊界)。
對於每個報文段而言,就很像UDP的“滾珠”了,不保證順序、不保證到達。TCP要對收到的報文重新排序,再才交給應用層。發出一個報文段后,會啟動一個定時器,等待對端ACK確認收到,否則將重傳該報文。由於重傳機制,報文段可能發生重復,接收端須丟棄重復報文。借此TCP實現了自己的可靠性。
那如何高效實現此可靠性的呢?
【ack確認】
首先是ack的設計,最直白的方式:跟我們平時寫異步IO很相似,發送一份數據,等待對方確認,再發下一份。
由於TCP是雙全工的,兩端都可有數據交互,對端的ack可以合並至其正常報文段中,減少交互量。此即:“經受延時的確認”——通常TCP在接收到數據時並不立即發送ACK;相反,它推遲發送,以便將ACK與需要沿該方向發送的數據一起發送。
再者,ACK是確認收到的字節序,未必每一條報文都須ACK,可一次ACK多條數據。比如連續收到兩個報文段“PSH 1:1025; PSH 1025:2049”,只需回復一條“ack 2049”即可。“隔一個報文段確認”策略——接收方不必確認每一個收到的分組,ACK是累積的,它們表示收方已正確收到了一直到確認序號減一的所有字節。
【快速重傳】
如果數據丟失/錯誤,每次都等超時才重傳會慢。
收到一個失序報文段(前面一條丟失/錯誤/暫未抵達),TCP立即產生一個ack(重復的,且不被延時),如果發方連續收到3(或以上)次相同的ack,就非常可能是報文段丟失/錯誤,於是重傳報文段,不必等timeout。
(PS:為什么是3次,查資料說是經驗數據,連續3次,即表示嫌疑報文后的兩條報文都正在到達對端了,由於由於鏈路層的失序造成的時差幾乎不可能如此劇烈,可以認為是丟了或校驗出錯)
還有重傳哪些報文段的問題。譬如連續發生5條報文,第二條丟了,其它均到達,發方收到4個ack(2),僅知道2號報文必須重傳,3/4/5號是不清楚的(某些TCP實現采用的是重傳丟包后的所有報文)。
可在TCP包頭中加入SACK,匯報收到的數據碎片,這樣發送端就可知道哪些數據到了,哪些是丟了。還是剛才的例子,發方仍收到4個ack(2),多了sack:sack(1, 3-5)。
【Nagle算法和滑動窗口】
其次,我們是有寬帶的,要是用戶讓發多少就發多少,會很浪費。好像200人的航班帶兩個人飛過。解決方案也很簡單:
一、限制小包的數目(Nagle算法,連接上最多只能有一個未確認的小分組,該分組的ack到達之前不能發送其他小分組了,算法是自適應的,收到的ack越快發的就越快);
二、上buffer,緩存用戶數據,再分發成塊交給IP層傳輸,相應的對端也要要個接收buffer,兩片buffer配合工作,保證收發正確性。
這就是TCP最大特征的“滑動窗口”了。
每條報文段的TCP頭部中都會通告當前窗口大小,發送端據此得知可預留多少字節的buffer給應用層。具體工作流程如下圖(請忽略字體,多謝):
通告窗口為0,顯示接收方雖已收到所有數據,但其應用層尚未及時從TcpRecvBuff中讀取,發方會停止發送。
Zero Window下,應用層讀取數據后,窗口重新空閑,要通知發送方(窗口更新)。一個單獨的主動ack,不確認數據,僅報告新的窗口大小。
【堅持定時器】
“單獨的主動ack”,跟一般的ack不一樣,還記得一般得ack丟了怎么重傳的嗎?那這貨丟了怎么辦?
TCP四大定時器(重傳、堅持、保活、2MSL)之堅持定時來搞定。
Zero Window后,發方停止發送數據,並設置其堅持定時器,若超時前仍未收到窗口更新,則發送一個特殊的探查報文,獲知對端窗口大小。探查含1字節,但不被ack確認,會一直重復。
【糊塗窗口綜合征】
好,我們有了Nagle算法,有了滑動窗口,是不是就完全搞定了寬帶高效利用呢?
考慮這樣一種場景,Zero Window后,收方應用程序每次僅拷走1個字節,觸發窗口更新,ack win 1,發方窗口右邊沿更新,發送了1字節數據,停止,等下一波窗口更新……loop(PS:實際不可能只是1字節,tcp頭都20字節)
相應的解決方案:
1)接收方不通告小窗口,除非窗口增加至MSS(最大報文段長),或增加到接收方緩沖空間的一半。
2)發送方只在下列條件之一滿足時才發數據:
(a) 可以發送一個滿長度的報文段
(b) 可以發送至少接收方窗口大小一半的報文段
(c) 無未被確認的數據,且能夠發送所有緩沖數據
(d) 連接關閉了Nagle算法
【擁塞避免、慢啟動】
還有問題嗎?之前由於寬帶利用問題,我們要滑動窗口,避免頻繁發小包;類似的,由於路由問題,我們還得避免不要太粗暴。
假設連接建立起始(窗口均空閑)便發送大量數據,塞滿網絡,而鏈路速率較慢——中間路由器就必須緩存分組了,一旦路由存儲空間耗盡,后來的分組會被直接丟棄,TCP吞吐量便隨之嚴重降低了。
“慢啟動、擁塞避免”上場——擁塞窗口(congestion window,記作cwnd),慢啟動門限(ssthresh)。
TCP連接建立時,ssthresh初始化為65535字節,擁塞窗口初始化為1個報文段(MSS),每收到一個ack,cwnd便增加一個報文段大小。發送方取min(擁塞窗口,滑動窗口)作為發送上限。
發方先發送一個報文段,等ack,收到后cwnd增加到2,可發兩個報文段。收到這兩個報文段的ack時,cwnd增加到4……指數增漲(慢啟動)。
攀升到一定值后抵達網絡容量,中間路由丟棄分組,發方重傳定時器超時,知曉cwnd過大引起擁塞,cwnd被重設為1個報文段,ssthresh則被設置為當前cwnd的一半。
還有種擁塞指示:收到重復ack,比如中間某報文損壞。此時僅設置ssthresh = cwnd/2,並不改寫cwnd。
若 cwnd <= ssthresh,則進行慢啟動,cwnd按指數更新;否則進行擁塞避免,cwnd按線性方式增長(每次ack增加1/cwnd,一個RTT內最多增加一個報文段,無論期間受到多少個ack)。