淺談TCP(2):流量控制與擁塞控制

monkeysayhi發表於2018-04-09

上文淺談TCP(1):狀態機與重傳機制介紹了TCP的狀態機與重傳機制。本文介紹流量控制(Flow Control,簡稱流控)與擁塞控制(Congestion Control)。TCP依此保障網路的QOS(Quality of Service)。

本文大部分內容基於TCP 的那些事兒(下)修改而來,部分觀點與原文不同,重要地方增加了解釋。

TCP的流量控制

RTT演算法

根據前文對TCP超時重傳機制的介紹,我們知道Timeout的設定對於重傳非常重要:

  • 設長了,重發就慢,丟了老半天才重發,沒有效率,效能差。
  • 設短了,會導致可能並沒有丟就重發。於是重發的就快,會增加網路擁塞,導致更多的超時,更多的超時導致更多的重發。

而且,這個超時時間在不同的網路環境下不同,必須動態設定。為此,TCP引入了RTT(Round Trip Time,環回時間):一個資料包從發出去到回來的時間。這樣,傳送端就大約知道正常傳輸需要多少時間,據此計算RTO(Retransmission TimeOut,超時重傳時間)。 聽起來似乎很簡單:在傳送方發包時記下t0,收到接收方的Ack時記一個t1,於是RTT = t1 – t0。然而,這只是一個取樣,不能代表網路環境的普遍情況。

經典演算法

RFC793 中定義了一個經典演算法

  1. 首先,取樣RTT,記下最近幾次的RTT值。

  2. 然後,使用加權移動平均演算法(Weighted Moving Average Method)做平滑,計算SRTT(Smoothed RTT):

    SRTT = ( α * SRTT ) + ((1- α) * RTT) (通常取0.8≤α≤0.9,α越大收斂速度越快)
    複製程式碼
  3. 最後,計算RTO: RTO = min [ UBOUND, max [ LBOUND, (β * SRTT) ] ] (通常取1.3≤β≤2.0)

Karn / Partridge 演算法

經典演算法描述了RTO計算的基本思路,但還有一個重要問題:RTT的取樣取“第一次發Seq+收Ack的時間”,還是“重傳Seq+收Ack的時間”?

如圖:

image.png

  • 情況(a)中,RTT的取樣取“第一次發Seq+收Ack的時間”。假設第一次發的Seq徹底丟包了,則取樣到的時間多算了“從第一次發到重傳的間隔”。
  • 情況(b)中,RTT的取樣取“重傳Seq+收Ack的時間”。假設是Ack傳輸的慢,導致恰好剛重傳就收到了接收方之前發出的Ack,則取樣到的時間少算了“從第一次發到重傳的間隔”。

問題的本質是:傳送方無法區分收到的Ack對應第一次發的Seq還是重傳的Seq(進入網路就都一樣了)。針對該問題,Karn / Partridge演算法選擇迴避重傳的問題:忽略重傳的樣本,RTT的取樣只取未產生重傳的樣本

簡單的忽略重傳樣本也有問題:假設當前的RTO很小,突然發生網路抖動,延時劇增導致要重傳所有的包;由於忽略重傳樣本,RTO不會被更新,於是繼續重傳使網路更加擁堵;擁堵導致更多的重傳,惡性迴圈直至網路癱瘓。Karn / Partridge演算法用了一個取巧的辦法:只要一發生重傳,就將現有的RTO值翻倍(指數回退策略),待網路恢復後再仿照經典演算法逐漸平滑以降低RTO

該演算法已經做到可用,然而網路抖動對效能的影響比較大。

Jacobson / Karels 演算法

前面兩種演算法均使用加權移動平均演算法做平滑,這種方法的最大問題是:很難發現RTT值上的較大波動,因為被平滑掉了(1 - a比較小,即最新RTT的權重小)。針對該問題,Jacobson / Karels演算法引入了最新取樣的RTT值和平滑過的SRTT值的差距做因子,即DevRTT(Deviation RTT,RTT的偏離度),同時考慮SRTT帶來的慣性和DevRTT帶來的波動:

SRTT = SRTT + α(RTT – SRTT)  —— 計算SRTT
DevRTT = (1-β)*DevRTT + β*(|RTT-SRTT|) —— 計算SRTT和最新RTT的差距(加權移動平均)
RTO= µ * SRTT + ∂ *DevRTT —— 同時考慮SRTT(慣性)與DevRTT(波動)
複製程式碼

Linux 2.6採用該演算法計算RTO,預設取α = 0.125, β = 0.25, μ = 1, ∂ = 4(玄學調參,你懂的)。

TCP滑動視窗

TCP使用滑動視窗(Sliding Window)做流量控制與亂序重排。亂序重排在TCP的重傳機制中已經介紹,下面介紹流量控制。

TCP頭裡有一個欄位叫Window(或Advertised Window),用於接收方通知傳送方自己還有多少緩衝區可以接收資料傳送方根據接收方的處理能力來傳送資料,不會導致接收方處理不過來,是謂流量控制。暫且把Advertised Window當做滑動視窗,更容易理解滑動視窗如何完成流量控制,後面介紹擁塞控制時再說明二者的區別。

觀察TCP協議的傳送緩衝區和接收緩衝區:

image.png

假設位置序號從左向右增長(常見的讀、寫緩衝區設計),解釋一下:

  • 傳送方:LastByteAcked指向收到的連續最大Ack的位置;LastByteSent指向已傳送的最後一個位元組的位置;LastByteWritten指向上層應用已寫完的最後一個位元組的位置。
  • 接收方:LastByteRead指向上層應用已讀完的最後一個位元組的位置;NextByteExpected指向收到的連續最大Seq的位置;LastByteRcvd指向已收到的最後一個位元組的位置。可以看到NextByteExpected與LastByteRcvd中間有些Seq還沒有到達,對應空白區。

據此在接收方計算AdvertisedWindow,在傳送方計算EffectiveWindow

  • 接收方在Ack中記錄自己的AdvertisedWindow = MaxRcvBuffer – (LastByteRcvd - LastByteRead),隨Ack回覆到傳送方。
  • 傳送方根據Ack中的AdvertisedWindow值,需保證LastByteSent - LastByteAcked ≤ AdvertisedWindow,則視窗內剩餘可傳送的資料大小EffectiveWindow = AdvertisedWindow - (LastByteSent - LastByteAcked),以保證接收方可以處理。

AdvertisedWindow與EffectiveWindow

AdvertisedWindow衡量接收方還能接收的資料量,傳送方要根據AdvertisedWindow決定接下來傳送的資料量上限,即EffectiveWindow(可能為0)。

AdvertisedWindow的計算

由於亂序問題的存在,LastByteRcvd可能指向Seq(LastByteSent),而Seq(LastByteAcked + 1)至Seq(LastByteSent - 1)都還在路上,即將到達接收方,最好的情況是不丟包(丟包後會重傳),則LastByteRcvd之後、接收緩衝區邊界之前的空間就是傳送方下一次傳送資料的長度上限(重傳不屬於下一次傳送),因此,AdvertisedWindow = MaxRcvBuffer – (LastByteRcvd - LastByteRead)

EffectiveWindow的計算

LastByteRcvd還可能指向Seq(LastByteAcked)(一個新包都沒有收到),顯然AdvertisedWindow的公式不變,而Seq(LastByteAcked + 1)至Seq(LastByteSent)都還在路上,未來將到達接收方,進入接收緩衝區,則“還在路上的Seq(LastByteAcked + 1)至Seq(LastByteSent)”不應超過接收緩衝區的剩餘空間AdvertisedWindow(目前等於MaxRcvBuffer),這要求的是上一次傳送滿足LastByteSent - LastByteAcked ≤ AdvertisedWindow,那麼LastByteSent之後、接收緩衝區剩餘空間邊界之前的空間就是傳送方視窗內剩餘可傳送資料的長度上限,因此,EffectiveWindow = AdvertisedWindow - (LastByteSent - LastByteAcked)

當然,EffectiveWindow最小取0。

示例1

以下是一個傳送緩衝區的滑動視窗:

image.png

上圖分為4個部分:

  • #1是已傳送已確認的資料,即LastByteAcked之前的區域。
  • #2是已傳送未確認的資料,即LastByteAcked與LastByteSent之間的區域,大小不超過AdvertisedWindow。
  • #3是視窗內未傳送的資料,即LastByteSent與視窗右界之間的區域,大小等於EffectiveWindow(可能為0)。
  • #4是視窗外未傳送的資料,即視窗右界與LastByteWritten之間的區域。

其中,#2 + #3組成了滑動視窗,總大小不超過AdvertisedWindow,二者比例受到接收方的處理速度與網路情況的影響(如果丟包嚴重或處理速度慢於傳送速度,則#2:#3會越來越大)。

示例2

以下是一個AdvertisedWindow的調整過程,EffectiveWindow隨之變化:

image.png

Zero Window

理解有問題,不要求掌握。

上圖,我們可以看到一個處理緩慢的Server(接收端)是怎麼把Client(傳送端)的傳送視窗size給降成0的。對於接收方來說,此時接收緩衝區確實已經滿了,因此令傳送方的傳送視窗size降為0以暫時禁止傳送是合理的。那麼,等接收方的接收緩衝區再空出來,怎麼通知傳送方新的window size呢?

針對這個問題,為TCP設計了ZWP技術(Zero Window Probe,零窗通告):傳送方在視窗變成0後,會發ZWP的包給接收方,讓接收方來Ack他的Window尺寸;ZWP的重傳也遵循指數回退策略,預設重試3次;如果3次後window size還是0,則認為接收方出現異常,發RST重置連線(部分文章寫的是重試到window size正常???)。

注意:只要有等待的地方都可能出現DDoS攻擊,Zero Window也不例外。一些攻擊者會在和服務端建好連線發完GET請求後,就把Window設定為0,於是服務端就只能等待進行ZWP;然後攻擊者再大量併發傳送ZWP,把伺服器端的資源耗盡。(客戶端等待怎麼耗服務端???

TCP的擁塞控制

通訊中的擁塞指:

到達通訊子網中某一部分的分組數量過多,使得該部分網路來不及處理,以致引起這部分乃至整個網路效能下降的現象,嚴重時甚至會導致網路通訊業務陷入停頓即出現死鎖。

為什麼要進行擁塞控制?假設網路已經出現擁塞,如果不處理擁塞,那麼延時增加,出現更多丟包,觸發傳送方重傳資料,加劇擁塞情況,繼續惡性迴圈直至網路癱瘓。可知,擁塞控制與流量控制的適應場景和目的均不同。

擁塞發生前,可避免流量過快增長拖垮網路;擁塞發生時,唯一的選擇就是降低流量。主要使用4種演算法完成擁塞控制:

  1. 慢啟動
  2. 擁塞避免
  3. 擁塞發生
  4. 快速恢復

演算法1、2適用於擁塞發生前,演算法3適用於擁塞發生時,演算法4適用於擁塞解決後(相當於擁塞發生前)。

rwnd與cwnd

在正式介紹上述演算法之前,先補充下rwnd(Receiver Window,接收者視窗)與cwnd(Congestion Window,擁塞視窗)的概念:

  • rwnd是用於流量控制的視窗大小,即上述流量控制中的AdvertisedWindow,主要取決於接收方的處理速度,由接收方通知傳送方被動調整(詳細邏輯見上)。
  • cwnd是用於擁塞處理的視窗大小,取決於網路狀況,由傳送方探查網路主動調整。

介紹流量控制時,我們沒有考慮cwnd,認為傳送方的滑動視窗最大即為rwnd。實際上,需要同時考慮流量控制與擁塞處理,則傳送方視窗的大小不超過min{rwnd, cwnd}。下述4種擁塞控制演算法只涉及對cwnd的調整,同介紹流量控制時一樣,暫且不考慮rwnd,假定滑動視窗最大為cwnd;但讀者應明確rwnd、cwnd與傳送方視窗大小的關係。

4種擁塞控制演算法

慢啟動演算法

慢啟動演算法(Slow Start)作用在擁塞產生之前:對於剛剛加入網路的連線,要一點一點的提速,不要妄圖一步到位。如下:

  1. 連線剛建好,初始化cwnd = 1(當然,通常不會初始化為1,太小),表明可以傳一個MSS大小的資料。
  2. 每收到一個ACK,cwnd++,線性增長。
  3. 每經過一個RTT,cwnd = cwnd * 2,指數增長(主要增長來源)。
  4. 還有一個ssthresh(slow start threshold),當cwnd >= ssthresh時,就會進入擁塞避免演算法(見後)。

因此,如果網速很快的話,Ack返回快,RTT短,那麼,這個慢啟動就一點也不慢。下圖說明了這個過程:

image.png

擁塞避免演算法

前面說過,當cwnd >= ssthresh(通常ssthresh = 65535)時,就會進入擁塞避免演算法(Congestion Avoidance):緩慢增長,小心翼翼的找到最優值。如下:

  1. 每收到一個Ack,cwnd = cwnd + 1/cwnd,顯然,cwnd > 1時無增長。
  2. 每經過一個RTT,cwnd++,線性增長(主要增長來源)。

慢啟動演算法主要呈指數增長,粗獷型,速度快(“慢”是相對於一步到位而言的);而擁塞避免演算法主要呈線性增長,精細型,速度慢,但更容易在不導致擁塞的情況下,找到網路環境的cwnd最優值。

擁塞發生時的演算法

慢啟動與擁塞避免演算法作用在擁塞發生前,採取不同的策略增大cwnd;如果已經發生擁塞,則需要採取策略減小cwnd。那麼,TCP如何判斷當前網路擁塞了呢?很簡單,如果傳送方發現有Seq傳送失敗(表現為“丟包”),就認為網路擁塞了

丟包後,有兩種重傳方式,對應不同的網路情況,也就對應著兩種擁塞發生時的控制演算法:

  1. 超時重傳。TCP認為這種情況太糟糕,調整力度比較大:
    1. ssthresh = cwnd /2
    2. cwnd = 1,重新進入慢啟動過程(網路糟糕,要慢慢調整)
  2. 快速重傳。TCP認為這種情況通常比RTO超時好一些,主流實現TCP Reno的調整力度更柔和(TCP Tahoe的實現和RTO超時一樣暴躁):
    1. ssthresh = cwnd /2
    2. cwnd = cwnd /2,進入快速恢復演算法(網路沒那麼糟,可以快速調整,見下)

可以看到,不管是哪種重傳方式,ssthresh都會變成cwnd的一半,仍然是_指數回退,待擁塞消失後再逐漸增長回到新的最優值_,總體上在最優值(動態)附近震盪。

回退後,根據不同的網路情況,可以選擇不同的恢復演算法。慢啟動已經介紹過了,下面介紹快速恢復演算法。

快速恢復演算法

如果觸發了快速重傳,即傳送方收到至少3次相同的Ack,那麼TCP認為網路情況不那麼糟,也就沒必要提心吊膽的,可以適當大膽的恢復。為此設計快速恢復演算法(Fast Recovery),下面介紹TCP Reno中的實現。

回顧一下,進入快速恢復之前,cwnd和sshthresh已被更新:

  1. ssthresh = cwnd /2
  2. cwnd = cwnd /2

然後,進入快速恢復演算法:

  1. cwnd = ssthresh + 3 * MSS (嘗試一步到位)
  2. 重傳重複Ack對應的Seq
  3. 如果再收到該重複Ack,則cwnd++,線性增長(緩慢調整)
  4. 如果收到了新Ack,則cwnd = ssthresh ,然後就進入了擁塞避免的演算法了(為什麼收到新Ack要降低sshthresh???

可暫時忽略的內容:

這種實現也有問題:依賴於3個重複Ack。回憶上文討論的“重傳一個還是重傳所有的問題”,3個重複Ack並不代表只丟了一個資料包,很有可能是丟了好多包。顯然快速恢復演算法選擇“重傳一個”,而剩下的那些包只能等到RTO超時。於是,超時一個視窗就減半一下,多個超時會超成TCP的傳輸速度指數級下降;同時,超時重傳不會觸發快速恢復演算法,慢啟動很容易加劇超時的情況,進入惡性迴圈。

示例

下面看一個簡單的圖示,感受擁塞控制過程中的cwnd變化:

image.png


參考:


本文連結:淺談TCP(2):流量控制與擁塞控制
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章