原文:【圖解】你還在為 TCP 重傳、滑動窗口、流量控制、擁塞控制發愁嗎?看完圖解就不愁了
作者:小林coding
TCP窗口
在tcp的首部有Window字段
,也就是窗口大小。
這個字段是接收端告訴發送端自己還有多少緩沖區可以接收數據。於是發送端就可以根據這個接收端的處理能力來發送數據,而不會導致接收端處理不過來。
首先明確滑動窗口的定義:TCP是雙工的協議,會話的雙方都可以同時接收和發送數據。
TCP會話的雙方都各自維護一個發送窗口和一個接收窗口。
各自的發送窗口則要求取決於對端通告的接收窗口,要求相同。
各自的接收窗口大小取決於應用、系統、硬件的限制(TCP傳輸速率不能大於應用的數據處理速率)。
TCP的滑動窗口機制有什么作用
可靠性:
- 發送端的滑動窗口只有在前面所有端都被確認之后才會往后移動,保證數據包被接收方確認並接收。(發送端關心ack)
- 接收端的滑動窗口只有在前面所有的段都接收的情況下才會移動左邊界。當在前面還有字節未接收但收到后面字節的情況下,窗口也不會移動,保證數據的全部接收。(接收端關心數據有沒有接收到)
流量控制:根據接收端的接收情況,動態去調整窗口大小,然后來控制發送端的數據流量。
TCP的滑動窗口的結構
發送方的滑動窗口
我們先來看看發送方的窗口,下圖就是發送方緩存的數據,根據處理的情況分成四個部分,其中深藍色方框是發送窗口,紫色方框是可用窗口:
- #1 是已發送並收到 ACK確認的數據:1~31 字節
- #2 是已發送但未收到 ACK確認的數據:32~45 字節
- #3 是未發送但總大小在接收方處理范圍內(接收方還有空間):46~51字節
- #4 是未發送但總大小超過接收方處理范圍(接收方沒有空間):52字節以后
在下圖,當發送方把數據「全部」都一下發送出去后,可用窗口的大小就為 0 了,表明可用窗口耗盡,在沒收到 ACK 確認之前是無法繼續發送數據了。
可用窗口耗盡
在下圖,當收到之前發送的數據 32~36
字節的 ACK 確認應答后,如果發送窗口的大小沒有變化,則滑動窗口往右邊移動 5 個字節,因為有 5 個字節的數據被應答確認,接下來 52~56
字節又變成了可用窗口,那么后續也就可以發送 52~56
這 5 個字節的數據了。
32 ~ 36 字節已確認
程序是如何表示發送方的四個部分的呢?
TCP 滑動窗口方案使用三個指針來跟蹤在四個傳輸類別中的每一個類別中的字節。其中兩個指針是絕對指針(指特定的序列號),一個是相對指針(需要做偏移)。
SND.WND、SND.UN、SND.NXT
-
SND.WND
:表示發送窗口的大小(大小是由接收方指定的); -
SND.UNA
:是一個絕對指針,它指向的是已發送但未收到確認的第一個字節的序列號,也就是 #2 的第一個字節。 -
SND.NXT
:也是一個絕對指針,它指向未發送但可發送范圍的第一個字節的序列號,也就是 #3 的第一個字節。 -
指向 #4 的第一個字節是個相對指針,它需要
SND.UNA
指針加上SND.WND
大小的偏移量,就可以指向 #4 的第一個字節了。
那么可用窗口大小的計算就可以是:
可用窗口大 = SND.WND -(SND.NXT - SND.UNA)
接收方的滑動窗口
接下來我們看看接收方的窗口,接收窗口相對簡單一些,根據處理的情況划分成三個部分:
- #1 + #2 是已成功接收並確認的數據(等待應用進程讀取);
- #3 是未收到數據但可以接收的數據;
- #4 未收到數據並不可以接收的數據;
接收窗口
其中三個接收部分,使用兩個指針進行划分:
RCV.WND
:表示接收窗口的大小,它會通告給發送方。RCV.NXT
:是一個指針,它指向期望從發送方發送來的下一個數據字節的序列號,也就是 #3 的第一個字節。- 指向 #4 的第一個字節是個相對指針,它需要
RCV.NXT
指針加上RCV.WND
大小的偏移量,就可以指向 #4 的第一個字節了。
接收窗口和發送窗口的大小是相等的嗎?
並不是完全相等,接收窗口的大小是約等於發送窗口的大小的。
因為滑動窗口並不是一成不變的。比如,當接收方的應用進程讀取數據的速度非常快的話,這樣的話接收窗口可以很快的就空缺出來。那么新的接收窗口大小,是通過 TCP 報文中的 Windows 字段來告訴發送方。那么這個傳輸過程是存在時延的,所以接收窗口和發送窗口是約等於的關系。
流量控制
發送方不能無腦的發數據給接收方,要考慮接收方處理能力。
如果一直無腦的發數據給對方,但對方處理不過來,那么就會導致觸發重發機制,從而導致網絡流量的無端的浪費。
為了解決這種現象發生,TCP 提供一種機制可以讓「發送方」根據「接收方」的實際接收能力控制發送的數據量,這就是所謂的流量控制。
下面舉個栗子,為了簡單起見,假設以下場景:
- 客戶端是接收方,服務端是發送方
- 假設接收窗口和發送窗口相同,都為
200
- 假設兩個設備在整個傳輸過程中都保持相同的窗口大小,不受外界影響
根據上圖的流量控制,說明下每個過程:
- 客戶端向服務端發送請求數據報文。這里要說明下,本次例子是把服務端作為發送方,所以沒有畫出服務端的接收窗口。
- 服務端收到請求報文后,發送確認報文和 80 字節的數據,於是可用窗口
Usable
減少為 120 字節,同時SND.NXT
指針也向右偏移 80 字節后,指向 321,這意味着下次發送數據的時候,序列號是 321。 - 客戶端收到 80 字節數據后,於是接收窗口往右移動 80 字節,
RCV.NXT
也就指向 321,這意味着客戶端期望的下一個報文的序列號是 321,接着發送確認報文給服務端。 - 服務端再次發送了 120 字節數據,於是可用窗口耗盡為 0,服務端無法在繼續發送數據。
- 客戶端收到 120 字節的數據后,於是接收窗口往右移動 120 字節,
RCV.NXT
也就指向 441,接着發送確認報文給服務端。 - 服務端收到對 80 字節數據的確認報文后,
SND.UNA
指針往右偏移后指向 321,於是可用窗口Usable
增大到 80。 - 服務端收到對 120 字節數據的確認報文后,
SND.UNA
指針往右偏移后指向 441,於是可用窗口Usable
增大到 200。 - 服務端可以繼續發送了,於是發送了 160 字節的數據后,
SND.NXT
指向 601,於是可用窗口Usable
減少到 40。 - 客戶端收到 160 字節后,接收窗口往右移動了 160 字節,
RCV.NXT
也就是指向了 601,接着發送確認報文給服務端。 - 服務端收到對 160 字節數據的確認報文后,發送窗口往右移動了 160 字節,於是
SND.UNA
指針偏移了 160 后指向 601,可用窗口Usable
也就增大至了 200。
操作系統緩沖區與滑動窗口的關系
前面的流量控制例子,我們假定了發送窗口和接收窗口是不變的,但是實際上,發送窗口和接收窗口中所存放的字節數,都是放在操作系統內存緩沖區中的,而操作系統的緩沖區,會被操作系統調整。
當應用進程沒辦法及時讀取緩沖區的內容時,也會對我們的緩沖區造成影響。
那操心系統的緩沖區,是如何影響發送窗口和接收窗口的呢?
我們先來看看第一個例子。
當應用程序沒有及時讀取緩存時,發送窗口和接收窗口的變化。
考慮以下場景:
- 客戶端作為發送方,服務端作為接收方,發送窗口和接收窗口初始大小為
360
; - 服務端非常的繁忙,當收到客戶端的數據時,應用層不能及時讀取數據。
根據上圖的流量控制,說明下每個過程:
- 客戶端發送 140 字節數據后,可用窗口變為 220 (360 - 140)。
- 服務端收到 140 字節數據,但是服務端非常繁忙,應用進程只讀取了 40 個字節,還有 100 字節占用着緩沖區,於是接收窗口收縮到了 260 (360 - 100),最后發送確認信息時,將窗口大小通過給客戶端。
- 客戶端收到確認和窗口通告報文后,發送窗口減少為 260。
- 客戶端發送 180 字節數據,此時可用窗口減少到 80。
- 服務端收到 180 字節數據,但是應用程序沒有讀取任何數據,這 180 字節直接就留在了緩沖區,於是接收窗口收縮到了 80 (260 - 180),並在發送確認信息時,通過窗口大小給客戶端。
- 客戶端收到確認和窗口通告報文后,發送窗口減少為 80。
- 客戶端發送 80 字節數據后,可用窗口耗盡。
- 服務端收到 80 字節數據,但是應用程序依然沒有讀取任何數據,這 80 字節留在了緩沖區,於是接收窗口收縮到了 0,並在發送確認信息時,通過窗口大小給客戶端。
- 客戶端收到確認和窗口通告報文后,發送窗口減少為 0。
可見最后窗口都收縮為 0 了,也就是發生了窗口關閉。當發送方可用窗口變為 0 時,發送方實際上會定時發送窗口探測報文,以便知道接收方的窗口是否發生了改變,這個內容后面會說,這里先簡單提一下。
我們先來看看第二個例子。
當服務端系統資源非常緊張的時候,操心系統可能會直接減少了接收緩沖區大小,這時應用程序又無法及時讀取緩存數據,那么這時候就有嚴重的事情發生了,會出現數據包丟失的現象。
說明下每個過程:
- 客戶端發送 140 字節的數據,於是可用窗口減少到了 220。
- 服務端因為現在非常的繁忙,操作系統於是就把接收緩存減少了 100 字節,當收到 對 140 數據確認報文后,又因為應用程序沒有讀取任何數據,所以 140 字節留在了緩沖區中,於是接收窗口大小從 360 收縮成了 100,最后發送確認信息時,通告窗口大小給對方。
- 此時客戶端因為還沒有收到服務端的通告窗口報文,所以不知道此時接收窗口收縮成了 100,客戶端只會看自己的可用窗口還有 220,所以客戶端就發送了 180 字節數據,於是可用窗口減少到 40。
- 服務端收到了 180 字節數據時,發現數據大小超過了接收窗口的大小,於是就把數據包丟失了。
- 客戶端收到第 2 步時,服務端發送的確認報文和通告窗口報文,嘗試減少發送窗口到 100,把窗口的右端向左收縮了 80,此時可用窗口的大小就會出現詭異的負值。
所以,如果發生了先減少緩存,再收縮窗口,就會出現丟包的現象。
為了防止這種情況發生,TCP 規定是不允許同時減少緩存又收縮窗口的,而是采用先收縮窗口,過段時間在減少緩存,這樣就可以避免了丟包情況。
窗口關閉
在前面我們都看到了,TCP 通過讓接收方指明希望從發送方接收的數據大小(窗口大小)來進行流量控制。
如果窗口大小為 0 時,就會阻止發送方給接收方傳遞數據,直到窗口變為非 0 為止,這就是窗口關閉。
窗口關閉潛在的危險
接收方向發送方通告窗口大小時,是通過 ACK
報文來通告的。
那么,當發生窗口關閉時,接收方處理完數據后,會向發送方通告一個窗口非 0 的 ACK 報文,如果這個通告窗口的 ACK 報文在網絡中丟失了,那麻煩就大了。
窗口關閉潛在的危險
這會導致發送方一直等待接收方的非 0 窗口通知,接收方也一直等待發送方的數據,如不不采取措施,這種相互等待的過程,會造成了死鎖的現象。
TCP 是如何解決窗口關閉時,潛在的死鎖現象呢?
為了解決這個問題,TCP 為每個連接設有一個持續定時器,只要 TCP 連接一方收到對方的零窗口通知,就啟動持續計時器。
如果持續計時器超時,就會發送窗口探測 ( Window probe ) 報文,而對方在確認這個探測報文時,給出自己現在的接收窗口大小。
窗口探測
- 如果接收窗口仍然為 0,那么收到這個報文的一方就會重新啟動持續計時器;
- 如果接收窗口不是 0,那么死鎖的局面就可以被打破了。
窗口探查探測的次數一般為 3 此次,每次次大約 30-60 秒(不同的實現可能會不一樣)。如果 3 次過后接收窗口還是 0 的話,有的 TCP 實現就會發 RST
報文來中斷連接。
糊塗窗口綜合症
如果接收方太忙了,來不及取走接收窗口里的數據,那么就會導致發送方的發送窗口越來越小。
到最后,如果接收方騰出幾個字節並告訴發送方現在有幾個字節的窗口,而發送方會義無反顧地發送這幾個字節,這就是糊塗窗口綜合症。
要知道,我們的 TCP + IP
頭有 40
個字節,為了傳輸那幾個字節的數據,要達上這么大的開銷,這太不經濟了。
就好像一個可以承載 50 人的大巴車,每次來了一兩個人,就直接發車。除非家里有礦的大巴司機,才敢這樣玩,不然遲早破產。要解決這個問題也不難,大巴司機等乘客數量超過了 25 個,才認定可以發車。
現舉個糊塗窗口綜合症的栗子,考慮以下場景:
接收方的窗口大小是 360 字節,但接收方由於某些原因陷入困境,假設接收方的應用層讀取的能力如下:
- 接收方每接收 3 個字節,應用程序就只能從緩沖區中讀取 1 個字節的數據;
- 在下一個發送方的 TCP 段到達之前,應用程序
還從緩沖區中讀取了 40 個額外的字節;
每個過程的窗口大小的變化,在圖中都描述的很清楚了,可以發現窗口不斷減少了,並且發送的數據都是比較小的了。
所以,糊塗窗口綜合症的現象是可以發生在發送方和接收方:
- 接收方可以通告一個小的窗口
- 而發送方可以發送小數據
於是,要解決糊塗窗口綜合症,就解決上面兩個問題就可以了
- 讓接收方不通告小窗口給發送方
- 讓發送方避免發送小數據
怎么讓接收方不通告小窗口呢?
接收方通常的策略如下:
當「窗口大小」小於 min( MSS,緩存空間/2 ) ,也就是小於 MSS 與 1/2 緩存大小中的最小值時,就會向發送方通告窗口為 0
,也就阻止了發送方再發數據過來。
等到接收方處理了一些數據后,窗口大小 >= MSS,或者接收方緩存空間有一半可以使用,就可以把窗口打開讓發送方發送數據過來。
怎么讓發送方避免發送小數據呢?
發送方通常的策略:
使用 Nagle 算法,該算法的思路是延時處理,它滿足以下兩個條件中的一條才可以發送數據:
- 要等到窗口大小 >=
MSS
或是 數據大小 >=MSS
- 收到之前發送數據的
ack
回包
只要沒滿足上面條件中的一條,發送方一直在囤積數據,直到滿足上面的發送條件。
另外,Nagle 算法默認是打開的,如果對於一些需要小數據包交互的場景的程序,比如,telnet 或 ssh 這樣的交互性比較強的程序,則需要關閉 Nagle 算法。
可以在 Socket 設置 TCP_NODELAY
選項來關閉這個算法(關閉 Nagle 算法沒有全局參數,需要根據每個應用自己的特點來關閉)
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));