你還在為TCP重傳、滑動窗口、流量控制、擁塞控制發(fā)愁嗎?看完圖解就不愁了
前言
前一篇「硬不硬你說了算!近 40 張圖解被問千百遍的 TCP 三次握手和四次揮手面試題」得到了很多讀者的認可,在此特別感謝你們的認可,大家都暖暖的。
來了,今天又來圖解 TCP 了,小林可能會遲到,但不會缺席。
遲到的原因,主要是 TCP 巨復(fù)雜,它為了保證可靠性,用了巨多的機制來保證,真是個「偉大」的協(xié)議,寫著寫著發(fā)現(xiàn)這水太深了。。。
本文的全部圖片都是小林繪畫的,非常的辛苦且累,不廢話了,直接進入正文吧,Go!
正文
相信大家都知道 TCP 是一個可靠傳輸?shù)膮f(xié)議,那如何它是如何保證可靠的呢?
為了實現(xiàn)可靠性傳輸,需要考慮很多事情,例如數(shù)據(jù)的破壞、丟包、重復(fù)以及分片順序混亂等問題。如不能解決這些問題,也就無從談起可靠傳輸。
那么,TCP 是通過序列號、確認應(yīng)答、重發(fā)控制、連接管理以及窗口控制等機制實現(xiàn)可靠性傳輸?shù)摹?/p>
今天,將重點介紹 TCP 的重傳機制、滑動窗口、流量控制、擁塞控制。
提綱
一、重傳機制
TCP 實現(xiàn)可靠傳輸?shù)姆绞街唬峭ㄟ^序列號與確認應(yīng)答。
在 TCP 中,當發(fā)送端的數(shù)據(jù)到達接收主機時,接收端主機會返回一個確認應(yīng)答消息,表示已收到消息。
正常的數(shù)據(jù)傳輸
但在錯綜復(fù)雜的網(wǎng)絡(luò),并不一定能如上圖那么順利能正常的數(shù)據(jù)傳輸,萬一數(shù)據(jù)在傳輸過程中丟失了呢?
所以 TCP 針對數(shù)據(jù)包丟失的情況,會用重傳機制解決。
接下來說說常見的重傳機制:
- 超時重傳
- 快速重傳
- SACK
- D-SACK
1. 超時重傳
重傳機制的其中一個方式,就是在發(fā)送數(shù)據(jù)時,設(shè)定一個定時器,當超過指定的時間后,沒有收到對方的 ACK 確認應(yīng)答報文,就會重發(fā)該數(shù)據(jù),也就是我們常說的超時重傳。
TCP 會在以下兩種情況發(fā)生超時重傳:
- 數(shù)據(jù)包丟失
- 確認應(yīng)答丟失
超時重傳的兩種情況
(1) 超時時間應(yīng)該設(shè)置為多少呢?
我們先來了解一下什么是 RTT(Round-Trip Time 往返時延),從下圖我們就可以知道:
RTT
RTT 就是數(shù)據(jù)從網(wǎng)絡(luò)一端傳送到另一端所需的時間,也就是包的往返時間。
超時重傳時間是以 RTO (Retransmission Timeout 超時重傳時間)表示。
假設(shè)在重傳的情況下,超時時間 RTO 「較長或較短」時,會發(fā)生什么事情呢?
超時時間較長與較短
上圖中有兩種超時時間不同的情況:
- 當超時時間 RTO 較大時,重發(fā)就慢,丟了老半天才重發(fā),沒有效率,性能差;
- 當超時時間 RTO 較小時,會導(dǎo)致可能并沒有丟就重發(fā),于是重發(fā)的就快,會增加網(wǎng)絡(luò)擁塞,導(dǎo)致更多的超時,更多的超時導(dǎo)致更多的重發(fā)。
精確的測量超時時間 RTO 的值是非常重要的,這可讓我們的重傳機制更高效。
根據(jù)上述的兩種情況,我們可以得知,超時重傳時間 RTO 的值應(yīng)該略大于報文往返 RTT 的值。
RTO 應(yīng)略大于 RTT
至此,可能大家覺得超時重傳時間 RTO 的值計算,也不是很復(fù)雜嘛。
好像就是在發(fā)送端發(fā)包時記下 t0 ,然后接收端再把這個 ack 回來時再記一個 t1,于是 RTT = t1 – t0。沒那么簡單,這只是一個采樣,不能代表普遍情況。
實際上「報文往返 RTT 的值」是經(jīng)常變化的,因為我們的網(wǎng)絡(luò)也是時常變化的。也就因為「報文往返 RTT 的值」 是經(jīng)常波動變化的,所以「超時重傳時間 RTO 的值」應(yīng)該是一個動態(tài)變化的值。
我們來看看 Linux 是如何計算 RTO 的呢?
估計往返時間,通常需要采樣以下兩個:
- 需要 TCP 通過采樣 RTT 的時間,然后進行加權(quán)平均,算出一個平滑 RTT 的值,而且這個值還是要不斷變化的,因為網(wǎng)絡(luò)狀況不斷地變化。
- 除了采樣 RTT,還要采樣 RTT 的波動范圍,這樣就避免如果 RTT 有一個大的波動的話,很難被發(fā)現(xiàn)的情況。
RFC6289 建議使用以下的公式計算 RTO:
RFC6289 建議的 RTO 計算
其中 SRTT 是計算平滑的RTT ,DevRTR 是計算平滑的RTT 與 最新 RTT 的差距。
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。別問怎么來的,問就是大量實驗中調(diào)出來的。
如果超時重發(fā)的數(shù)據(jù),再次超時的時候,又需要重傳的時候,TCP 的策略是超時間隔加倍。
也就是每當遇到一次超時重傳的時候,都會將下一次超時時間間隔設(shè)為先前值的兩倍。兩次超時,就說明網(wǎng)絡(luò)環(huán)境差,不宜頻繁反復(fù)發(fā)送。
超時觸發(fā)重傳存在的問題是,超時周期可能相對較長。那是不是可以有更快的方式呢?
于是就可以用「快速重傳」機制來解決超時重發(fā)的時間等待。
2. 快速重傳
TCP 還有另外一種快速重傳(Fast Retransmit)機制,它不以時間為驅(qū)動,而是以數(shù)據(jù)驅(qū)動重傳。
快速重傳機制,是如何工作的呢?其實很簡單,一圖勝千言。
快速重傳機制
在上圖,發(fā)送方發(fā)出了 1,2,3,4,5 份數(shù)據(jù):
- 第一份 Seq1 先送到了,于是就 Ack 回 2;
- 結(jié)果 Seq2 因為某些原因沒收到,Seq3 到達了,于是還是 Ack 回 2;
- 后面的 Seq4 和 Seq5 都到了,但還是 Ack 回 2,因為 Seq2 還是沒有收到;
- 發(fā)送端收到了三個 Ack = 2 的確認,知道了 Seq2 還沒有收到,就會在定時器過期之前,重傳丟失的 Seq2。
- 最后,接收到收到了 Seq2,此時因為 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。
所以,快速重傳的工作方式是當收到三個相同的 ACK 報文時,會在定時器過期之前,重傳丟失的報文段。
快速重傳機制只解決了一個問題,就是超時時間的問題,但是它依然面臨著另外一個問題。就是重傳的時候,是重傳之前的一個,還是重傳所有的問題。
比如對于上面的例子,是重傳 Seq2 呢?還是重傳 Seq2、Seq3、Seq4、Seq5 呢?因為發(fā)送端并不清楚這連續(xù)的三個 Ack 2 是誰傳回來的。
根據(jù) TCP 不同的實現(xiàn),以上兩種情況都是有可能的。可見,這是一把雙刃劍。
為了解決不知道該重傳哪些 TCP 報文,于是就有 SACK方法。
3. SACK 方法
還有一種實現(xiàn)重傳機制的方式叫:SACK( Selective Acknowledgment 選擇性確認)。
這種方式需要在 TCP 頭部「選項」字段里加一個 SACK的東西,它可以將緩存的地圖發(fā)送給發(fā)送方,這樣發(fā)送方就可以知道哪些數(shù)據(jù)收到了,哪些數(shù)據(jù)沒收到,知道了這些信息,就可以只重傳丟失的數(shù)據(jù)。
如下圖,發(fā)送方收到了三次同樣的 ACK 確認報文,于是就會觸發(fā)快速重發(fā)機制,通過 SACK 信息發(fā)現(xiàn)只有 200~299 這段數(shù)據(jù)丟失,則重發(fā)時,就只選擇了這個 TCP 段進行重復(fù)。
選擇性確認
如果要支持 SACK,必須雙方都要支持。在 Linux 下,可以通過 net.ipv4.tcp_sack 參數(shù)打開這個功能(Linux 2.4 后默認打開)。
4. Duplicate SACK
Duplicate SACK 又稱 D-SACK,其主要使用了 SACK 來告訴「發(fā)送方」有哪些數(shù)據(jù)被重復(fù)接收了。
下面舉例兩個栗子,來說明 D-SACK 的作用。
栗子一號:ACK 丟包
ACK 丟包
- 「接收方」發(fā)給「發(fā)送方」的兩個 ACK 確認應(yīng)答都丟失了,所以發(fā)送方超時后,重傳第一個數(shù)據(jù)包(3000 ~ 3499)
- 于是「接收方」發(fā)現(xiàn)數(shù)據(jù)是重復(fù)收到的,于是回了一個 SACK = 3000~3500,告訴「發(fā)送方」 3000~3500 的數(shù)據(jù)早已被接收了,因為 ACK 都到了 4000 了,已經(jīng)意味著 4000 之前的所有數(shù)據(jù)都已收到,所以這個 SACK 就代表著 D-SACK。
- 這樣「發(fā)送方」就知道了,數(shù)據(jù)沒有丟,是「接收方」的 ACK 確認報文丟了。
栗子二號:網(wǎng)絡(luò)延時
網(wǎng)絡(luò)延時
- 數(shù)據(jù)包(1000~1499) 被網(wǎng)絡(luò)延遲了,導(dǎo)致「發(fā)送方」沒有收到 Ack 1500 的確認報文。
- 而后面報文到達的三個相同的 ACK 確認報文,就觸發(fā)了快速重傳機制,但是在重傳后,被延遲的數(shù)據(jù)包(1000~1499)又到了「接收方」;
- 所以「接收方」回了一個 SACK=1000~1500,因為 ACK 已經(jīng)到了 3000,所以這個 SACK 是 D-SACK,表示收到了重復(fù)的包。
- 這樣發(fā)送方就知道快速重傳觸發(fā)的原因不是發(fā)出去的包丟了,也不是因為回應(yīng)的 ACK 包丟了,而是因為網(wǎng)絡(luò)延遲了。
可見,D-SACK 有這么幾個好處:
- 可以讓「發(fā)送方」知道,是發(fā)出去的包丟了,還是接收方回應(yīng)的 ACK 包丟了;
- 可以知道是不是「發(fā)送方」的數(shù)據(jù)包被網(wǎng)絡(luò)延遲了;
- 可以知道網(wǎng)絡(luò)中是不是把「發(fā)送方」的數(shù)據(jù)包給復(fù)制了;
在 Linux 下可以通過 net.ipv4.tcp_dsack 參數(shù)開啟/關(guān)閉這個功能(Linux 2.4 后默認打開)。
二、滑動窗口
1. 引入窗口概念的原因
我們都知道 TCP 是每發(fā)送一個數(shù)據(jù),都要進行一次確認應(yīng)答。當上一個數(shù)據(jù)包收到了應(yīng)答了, 再發(fā)送下一個。
這個模式就有點像我和你面對面聊天,你一句我一句。但這種方式的缺點是效率比較低的。
如果你說完一句話,我在處理其他事情,沒有及時回復(fù)你,那你不是要干等著我做完其他事情后,我回復(fù)你,你才能說下一句話,很顯然這不現(xiàn)實。
按數(shù)據(jù)包進行確認應(yīng)答
所以,這樣的傳輸方式有一個缺點:數(shù)據(jù)包的往返時間越長,通信的效率就越低。
為解決這個問題,TCP 引入了窗口這個概念。即使在往返時間較長的情況下,它也不會降低網(wǎng)絡(luò)通信的效率。
那么有了窗口,就可以指定窗口大小,窗口大小就是指無需等待確認應(yīng)答,而可以繼續(xù)發(fā)送數(shù)據(jù)的最大值。
窗口的實現(xiàn)實際上是操作系統(tǒng)開辟的一個緩存空間,發(fā)送方主機在等到確認應(yīng)答返回之前,必須在緩沖區(qū)中保留已發(fā)送的數(shù)據(jù)。如果按期收到確認應(yīng)答,此時數(shù)據(jù)就可以從緩存區(qū)清除。
假設(shè)窗口大小為 3 個 TCP 段,那么發(fā)送方就可以「連續(xù)發(fā)送」 3 個 TCP 段,并且中途若有 ACK 丟失,可以通過「下一個確認應(yīng)答進行確認」。如下圖:
用滑動窗口方式并行處理
圖中的 ACK 600 確認應(yīng)答報文丟失,也沒關(guān)系,因為可以通話下一個確認應(yīng)答進行確認,只要發(fā)送方收到了 ACK 700 確認應(yīng)答,就意味著 700 之前的所有數(shù)據(jù)「接收方」都收到了。這個模式就叫累計確認或者累計應(yīng)答。
(1) 窗口大小由哪一方?jīng)Q定?
TCP 頭里有一個字段叫 Window,也就是窗口大小。
這個字段是接收端告訴發(fā)送端自己還有多少緩沖區(qū)可以接收數(shù)據(jù)。于是發(fā)送端就可以根據(jù)這個接收端的處理能力來發(fā)送數(shù)據(jù),而不會導(dǎo)致接收端處理不過來。
所以,通常窗口的大小是由接收方的決定的。
發(fā)送方發(fā)送的數(shù)據(jù)大小不能超過接收方的窗口大小,否則接收方就無法正常接收到數(shù)據(jù)。
(2) 發(fā)送方的滑動窗口
我們先來看看發(fā)送方的窗口,下圖就是發(fā)送方緩存的數(shù)據(jù),根據(jù)處理的情況分成四個部分,其中深藍色方框是發(fā)送窗口,紫色方框是可用窗口:
- #1 是已發(fā)送并收到 ACK確認的數(shù)據(jù):1~31 字節(jié)
- #2 是已發(fā)送但未收到 ACK確認的數(shù)據(jù):32~45 字節(jié)
- #3 是未發(fā)送但總大小在接收方處理范圍內(nèi)(接收方還有空間):46~51字節(jié)
- #4 是未發(fā)送但總大小超過接收方處理范圍(接收方?jīng)]有空間):52字節(jié)以后
在下圖,當發(fā)送方把數(shù)據(jù)「全部」都一下發(fā)送出去后,可用窗口的大小就為 0 了,表明可用窗口耗盡,在沒收到 ACK 確認之前是無法繼續(xù)發(fā)送數(shù)據(jù)了。
可用窗口耗盡
在下圖,當收到之前發(fā)送的數(shù)據(jù) 32~36 字節(jié)的 ACK 確認應(yīng)答后,如果發(fā)送窗口的大小沒有變化,則滑動窗口往右邊移動 5 個字節(jié),因為有 5 個字節(jié)的數(shù)據(jù)被應(yīng)答確認,接下來 52~56 字節(jié)又變成了可用窗口,那么后續(xù)也就可以發(fā)送 52~56 這 5 個字節(jié)的數(shù)據(jù)了。
32 ~ 36 字節(jié)已確認
(3) 程序是如何表示發(fā)送方的四個部分的呢?
TCP 滑動窗口方案使用三個指針來跟蹤在四個傳輸類別中的每一個類別中的字節(jié)。其中兩個指針是絕對指針(指特定的序列號),一個是相對指針(需要做偏移)。
SND.WND、SND.UN、SND.NXT
- SND.WND:表示發(fā)送窗口的大小(大小是由接收方指定的);
- SND.UNA:是一個絕對指針,它指向的是已發(fā)送但未收到確認的第一個字節(jié)的序列號,也就是 #2 的第一個字節(jié)。
- SND.NXT:也是一個絕對指針,它指向未發(fā)送但可發(fā)送范圍的第一個字節(jié)的序列號,也就是 #3 的第一個字節(jié)。
- 指向 #4 的第一個字節(jié)是個相對指針,它需要 SND.NXT 指針加上 SND.WND 大小的偏移量,就可以指向 #4 的第一個字節(jié)了。
那么可用窗口大小的計算就可以是:可用窗口大 = SND.WND -(SND.NXT - SND.UNA)
(4) 接收方的滑動窗口
接下來我們看看接收方的窗口,接收窗口相對簡單一些,根據(jù)處理的情況劃分成三個部分:
- #1 + #2 是已成功接收并確認的數(shù)據(jù)(等待應(yīng)用進程讀取);
- #3 是未收到數(shù)據(jù)但可以接收的數(shù)據(jù);
- #4 未收到數(shù)據(jù)并不可以接收的數(shù)據(jù);
接收窗口
其中三個接收部分,使用兩個指針進行劃分:
- RCV.WND:表示接收窗口的大小,它會通告給發(fā)送方。
- RCV.NXT:是一個指針,它指向期望從發(fā)送方發(fā)送來的下一個數(shù)據(jù)字節(jié)的序列號,也就是 #3 的第一個字節(jié)。
- 指向 #4 的第一個字節(jié)是個相對指針,它需要 RCV.NXT 指針加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一個字節(jié)了。
(5) 接收窗口和發(fā)送窗口的大小是相等的嗎?
并不是完全相等,接收窗口的大小是約等于發(fā)送窗口的大小的。
因為滑動窗口并不是一成不變的。比如,當接收方的應(yīng)用進程讀取數(shù)據(jù)的速度非常快的話,這樣的話接收窗口可以很快的就空缺出來。那么新的接收窗口大小,是通過 TCP 報文中的 Windows 字段來告訴發(fā)送方。那么這個傳輸過程是存在時延的,所以接收窗口和發(fā)送窗口是約等于的關(guān)系。
三、流量控制
發(fā)送方不能無腦的發(fā)數(shù)據(jù)給接收方,要考慮接收方處理能力。
如果一直無腦的發(fā)數(shù)據(jù)給對方,但對方處理不過來,那么就會導(dǎo)致觸發(fā)重發(fā)機制,從而導(dǎo)致網(wǎng)絡(luò)流量的無端的浪費。
為了解決這種現(xiàn)象發(fā)生,TCP 提供一種機制可以讓「發(fā)送方」根據(jù)「接收方」的實際接收能力控制發(fā)送的數(shù)據(jù)量,這就是所謂的流量控制。
下面舉個栗子,為了簡單起見,假設(shè)以下場景:
- 客戶端是接收方,服務(wù)端是發(fā)送方
- 假設(shè)接收窗口和發(fā)送窗口相同,都為 200
- 假設(shè)兩個設(shè)備在整個傳輸過程中都保持相同的窗口大小,不受外界影響
流量控制
根據(jù)上圖的流量控制,說明下每個過程:
- 客戶端向服務(wù)端發(fā)送請求數(shù)據(jù)報文。這里要說明下,本次例子是把服務(wù)端作為發(fā)送方,所以沒有畫出服務(wù)端的接收窗口。
- 服務(wù)端收到請求報文后,發(fā)送確認報文和 80 字節(jié)的數(shù)據(jù),于是可用窗口 Usable 減少為 120 字節(jié),同時 SND.NXT 指針也向右偏移 80 字節(jié)后,指向 321,這意味著下次發(fā)送數(shù)據(jù)的時候,序列號是 321。
- 客戶端收到 80 字節(jié)數(shù)據(jù)后,于是接收窗口往右移動 80 字節(jié),RCV.NXT 也就指向 321,這意味著客戶端期望的下一個報文的序列號是 321,接著發(fā)送確認報文給服務(wù)端。
- 服務(wù)端再次發(fā)送了 120 字節(jié)數(shù)據(jù),于是可用窗口耗盡為 0,服務(wù)端無法在繼續(xù)發(fā)送數(shù)據(jù)。
- 客戶端收到 120 字節(jié)的數(shù)據(jù)后,于是接收窗口往右移動 120 字節(jié),RCV.NXT 也就指向 441,接著發(fā)送確認報文給服務(wù)端。
- 服務(wù)端收到對 80 字節(jié)數(shù)據(jù)的確認報文后,SND.UNA指針往右偏移后指向 321,于是可用窗口 Usable 增大到 80。
- 服務(wù)端收到對 120 字節(jié)數(shù)據(jù)的確認報文后,SND.UNA指針往右偏移后指向 441,于是可用窗口 Usable 增大到 200。
- 服務(wù)端可以繼續(xù)發(fā)送了,于是發(fā)送了 160 字節(jié)的數(shù)據(jù)后,SND.NXT 指向 601,于是可用窗口 Usable 減少到 40。
- 客戶端收到 160 字節(jié)后,接收窗口往右移動了 160 字節(jié),RCV.NXT 也就是指向了 601,接著發(fā)送確認報文給服務(wù)端。
- 服務(wù)端收到對 160 字節(jié)數(shù)據(jù)的確認報文后,發(fā)送窗口往右移動了 160 字節(jié),于是 SND.UNA 指針偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。
1. 操作系統(tǒng)緩沖區(qū)與滑動窗口的關(guān)系
前面的流量控制例子,我們假定了發(fā)送窗口和接收窗口是不變的,但是實際上,發(fā)送窗口和接收窗口中所存放的字節(jié)數(shù),都是放在操作系統(tǒng)內(nèi)存緩沖區(qū)中的,而操作系統(tǒng)的緩沖區(qū),會被操作系統(tǒng)調(diào)整。
當應(yīng)用進程沒辦法及時讀取緩沖區(qū)的內(nèi)容時,也會對我們的緩沖區(qū)造成影響。
(1) 那操心系統(tǒng)的緩沖區(qū),是如何影響發(fā)送窗口和接收窗口的呢?
我們先來看看第一個例子。
當應(yīng)用程序沒有及時讀取緩存時,發(fā)送窗口和接收窗口的變化。
考慮以下場景:
- 客戶端作為發(fā)送方,服務(wù)端作為接收方,發(fā)送窗口和接收窗口初始大小為 360;
- 服務(wù)端非常的繁忙,當收到客戶端的數(shù)據(jù)時,應(yīng)用層不能及時讀取數(shù)據(jù)。
根據(jù)上圖的流量控制,說明下每個過程:
- 客戶端發(fā)送 140 字節(jié)數(shù)據(jù)后,可用窗口變?yōu)?220 (360 - 140)。
- 服務(wù)端收到 140 字節(jié)數(shù)據(jù),但是服務(wù)端非常繁忙,應(yīng)用進程只讀取了 40 個字節(jié),還有 100 字節(jié)占用著緩沖區(qū),于是接收窗口收縮到了 260 (360 - 100),最后發(fā)送確認信息時,將窗口大小通過給客戶端。
- 客戶端收到確認和窗口通告報文后,發(fā)送窗口減少為 260。
- 客戶端發(fā)送 180 字節(jié)數(shù)據(jù),此時可用窗口減少到 80。
- 服務(wù)端收到 180 字節(jié)數(shù)據(jù),但是應(yīng)用程序沒有讀取任何數(shù)據(jù),這 180 字節(jié)直接就留在了緩沖區(qū),于是接收窗口收縮到了 80 (260 - 180),并在發(fā)送確認信息時,通過窗口大小給客戶端。
- 客戶端收到確認和窗口通告報文后,發(fā)送窗口減少為 80。
- 客戶端發(fā)送 80 字節(jié)數(shù)據(jù)后,可用窗口耗盡。
- 服務(wù)端收到 80 字節(jié)數(shù)據(jù),但是應(yīng)用程序依然沒有讀取任何數(shù)據(jù),這 80 字節(jié)留在了緩沖區(qū),于是接收窗口收縮到了 0,并在發(fā)送確認信息時,通過窗口大小給客戶端。
- 客戶端收到確認和窗口通告報文后,發(fā)送窗口減少為 0。
可見最后窗口都收縮為 0 了,也就是發(fā)生了窗口關(guān)閉。當發(fā)送方可用窗口變?yōu)?0 時,發(fā)送方實際上會定時發(fā)送窗口探測報文,以便知道接收方的窗口是否發(fā)生了改變,這個內(nèi)容后面會說,這里先簡單提一下。
我們先來看看第二個例子。
當服務(wù)端系統(tǒng)資源非常緊張的時候,操心系統(tǒng)可能會直接減少了接收緩沖區(qū)大小,這時應(yīng)用程序又無法及時讀取緩存數(shù)據(jù),那么這時候就有嚴重的事情發(fā)生了,會出現(xiàn)數(shù)據(jù)包丟失的現(xiàn)象。
說明下每個過程:
- 客戶端發(fā)送 140 字節(jié)的數(shù)據(jù),于是可用窗口減少到了 220。
- 服務(wù)端因為現(xiàn)在非常的繁忙,操作系統(tǒng)于是就把接收緩存減少了 100 字節(jié),當收到 對 140 數(shù)據(jù)確認報文后,又因為應(yīng)用程序沒有讀取任何數(shù)據(jù),所以 140 字節(jié)留在了緩沖區(qū)中,于是接收窗口大小從 360 收縮成了 100,最后發(fā)送確認信息時,通告窗口大小給對方。
- 此時客戶端因為還沒有收到服務(wù)端的通告窗口報文,所以不知道此時接收窗口收縮成了 100,客戶端只會看自己的可用窗口還有 220,所以客戶端就發(fā)送了 180 字節(jié)數(shù)據(jù),于是可用窗口減少到 40。
- 服務(wù)端收到了 180 字節(jié)數(shù)據(jù)時,發(fā)現(xiàn)數(shù)據(jù)大小超過了接收窗口的大小,于是就把數(shù)據(jù)包丟失了。
- 客戶端收到第 2 步時,服務(wù)端發(fā)送的確認報文和通告窗口報文,嘗試減少發(fā)送窗口到 100,把窗口的右端向左收縮了 80,此時可用窗口的大小就會出現(xiàn)詭異的負值。
所以,如果發(fā)生了先減少緩存,再收縮窗口,就會出現(xiàn)丟包的現(xiàn)象。
為了防止這種情況發(fā)生,TCP 規(guī)定是不允許同時減少緩存又收縮窗口的,而是采用先收縮窗口,過段時間在減少緩存,這樣就可以避免了丟包情況。
2. 窗口關(guān)閉
在前面我們都看到了,TCP 通過讓接收方指明希望從發(fā)送方接收的數(shù)據(jù)大小(窗口大小)來進行流量控制。
如果窗口大小為 0 時,就會阻止發(fā)送方給接收方傳遞數(shù)據(jù),直到窗口變?yōu)榉?0 為止,這就是窗口關(guān)閉。
(1) 窗口關(guān)閉潛在的危險
接收方向發(fā)送方通告窗口大小時,是通過 ACK 報文來通告的。
那么,當發(fā)生窗口關(guān)閉時,接收方處理完數(shù)據(jù)后,會向發(fā)送方通告一個窗口非 0 的 ACK 報文,如果這個通告窗口的 ACK 報文在網(wǎng)絡(luò)中丟失了,那麻煩就大了。
窗口關(guān)閉潛在的危險
這會導(dǎo)致發(fā)送方一直等待接收方的非 0 窗口通知,接收方也一直等待發(fā)送方的數(shù)據(jù),如不不采取措施,這種相互等待的過程,會造成了死鎖的現(xiàn)象。
(2) TCP 是如何解決窗口關(guān)閉時,潛在的死鎖現(xiàn)象呢?
為了解決這個問題,TCP 為每個連接設(shè)有一個持續(xù)定時器,只要 TCP 連接一方收到對方的零窗口通知,就啟動持續(xù)計時器。
如果持續(xù)計時器超時,就會發(fā)送窗口探測 ( Window probe ) 報文,而對方在確認這個探測報文時,給出自己現(xiàn)在的接收窗口大小。
窗口探測
- 如果接收窗口仍然為 0,那么收到這個報文的一方就會重新啟動持續(xù)計時器;
- 如果接收窗口不是 0,那么死鎖的局面就可以被打破了。
窗口探查探測的次數(shù)一般為 3 此次,每次次大約 30-60 秒(不同的實現(xiàn)可能會不一樣)。如果 3 次過后接收窗口還是 0 的話,有的 TCP 實現(xiàn)就會發(fā) RST 報文來中斷連接。
3. 糊涂窗口綜合癥
如果接收方太忙了,來不及取走接收窗口里的數(shù)據(jù),那么就會導(dǎo)致發(fā)送方的發(fā)送窗口越來越小。
到最后,如果接收方騰出幾個字節(jié)并告訴發(fā)送方現(xiàn)在有幾個字節(jié)的窗口,而發(fā)送方會義無反顧地發(fā)送這幾個字節(jié),這就是糊涂窗口綜合癥。
要知道,我們的 TCP + IP 頭有 40 個字節(jié),為了傳輸那幾個字節(jié)的數(shù)據(jù),要達上這么大的開銷,這太不經(jīng)濟了。
就好像一個可以承載 50 人的大巴車,每次來了一兩個人,就直接發(fā)車。除非家里有礦的大巴司機,才敢這樣玩,不然遲早破產(chǎn)。要解決這個問題也不難,大巴司機等乘客數(shù)量超過了 25 個,才認定可以發(fā)車。
現(xiàn)舉個糊涂窗口綜合癥的栗子,考慮以下場景:
接收方的窗口大小是 360 字節(jié),但接收方由于某些原因陷入困境,假設(shè)接收方的應(yīng)用層讀取的能力如下:
接收方每接收 3 個字節(jié),應(yīng)用程序就只能從緩沖區(qū)中讀取 1 個字節(jié)的數(shù)據(jù);
- 在下一個發(fā)送方的 TCP 段到達之前,應(yīng)用程序
- 還從緩沖區(qū)中讀取了 40 個額外的字節(jié);
糊涂窗口綜合癥
每個過程的窗口大小的變化,在圖中都描述的很清楚了,可以發(fā)現(xiàn)窗口不斷減少了,并且發(fā)送的數(shù)據(jù)都是比較小的了。
所以,糊涂窗口綜合癥的現(xiàn)象是可以發(fā)生在發(fā)送方和接收方:
- 接收方可以通告一個小的窗口
- 而發(fā)送方可以發(fā)送小數(shù)據(jù)
于是,要解決糊涂窗口綜合癥,就解決上面兩個問題就可以了
- 讓接收方不通告小窗口給發(fā)送方
- 讓發(fā)送方避免發(fā)送小數(shù)據(jù)
(1) 怎么讓接收方不通告小窗口呢?
接收方通常的策略如下:
當「窗口大小」小于 min( MSS,緩存空間/2 ) ,也就是小于 MSS 與 1/2 緩存大小中的最小值時,就會向發(fā)送方通告窗口為 0,也就阻止了發(fā)送方再發(fā)數(shù)據(jù)過來。
等到接收方處理了一些數(shù)據(jù)后,窗口大小 >= MSS,或者接收方緩存空間有一半可以使用,就可以把窗口打開讓發(fā)送方發(fā)送數(shù)據(jù)過來。
(2) 怎么讓發(fā)送方避免發(fā)送小數(shù)據(jù)呢?
發(fā)送方通常的策略:
使用 Nagle 算法,該算法的思路是延時處理,它滿足以下兩個條件中的一條才可以發(fā)送數(shù)據(jù):
- 要等到窗口大小 >= MSS 或是 數(shù)據(jù)大小 >= MSS
- 收到之前發(fā)送數(shù)據(jù)的 ack 回包
只要沒滿足上面條件中的一條,發(fā)送方一直在囤積數(shù)據(jù),直到滿足上面的發(fā)送條件。
另外,Nagle 算法默認是打開的,如果對于一些需要小數(shù)據(jù)包交互的場景的程序,比如,telnet 或 ssh 這樣的交互性比較強的程序,則需要關(guān)閉 Nagle 算法。
可以在 Socket 設(shè)置 TCP_NODELAY 選項來關(guān)閉這個算法(關(guān)閉 Nagle 算法沒有全局參數(shù),需要根據(jù)每個應(yīng)用自己的特點來關(guān)閉)
- setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
四、擁塞控制
1. 為什么要有擁塞控制呀,不是有流量控制了嗎?
前面的流量控制是避免「發(fā)送方」的數(shù)據(jù)填滿「接收方」的緩存,但是并不知道網(wǎng)絡(luò)的中發(fā)生了什么。
一般來說,計算機網(wǎng)絡(luò)都處在一個共享的環(huán)境。因此也有可能會因為其他主機之間的通信使得網(wǎng)絡(luò)擁堵。
在網(wǎng)絡(luò)出現(xiàn)擁堵時,如果繼續(xù)發(fā)送大量數(shù)據(jù)包,可能會導(dǎo)致數(shù)據(jù)包時延、丟失等,這時 TCP 就會重傳數(shù)據(jù),但是一重傳就會導(dǎo)致網(wǎng)絡(luò)的負擔更重,于是會導(dǎo)致更大的延遲以及更多的丟包,這個情況就會進入惡性循環(huán)被不斷地放大….
所以,TCP 不能忽略網(wǎng)絡(luò)上發(fā)生的事,它被設(shè)計成一個無私的協(xié)議,當網(wǎng)絡(luò)發(fā)送擁塞時,TCP 會自我犧牲,降低發(fā)送的數(shù)據(jù)量。
于是,就有了擁塞控制,控制的目的就是避免「發(fā)送方」的數(shù)據(jù)填滿整個網(wǎng)絡(luò)。
為了在「發(fā)送方」調(diào)節(jié)所要發(fā)送數(shù)據(jù)的量,定義了一個叫做「擁塞窗口」的概念。
2. 什么是擁塞窗口?和發(fā)送窗口有什么關(guān)系呢?
擁塞窗口 cwnd是發(fā)送方維護的一個 的狀態(tài)變量,它會根據(jù)網(wǎng)絡(luò)的擁塞程度動態(tài)變化的。
我們在前面提到過發(fā)送窗口 swnd 和接收窗口 rwnd 是約等于的關(guān)系,那么由于入了擁塞窗口的概念后,此時發(fā)送窗口的值是swnd = min(cwnd, rwnd),也就是擁塞窗口和接收窗口中的最小值。
擁塞窗口 cwnd 變化的規(guī)則:
- 只要網(wǎng)絡(luò)中沒有出現(xiàn)擁塞,cwnd 就會增大;
- 但網(wǎng)絡(luò)中出現(xiàn)了擁塞,cwnd 就減少;
3. 那么怎么知道當前網(wǎng)絡(luò)是否出現(xiàn)了擁塞呢?
其實只要「發(fā)送方」沒有在規(guī)定時間內(nèi)接收到 ACK 應(yīng)答報文,也就是發(fā)生了超時重傳,就會認為網(wǎng)絡(luò)出現(xiàn)了用擁塞。
4. 擁塞控制有哪些控制算法?
擁塞控制主要是四個算法:
- 慢啟動
- 擁塞避免
- 擁塞發(fā)生
- 快速恢復(fù)
(1) 慢啟動
TCP 在剛建立連接完成后,首先是有個慢啟動的過程,這個慢啟動的意思就是一點一點的提高發(fā)送數(shù)據(jù)包的數(shù)量,如果一上來就發(fā)大量的數(shù)據(jù),這不是給網(wǎng)絡(luò)添堵嗎?
慢啟動的算法記住一個規(guī)則就行:當發(fā)送方每收到一個 ACK,就擁塞窗口 cwnd 的大小就會加 1。
這里假定擁塞窗口 cwnd 和發(fā)送窗口 swnd 相等,下面舉個栗子:
- 連接建立完成后,一開始初始化 cwnd = 1,表示可以傳一個 MSS 大小的數(shù)據(jù)。
- 當收到一個 ACK 確認應(yīng)答后,cwnd 增加 1,于是一次能夠發(fā)送 2 個
- 當收到 2 個的 ACK 確認應(yīng)答后, cwnd 增加 2,于是就可以比之前多發(fā)2 個,所以這一次能夠發(fā)送 4 個
- 當這 4 個的 ACK 確認到來的時候,每個確認 cwnd 增加 1, 4 個確認 cwnd 增加 4,于是就可以比之前多發(fā) 4 個,所以這一次能夠發(fā)送 8 個。
慢啟動算法
可以看出慢啟動算法,發(fā)包的個數(shù)是指數(shù)性的增長。
那慢啟動漲到什么時候是個頭呢?
有一個叫慢啟動門限 ssthresh (slow start threshold)狀態(tài)變量。
- 當 cwnd < ssthresh 時,使用慢啟動算法。
- 當 cwnd >= ssthresh 時,就會使用「擁塞避免算法」。
(2) 擁塞避免算法
前面說道,當擁塞窗口 cwnd 「超過」慢啟動門限 ssthresh 就會進入擁塞避免算法。
一般來說 ssthresh 的大小是 65535 字節(jié)。
那么進入擁塞避免算法后,它的規(guī)則是:每當收到一個 ACK 時,cwnd 增加 1/cwnd。
接上前面的慢啟動的栗子,現(xiàn)假定 ssthresh 為 8:
當 8 個 ACK 應(yīng)答確認到來時,每個確認增加 1/8,8 個 ACK 確認 cwnd 一共增加 1,于是這一次能夠發(fā)送 9 個 MSS 大小的數(shù)據(jù),變成了線性增長。
擁塞避免
所以,我們可以發(fā)現(xiàn),擁塞避免算法就是將原本慢啟動算法的指數(shù)增長變成了線性增長,還是增長階段,但是增長速度緩慢了一些。
就這么一直增長著后,網(wǎng)絡(luò)就會慢慢進入了擁塞的狀況了,于是就會出現(xiàn)丟包現(xiàn)象,這時就需要對丟失的數(shù)據(jù)包進行重傳。
當觸發(fā)了重傳機制,也就進入了「擁塞發(fā)生算法」。
(3) 擁塞發(fā)生
當網(wǎng)絡(luò)出現(xiàn)擁塞,也就是會發(fā)生數(shù)據(jù)包重傳,重傳機制主要有兩種:
- 超時重傳
- 快速重傳
這兩種使用的擁塞發(fā)送算法是不同的,接下來分別來說說。
a. 發(fā)生超時重傳的擁塞發(fā)生算法
當發(fā)生了「超時重傳」,則就會使用擁塞發(fā)生算法。
這個時候,sshresh 和 cwnd 的值會發(fā)生變化:
- ssthresh 設(shè)為 cwnd/2,
- cwnd 重置為 1
擁塞發(fā)送 —— 超時重傳
接著,就重新開始慢啟動,慢啟動是會突然減少數(shù)據(jù)流的。這真是一旦「超時重傳」,馬上回到解放前。但是這種方式太激進了,反應(yīng)也很強烈,會造成網(wǎng)絡(luò)卡頓。
就好像本來在秋名山高速漂移著,突然來個緊急剎車,輪胎受得了嗎。。。
b. 發(fā)生快速重傳的擁塞發(fā)生算法
還有更好的方式,前面我們講過「快速重傳算法」。當接收方發(fā)現(xiàn)丟了一個中間包的時候,發(fā)送三次前一個包的 ACK,于是發(fā)送端就會快速地重傳,不必等待超時再重傳。
TCP 認為這種情況不嚴重,因為大部分沒丟,只丟了一小部分,則 ssthresh 和 cwnd 變化如下:
- cwnd = cwnd/2 ,也就是設(shè)置為原來的一半;
- ssthresh = cwnd;
- 進入快速恢復(fù)算法
(4) 快速恢復(fù)
快速重傳和快速恢復(fù)算法一般同時使用,快速恢復(fù)算法是認為,你還能收到 3 個重復(fù) ACK 說明網(wǎng)絡(luò)也不那么糟糕,所以沒有必要像 RTO 超時那么強烈。
正如前面所說,進入快速恢復(fù)之前,cwnd 和 ssthresh已被更新了:
- cwnd = cwnd/2 ,也就是設(shè)置為原來的一半;
- ssthresh = cwnd;
然后,進入快速恢復(fù)算法如下:
- 擁塞窗口 cwnd = ssthresh + 3 ( 3 的意思是確認有 3 個數(shù)據(jù)包被收到了)
- 重傳丟失的數(shù)據(jù)包
- 如果再收到重復(fù)的 ACK,那么 cwnd 增加 1
- 如果收到新數(shù)據(jù)的 ACK 后,設(shè)置 cwnd 為 ssthresh,接著就進入了擁塞避免算法
快速重傳和快速恢復(fù)
也就是沒有像「超時重傳」一夜回到解放前,而是還在比較高的值,后續(xù)呈線性增長。