文章正文 編輯

  • 目錄

    • TCP 協議基礎

    • 面向連接的協議

    • 善始善終的連接管理

    • TCP 容錯功能

    • 從編程實現角度看 TCP 連接

    • TCP 大包分裂和重組

    • TCP 重傳機制

    • TCP 滑動窗口機制

    • TCP 擁塞控制

    • 慢啓動

    • 擁塞避免

    • 擁塞發生

    • 快速恢復

    • TCP 長連接提升上層應用性能

    • 總結

協議是計算機通信的基石,爲了在世界範圍內建立統一的計算機互聯網絡,國際標準化組織(ISO)於 1984 年發佈 ISO/IEC 7498 標準,該標準定義了網絡互聯的七層框架,自下而上依次爲物理層、數據鏈路層、網絡層、傳輸層、會話層、表示層和應用層。

而 TCP/IP 協議是參考 ISO 制定的理論模型的具體實現,因此 ISO 七層模型又被稱爲 OSI(Open System Interconnection)參考模型。TCP/IP 協議分爲網絡接口層、網際層、運輸層和應用層,總共 4 層,它將 OSI 參考模型的會話層,表示層和應用層合爲一個應用層,將物理層和數據鏈路層合爲一個網絡接口層。而五層協議模型是業界跟 TCP/IP 結合產生的非官方模型。三套協議模型的對比見下圖:

雖然三套模型分層略有差別(無實質差別),但都遵從了層次劃分的原則:

  • 網絡中各結點都具有相同的層次

  • 不同結點的同等層具有相同的功能

  • 不同結點的同等層通過協議來實現對等層之間的通信

  • 同一結點內相鄰層之間通過接口通信

  • 每個層可以使用下層提供的服務,並向其上層提供服務

有了公共的通信協議,網絡中的任意兩臺計算機通信都按照相同的邏輯進行:發送方先對數據層層封包,加上本層協議的頭部信息,然後通過物理層以比特流的形式到達對方,最後接收方層層去掉頭部信息,還原出原來的數據。當然實際過程遠比這些複雜,比如數據正確性校驗、失敗重傳等。原始數據經過每層的處理如下圖所示(以 OSI 參考模型爲例):

TCP/IP 協議準確的說是一個協議族,包括 TCP 協議、IP 協議和 ICMP 協議和 HTTP 協議等。按照模型層次從上到下依次列出每層常見協議如下圖所示:

接下來我們把觀察的“鏡頭”拉近,聚焦在運輸層的 TCP 這個具體的協議上,看看這個面向連接、字節流的通信協議如何保證其可靠性?

TCP 協議基礎

TCP/IP 協議能達成的功能或者作用由其協議頭結構組成,如下圖所示:

(圖片來源:http://c.biancheng.net/view/6427.html)

協議頭主要由源端口和目的端口、序列號、確認序列號、標誌位、窗口大小、TCP 校驗和、緊急指針組成。其中

  • 序列號和確認序列號能夠保證數據接收的順序;

  • 標誌位中的 RST、SYN 和 FIN 跟連接有關,用於建立和釋放連接;

  • 標誌位中的 CWR、ECE 和窗口大小字段涉及到滑動窗口,用於保證服務質量;

  • 標誌位中的 URG 和 PSH 跟數據的緊急程度和優先級有關,實現數據傳輸的控制功能。

跟 TCP 協議相對應的 UDP——面向無連接、不可靠的數據傳輸協議——協議頭要簡單得多,同時提供的功能和服務也少得多。不同的是多了一個表示數據長度的字段。

那自然要問,如何知道 TCP 包的數據長度呢?因爲在 IP 分組的數據頭部有表示分組的長度字段,因此可以根據 IP 包數據大小減去 IP 包頭和 TCP 包頭的大小計算,UDP 協議頭格式如下:

面向連接的協議

不同於 UDP 的盡最大努力交付,TCP 在正式傳輸之前先跟接收端就某些“事項”達成一致,然後再開始數據傳送,其中某些“事項”包括:如套接字、窗口大小、序列號、MSS(Maxitum Segment Size),這個達成一致的過程就是連接建立的過程,實際上連接雙方之間並不存在真正的連接,僅僅是雙方維護着雙方的狀態信息而已。

三步建立連接的過程見下圖:

有幾點需要注意和說明的是:

  1. 第一步中 ACK=0,後兩步 ACK=1。首先明確 ACK 的含義是指示確認序列號(Ackknowledgement Number,圖例中的 ack)字段是否有效,ACK=0,ack 無效。TCP 規定,連接建立後,ACK 必須爲 1。

  2. 前兩步中的 SYN=1,第三步的 SYN=0。首先明確 SYN 表示在連接時同步序列號,SYN=1 時,說明這是一個請求建立連接或同意建立連接的報文。

  3. 連接發起方和接收方的序列號分別爲 x 和 y,表示雙方獨立初始化序列號。實際上初始序列號根據時鐘和連接雙方的 IP 地址和端口的哈希結果生成的。至於使用不同的隨機序列號就是防止因爲在網絡中延遲、重複的數據包出現導致連接的混亂。

  4. 恰好三步建立連接,不多不少。其主要原因是防止舊的重複 SYN 包出現:假設出現了舊的 SYN 包,接收方通過 SYN+ACK 回應,客戶端就可以根據確認序列號判斷該連接過期無效,從而發送 RST 報文終止此連接。同時,只有第二步和第三步完成,雙方纔同步完序列號,少一步不行,多一步浪費!

善始善終的連接管理

因爲連接的雙方一直維護着雙方的狀態信息,當數據傳輸完畢,理應釋放連接。相比連接的建立,釋放連接要略微複雜一些,見下圖:

有幾點需要注意和說明的是:

  1. 第一步和第三步都是 FIN 報文。FIN 標誌表示數據是否傳送完畢,如果是說明可以釋放連接,跟 RST 不同。從發送 FIN 到對方響應 FIN 之間有一個間隔過程。在此期間,被動關閉的一方還可以繼續傳送數據,直到其傳送完成爲止。

  2. 第二步和第三步中的確認序列號 ack 相同。這兩個報文中的序列號都表示希望接下來從主動關閉方接受一個相同序號的報文。

  3. 雙方發送 FIN 報文都需要一個 ACK,兩邊對稱,各兩個報文,一共 4 步,完成連接釋放。

  4. 發起 CLOSE 的一方經過三個報文,狀態依次經過 FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT,在進入 CLOSE 狀態先等待 2MSL(Maximum Segment Lifetime)。這是因爲最後一個 ACK 可能會丟失,假如主動關閉一方直接從 TIME_WAIT 進入 CLOSE,被動關閉的一方沒有收到 ACK,就會重新發送 FIN 報文,這就導致被動關閉一方無法釋放連接。同時由於重新發送的報文還在網絡中,可能會導致其他正常連接的關閉。

TCP 容錯功能

從編程實現角度看 TCP 連接

一個同步阻塞式的 TCP 連接有兩部分組成:服務端通過 bind 方法綁定一個事先選定的端口,並通過 listen 方法監聽客戶端的連接請求。客戶端通過 connect 發起連接請求,在該方法中發送 SYN 報文。服務端接到 SYN 報文後將客戶端連接信息放在 syns_queue 半連接隊列,同時回應 SYN+ACK 給客戶端,服務端收到客戶端的 ACK 之後,將 syns_queue 的連接信息移到 accept_queue 全連接隊列,到此才表示一個完整的連接已經建立。之後上層應用通過 accept 方法調用將 accept_queue 隊列的連接信息取出,如下圖:

這裏面有幾點需要注意和說明的是:

1. syns_queue 隊列是有界隊列,容量不夠怎麼辦?

首先看看容易導致容量不夠的一大原因:當客戶端持續發送 SYN,但不對服務端發送的 SYN+ACK 確認,此時半連接隊列的連接信息就不會移動到全連接隊列。這種持續性的 Flood 攻擊極容易導致 syns_queue 隊列滿。

2. accept_queue 隊列是有界隊列,容量不夠怎麼辦?

當應用程序不能及時處理連接請求,造成 accept_queue 滿,之後新發送的 SYN 或者 ACK(用於建立連接)將不會被處理,除非隊列出現空閒位置。

對以上問題的解決方法,詳細參考:https://coolshell.cn/articles/11564.html

TCP 大包分裂和重組

因爲鏈路層的通信協議對每次傳送的數據傳輸單元(maximum transmission unit,MTU)大小有限制,比如以太網的 MTU 爲 1500 字節,802.3 的 MTU 爲 1492 字節。MTU 也就是最大數據長度,反應到上層協議就是 IP 數據分組的最大長度,TCP 的最大數據分段大小(maximum segment size,MSS)。兩者的關係爲 MSS=MTU-sizeof(IP Header +TCP Header),如下圖所示:

這就引出以下幾個問題:

1. 如何設置 MSS 的值?

MSS 的值過小或者過大都不行,過小的數據負載,比如 TCP 傳輸 1 個字節同樣佔用 20 個字節的通信頭,佔用網絡帶寬,通信效率太低,所以這種一般用於傳遞控制信息。過大的數據負載,導致 IP 的數據分片,接收端收到分片需要還原原來的數據包,如果任意一個數據包傳輸失敗,還需要重試,從而導致傳輸開銷太大。所以最佳的大小是滿負載傳輸。

2. 通信雙方如何確定 MSS 的值?

在 TCP 連接的前兩步中,通過頭部可選數據部分傳遞 MSS,取兩者的最小值。

3. 當 TCP 傳輸的數據不是按照最大負荷 MSS 會發生什麼現象?

因爲 TCP 報文頭中沒有表示數據長度的字段,雖然對單個數據包的數據長度可以由 IP 包的數據大小計算出來,但是對連續的 TCP 數據包,上層協議是無法得知 TCP 包有多少,何時結束,對於接收端來說,TCP 數據是無界的字節流,實際上在 Socket 實現的時候也是發送端通過系統調用 send 將上層數據發送到發送緩存隊列即返回(記爲 SendQ),接收端只管從接收緩存隊列(記爲 RecvQ)。只要發送緩存隊列沒有滿,發送操作就不會阻塞,同樣的,接收緩存隊列不爲空就不會阻塞,數據發送端和接收端的收發示意圖如下:

當上層發送數據太多或者過小,進行 TCP 封包的時候就可以根據當前環境(比如緩存隊列的數據量、根據網絡擁塞情況動態設定的接收窗口大小等)靈活設定數據包的大小,比如將大包拆小或者將小包合併成大包,於是在網絡上傳送的 2 個包可能會出現以下 4 種情況:

上圖中四種情況解釋如下:

  • 兩個包相互獨立,互不影響;

  • 兩個包合爲一體(即粘包:同一個包頭,但數據合併在一起);

  • 某一個包拆分成兩個(即拆包成兩個或多個),其中一部分合併到另一個包。

這就是 TCP 的粘包和拆包現象,粘包的原因可能是協議爲了優化網絡傳輸效率,將多個小包合併到一起等,而拆包的原因無外乎是上層協議傳輸的數據過大,爲保證正常傳輸,將大包拆小。

這種現象只會發生在 TCP 協議上,UDP 協議因爲是無連接的,數據的分片是在 IP 層完成的,在接收端的傳輸層可以根據包長度自動完成數據的組合還原,因此接收端收到的仍是完整的數據包。相對於 TCP 的無界數據來說,UDP 的包數據是有界的。

TCP 重傳機制

重傳是保證 TCP 可靠性的重要手段之一,因爲數據包可能因爲網絡等問題導致數據包丟失,甚至是接收方發送的 ACK 包本身丟失,它們都會導致發送方重新發送數據包。這裏面涉及到很多問題,逐條列舉如下:

1. 什麼時候重發數據包?

這裏自然引入超時機制,在發送包的同時啓動一個定時器,如果沒有在超時之前收到 ACK,那麼發送方就會重發。

2. 如何確定超時時間 RTO(ReTransmission Timeout)?

正常情況下,收到確認包需要一來一回的往返時間 RTT(Round Trip Time),而 RTT 本身到網絡擁擠等情況影響波動會較大,實際上在確定 RTO 的時候在 RTT 的測量結果基礎上做平滑處理,自適應調整的。

3. 如何提高重傳效率?

使用超時重傳需要等到超時時間過期爲止,假如當前通信網絡比較糟糕,勢必帶來大量的超時等待。爲了優化超時等待的時間,發送方連續接收到接收方 3 次 ACK 就立即重傳,減少不必須的等待。如下圖所示的快速重傳,2 號包發送失敗,雖然之後的包都收到了,但是接收方仍然回覆對 2 號包的確認,當重複次數達到 3 次就立即重傳 2 號包:

當 2 號包收到之後,通過如何處理後續已經收到的包,可以引申出兩種優化後的通信協議:

  • 回退協議 :比如上圖中收到重傳的 2 號包後繼續重傳 3-5 號包,這種回退直接發送後續包的做法雖然處理起來較快,但是佔用網絡帶寬。另一種處理做法是隻重傳失敗的包。

  • 選擇重傳 :比如上圖中收到重傳的 2 號包接着確認上次已經接收到的 5 號包(即將接收 6 號包)。這就是選擇重傳 Selective Acknowledgment(SACK),它需要接收方額外存儲已經收到的包並且對其重排序,處理較慢,但是節約網絡帶寬。實際上,選擇重傳可以一般化描述爲,接收方可以收到很多不連續的包,即存在包“空洞”,此時接收端需要明確告訴接收方哪些包已經收到,哪些需要重傳。使用選擇性重傳,需要通信雙方都支持,並且在 TCP 頭部 option 字段中使用 SACK,一個示例如下圖所示:

4. 重傳何時結束?

換個問法就是如果超時重傳或者快重傳之後,發送方仍然沒有收到接收方的確認會怎麼辦?這種情況一般意味着網絡比較擁堵或者接收方出現故障,發送方採取的辦法是按照指數避讓(指數基數爲 2)的方式,增加後續重發的時間間隔,當達到最大重試次數(默認 15 次)就不再重發。

TCP 滑動窗口機制

在 TCP 重傳機制的示例中,我們已經見到了滑動窗口的影子:發送方在沒有接到接收方的確認時,可以連續發送多個數據包,如 1~5 號數據包,接收方異步發送確認。從發送方來看,接收方可以接受發送包的數量就是發送窗口大小。在詳細描述滑動窗口前先看下常規的停止-等待協議的示意圖:

每個數據包接收到確認,發送端纔會繼續發送下一個數據包,這種發送效率很低,自然地可以通過連續發送數據包,如下圖:

發送方和接收方窗口大小都是 3,發送端可以連續發送 3 個數據包,而不需等待每個都收到確認應答才繼續發送數據包,這就是滑動窗口的基本思想,相比逐一確認的方式,效率大大提高。下面以一個示例來看看滑動窗口的組成部分:

假設將所有數據包連起來形成一個隊列,對發送端來說,該隊列可以分爲 4 個部分,具體劃分見上圖。其中中間 2 部分就是我們關心的發送窗口:由已發送但沒有收到確認的數據包(#2)和沒有發送但可以發送的數據包(#3)組成。

關於滑動窗口需要注意的點:

  1. 圖示中將一個數據包抽象爲一個字節,發送窗口以字節爲單位往前推進。

  2. 當發送窗口中有幾個字節數據收到確認,發送窗口的左邊沿就前進幾個。

  3. 收到的確認可能不是嚴格按照左邊沿從左到右的順序,比如收到了 #2 中間部分的確認(捎帶確認),此時發送窗口左邊沿同樣可以移動到收到確認的位置。

  4. 發送端的滑動窗口大小是動態變化的,由接收端可用緩衝區大小決定。比如剛開始發送端和接收端窗口大小一樣(都是 20),當接收端接收並確認 10 個,但是接收端上層應用沒有讀取數據,此時接收端仍剩餘存儲空間大小 10。此時發送端雖然收到了 10 個確認包,但是其滑動窗口右邊沿仍然保持不變,除非接收端有更多存儲空間出來。

  5. 如果接收端接收窗口爲零發送端如何處理?如果接收端無法接收數據,最終反饋到發送端導致發送端發送窗口爲零,此時發送端持續性地發送窗口探測報文(比如同樣可以使用指數退避算法),直到接收端窗口再次打開。

  6. 接收端如果每次有少量可用窗口就給發送端發送窗口改變的通知,這樣就會導致發送端產生大量的小包(數據長度沒有達到 MSS 長度),該現象即糊塗窗口綜合徵(Silly Window Syndrome)。爲解決這個問題,發送端和接收端都可以考慮將數據積攢到一定大小,比如採用類似 Nagle 算法,當窗口大小超過一個 MSS 再發送。

TCP 擁塞控制

上面介紹的滑動窗口機制實際上是基於連接的處理端到端的流量控制,是一種微觀層面的“治堵”策略,因爲沒有考慮到網絡的整體運行情況。相對應的有一種基於網絡整體宏觀層面考量的“治堵”策略,這就是擁塞控制。在擁塞控制中引入了一個擁塞窗口(記爲 cwnd),此時發送端的發送窗口 swnd 就取擁塞窗口和接收窗口(rwnd)的較小值:swnd=min(cwnd,rwnd)。cwnd 如何取值將在下面介紹。

擁塞控制整體分爲 4 個階段,涉及 4 個算法:慢啓動,擁塞避免,擁塞發生,快速恢復。

慢啓動

該階段主要解決如何初始化擁塞窗口大小的問題,即發送方每收到一個 ACK,cwnd 加 1,於是經過一個輪次(窗口中的包全部發送完並且都收到確認)之後,cwnd 大小將翻倍,如下圖所示:

擁塞避免

當 cwnd 增加到 ssthresh (slow start threshold,通常爲 65535 字節 )閾值後,進入擁塞避免階段。該階段 cwnd 繼續增長,但是從指數增長轉爲線性增長:每收到一個 ACK,cwnd 增加 1/cwnd,經過一個輪次,cwnd 增加 1,如下圖所示:

擁塞發生

cwnd 繼續增長,直到發生丟包重傳現象。此時根據丟包重傳的不同應對策略,有兩種擁塞發生策略:

  • 如果是超時重傳,首先將 ssthresh 減半,然後 cwnd 重置爲 1,然後進入慢啓動階段;

  • 如果是快重傳,首先將 cwnd 減半,然後 ssthresh 調整爲減半後的 cwnd,最後進入快速恢復階段。

快速恢復

快速恢復跟快速重傳一起使用,考慮到已經收到快重傳收到的 3 個 ACK,所以設置 cwnd = sshthresh + 3,接着重傳丟失的數據包。然後根據收到的確認是重傳得數據包還是新的數據包設置 cwnd,如果是前者 cwnd=cwnd+1,否則 cwnd=ssthresh;最後進入擁塞避免階段。

上面 4 個階段從狀態轉換的角度總結爲如下圖:

(圖片來源:segmentfault.com)

TCP 長連接提升上層應用性能

從上文介紹的 TCP 連接和釋放需要經過 3 次握手和四次揮手過程,在高訪問量的情況下,大量 TCP 連接和關閉勢必嚴重影響網站性能,爲了在這種情況下優化網絡性能,可以使用 TCP 長連接。這裏就涉及到 TCP 協議之上的 HTTP 協議。早期的 HTTP/1.0,每次響應一個請求就會斷開 TCP 連接,除非服務器支持請求頭: Connection: keep-alive ,然而在 HTTP/1.1 默認開啓了請求頭,支持長連接。這樣就涉及到長連接的若干問題,一併列舉如下:

1. TCP 長連接是否支持複用?

答案是可以的。每個 HTTP 連接都可以共享同一個 TCP 連接,但是得串行使用,也就是當一個 HTTP 響應返回後下一個 HTTP 請求才可以發送。因爲 HTTP 協議是純文本協議,如果多個 HTTP 請求並行發送,客戶端對服務器的返回內容就無法區分每個請求的對應關係,至少是處理起來比較困難。雖然 HTTP/1.1 存在 Pipelining 技術,但是還不能保證瀏覽器能支持,所以瀏覽器默認都關閉這項功能。

2. 瀏覽器如何限制併發請求數量?

當瀏覽器打開一個頁面,理論上可以同時打開多個連接請求,如果沒有對連接數量的限制,客戶端或者服務器肯定無法接受。實際上,瀏覽器對同一個域名的訪問,默認併發連接數量限制爲 6 個。

3. HTTP/2 Multiplexing 多路複用功能如何工作的?

在 TCP 連接複用情況下有一個問題是如果前面有個連接處理阻塞,後面的請求也將被阻塞,雖然可以通過多個 TCP 連接來解決這個問題,但是意味着管理更多的 TCP 連接,另一種更優雅的方式是使用一個 TCP 連接,但是可以並行多個請求。不同於 HTPP/1.1 的 Pipeline 串行響應,HTTP/2 Multiplexing 通過並行的方式發送請求和響應,而且對請求和響應的順序不做要求,作爲兩者的對比示意圖如下:

(圖片來源:freecontent.manning.com)

圖示中,HTTP/1.1 客戶端發送 3 個請求,通過 3 個 HTTP 連接發送請求和接收響應。HTTP/2 客戶端 3 個請求共用一個 TCP 連接,實現多路複用的目的。如何實現的呢?一句話總結就是 HTTP/2 通過二進制分幀技術將 HTTP 請求和響應消息分割爲不同類型的幀,然後以幀爲單位,在一個雙向流通道上交替傳輸,仍然保持 HTTP/1 的消息格式和語義。

幀分層

對比 HTTP/1 的文本協議,HTTP/2 使用二進制格式,其處理效率自然要高於文本格式,但對高層應用使用透明,因爲幀結構並沒有改變原來的協議語義。作爲對比,看下如下圖:

示例中 HTTP/1 的請求頭部分對應 HTTP/2 的 HEADERS 幀,HTTP/1 的請求內容部分對應 HTTP/2 的 DATA 幀,可見 HTTP/2 只是對原有協議內容做了個包裝。

幀結構

每個類型的幀首部前 9 個字節固定,之後是變長的載荷,見下圖:

各字段含義見下表:

其中最重要的是幀類型的定義,其支持 10 種不同的幀數據類型的傳輸,詳情見下表:

另外在幀結構定義中,我們看到每個幀都有一個流唯一標識 ID,這樣發送端和接收端都可以根據該字段對幀數據重排。有了幀結構的理解,對如何實現消息推送,流量控制以及優先級就比較容易理解。

可靠 TCP 的進化

HTTP/2 中多個連接共享同一個 TCP,雖然它通過分幀技術讓多個連接併發進行,但仍沒有完全解決因爲 TCP 丟包導致的阻塞問題,換句話說此時 TCP 本身成了 HTTP/2 的最大瓶頸問題。爲了徹底解決該問題,Google 直接另起爐竈,從底層傳輸協議開始,使用了基於 UDP 的“QUIC”協議,引入了 HTTP/2 的流和多路複用概念,但是多個邏輯流獨立,這樣不僅讓連接速度更快,也解決了阻塞問題。但是 UDP 是非可靠的,無連接協議,如何實現上層的可靠傳輸呢?QUIC 不僅實現了類似 TCP 的流量控制、傳輸可靠性的功能還集成了 TLS 加密功能。爲了方便比較 HTTP 各版本的區別,可以看下面這個圖:

由圖可見,從 HTTPS 開始,協議都建立在 TLS 之上,而傳統的 HTTP 並不保證安全性。由此也可推知,數據傳輸安全的重要性。

總結

本文從 TCP 協議開始,通過面向連接管理的協議、容錯功能、失敗重傳、滑動窗口機制、網路擁塞控制和長連接優化上層應用等方面論述實現 TCP/IP 可靠傳輸的原理。最後介紹了基於 TCP 之上的 HTTP 協議的一些最新進展,雖然可靠的 TCP 看起來已經要被不可靠的 UDP 取代,雖然看起來像是一個“笑話”,但是這需要一個過程。更重要的是,即使未來 TCP 真的被完全拋棄,但是其“精神”還在,其可靠性仍然通過另一種形式繼續存在,如 QUIC。從 TCP 設計之初,安全性和可靠性一直是最重要的兩個方面,本文把重點都放在可靠性的講述上,並不是說安全性不重要,而是越來越重要。全文完。

更多精彩文章,請關注微信公衆號:碼上觀世界。

參考鏈接:

相關文章