淺析TCP協議中的疑難雜症(下篇)

TIME_WAIT的快速回收和重用

1TIME_WAIT快速回收


linux下開啟TIME_WAIT快速回收需要同時打開tcp_tw_recycle和tcp_timestamps(默認打開)兩選項。Linux下快速回收的時間為3.5 * RTO(Retransmission Timeout),而一個RTO時間為200ms至120s。開啟快速回收TIME_WAIT,可能會帶來(問題一、)中說的三點危險。
為了避免這些危險,要求同時滿足以下三種情況的新連接要被拒絕掉:

  • 1)來自同一個對端Peer的TCP包攜帶了時間戳;
  • 2)之前同一臺peer機器(僅僅識別IP地址,因為連接被快速釋放了,沒了端口信息)的某個TCP數據在MSL秒之內到過本Server;
  • 3)Peer機器新連接的時間戳小於peer機器上次TCP到來時的時間戳,且差值大於重放窗口戳(TCP_PAWS_WINDOW)。初看起來正常的數據包同時滿足下面3條几乎不可能, 因為機器的時間戳不可能倒流的,出現上述的3點均滿足時,一定是老的重複數據包又回來了,丟棄老的SYN包是正常的。到此,似乎啟用快速回收就能很大程度緩解TIME_WAIT帶來的問題。但是,這裡忽略了一個東西就是NAT。。。在一個NAT後面的所有Peer機器在Server看來都是一個機器,NAT後面的那麼多Peer機器的系統時間戳很可能不一致,有些快,有些慢。這樣,在Server關閉了與系統時間戳快的Client的連接後,在這個連接進入快速回收的時候,同一NAT後面的系統時間戳慢的Client向Server發起連接,這就很有可能同時滿足上面的三種情況,造成該連接被Server拒絕掉。所以,在是否開啟tcp_tw_recycle需要慎重考慮了。


2TIME_WAIT重用


linux上比較完美的實現了TIME_WAIT重用問題。只要滿足下面兩點中的一點,一個TW狀態的四元組(即一個socket連接)可以重新被新到來的SYN連接使用:

  • 1)新連接SYN告知的初始序列號比TIME_WAIT老連接的末序列號大;
  • 2)如果開啟了tcp_timestamps,並且新到來的連接的時間戳比老連接的時間戳大。


要同時開啟tcp_tw_reuse選項和tcp_timestamps 選項才可以開啟TIME_WAIT重用,還有一個條件是:重用TIME_WAIT的條件是收到最後一個包後超過1s。細心的同學可能發現TIME_WAIT重用對Server端來說並沒解決大量TIME_WAIT造成的資源消耗的問題,因為不管TIME_WAIT連接是否被重用,它依舊佔用著系統資源。即便如此,TIME_WAIT重用還是有些用處的,它解決了整機範圍拒絕接入的問題,雖然一般一個單獨的Client是不可能在MSL內用同一個端口連接同一個服務的,但是如果Client做了bind端口那就是同個端口了。時間戳重用TIME_WAIT連接的機制的前提是IP地址唯一性,得出新請求發起自同一臺機器,但是如果是NAT環境下就不能這樣保證了,於是在NAT環境下,TIME_WAIT重用還是有風險的。


有些同學可能會混淆tcp_tw_reuse和SO_REUSEADDR 選項,認為是相關的一個東西,其實他們是兩個完全不同的東西,可以說兩個半毛錢關係都沒。tcp_tw_reuse是內核選項,而SO_REUSEADDR用戶態的選項,使用SO_REUSEADDR是告訴內核,如果端口忙,但TCP狀態位於 TIME_WAIT ,可以重用端口。如果端口忙,而TCP狀態位於其他狀態,重用端口時依舊得到一個錯誤信息, 指明Address already in use”。如果你的服務程序停止後想立即重啟,而新套接字依舊使用同一端口,此時 SO_REUSEADDR 選項非常有用。但是,使用這個選項就會有(問題二、)中說的三點危險,雖然發生的概率不大。

清掉TIME_WAIT的奇技怪巧


可以用下面兩種方式控制服務器的TIME_WAIT數量:

  • 修改tcp_max_tw_buckets:
    tcp_max_tw_buckets 控制併發的TIME_WAIT的數量,默認值是180000。如果超過默認值,內核會把多的TIME_WAIT連接清掉,然後在日誌裡打一個警告。官網文檔說這個選項只是為了阻止一些簡單的DoS攻擊,平常不要人為的降低它;
  • 利用RST包從外部清掉TIME_WAIT鏈接:

    根據TCP規範,收到任何的發送到未偵聽端口、已經關閉的連接的數據包、連接處於任何非同步狀態(LISTEN, SYS-SENT, SYN-RECEIVED)並且收到的包的ACK在窗口外,或者安全層不匹配,都要回執以RST響應(而收到滑動窗口外的序列號的數據包,都要丟棄這個數據包,並回復一個ACK包),內核收到RST將會產生一個錯誤並終止該連接。我們可以利用RST包來終止掉處於TIME_WAIT狀態的連接,其實這就是所謂的RST攻擊了。為了描述方便:假設Client和Server有個連接Connect1,Server主動關閉連接並進入了TIME_WAIT狀態,我們來描述一下怎麼從外部使得Server的處於 TIME_WAIT狀態的連接Connect1提前終止掉。要實現這個RST攻擊,首先我們要知道Client在Connect1中的端口port1(一般這個端口是隨機的,比較難猜到,這也是RST攻擊較難的一個點),利用IP_TRANSPARENT這個socket選項,它可以bind不屬於本地的地址,因此可以從任意機器綁定Client地址以及端口port1,然後向Server發起一個連接,Server收到了窗口外的包於是響應一個ACK,這個ACK包會路由到Client處,這個時候99%的可能Client已經釋放連接connect1了,這個時候Client收到這個ACK包,會發送一個RST包,server收到RST包然後就釋放連接connect1提前終止TIME_WAIT狀態了。提前終止TIME_WAIT狀態是可能會帶來(問題二、)中說的三點危害,具體的危害情況可以看下RFC1337。RFC1337中建議,不要用RST過早的結束TIME_WAIT狀態。


至此,上面的疑症都解析完畢,然而細心的同學會有下面的疑問:

  • 1)TCP的可靠傳輸是確認號來實現的,那麼TCP的確認機制是怎樣的呢?是收到一個包就馬上確認,還是可以稍等一下在確認呢?
  • 2)假如發送一個包,一直都沒收到確認呢?什麼時候重傳呢?超時機制的怎樣的?
  • 3)TCP兩端Peer的處理能力不對等的時候,比如發送方處理能力很強,接收方處理能力很弱,這樣發送方是否能夠不管接收方死活狂發數據呢?如果不能,流量控制機制的如何的?
  • 4)TCP是端到端的協議,也就是TCP對端Peer只看到對方,看不到網絡上的其他點,那麼TCP的兩端怎麼對網絡情況做出反映呢?發生擁塞的時候,擁塞控制機制是如何的?


疑症7:TCP的延遲確認機制


按照TCP協議,確認機制是累積的,也就是確認號X的確認指示的是所有X之前但不包括X的數據已經收到了。確認號(ACK)本身就是不含數據的分段,因此大量的確認號消耗了大量的帶寬,雖然大多數情況下,ACK還是可以和數據一起捎帶傳輸的,但是如果沒有捎帶傳輸,那麼就只能單獨回來一個ACK,如果這樣的分段太多,網絡的利用率就會下降。為緩解這個問題,RFC建議了一種延遲的ACK,也就是說,ACK在收到數據後並不馬上回復,而是延遲一段可以接受的時間,延遲一段時間的目的是看能不能和接收方要發給發送方的數據一起回去,因為TCP協議頭中總是包含確認號的,如果能的話,就將數據一起捎帶回去,這樣網絡利用率就提高了。


延遲ACK就算沒有數據捎帶,那麼如果收到了按序的兩個包,那麼只要對第二包做確認即可,這樣也能省去一個ACK消耗。由於TCP協議不對ACK進行ACK的,RFC建議最多等待2個包的積累確認,這樣能夠及時通知對端Peer,我這邊的接收情況。Linux實現中,有延遲ACK和快速ACK,並根據當前的包的收發情況來在這兩種ACK中切換。一般情況下,ACK並不會對網絡性能有太大的影響,延遲ACK能減少發送的分段從而節省了帶寬,而快速ACK能及時通知發送方丟包,避免滑動窗口停等,提升吞吐率。
關於ACK分段,有個細節需要說明一下,ACK的確認號,是確認按序收到的最後一個字節序,對於亂序到來的TCP分段,接收端會回覆相同的ACK分段,只確認按序到達的最後一個TCP分段。TCP連接的延遲確認時間一般初始化為最小值40ms,隨後根據連接的重傳超時時間(RTO)、上次收到數據包與本次接收數據包的時間間隔等參數進行不斷調整。

疑症8:TCP的重傳機制以及重傳的超時計算


1TCP的重傳超時計算


TCP交互過程中,如果發送的包一直沒收到ACK確認,是要一直等下去嗎?顯然不能一直等(如果發送的包在路由過程中丟失了,對端都沒收到又如何給你發送確認呢?),這樣協議將不可用,既然不能一直等下去,那麼該等多久呢?等太長時間的話,數據包都丟了很久了才重發,沒有效率,性能差;等太短時間的話,可能ACK還在路上快到了,這時候卻重傳了,造成浪費,同時過多的重傳會造成網絡擁塞,進一步加劇數據的丟失。也是,我們不能去猜測一個重傳超時時間,應該是通過一個算法去計算,並且這個超時時間應該是隨著網絡的狀況在變化的。為了使我們的重傳機制更高效,如果我們能夠比較準確知道在當前網絡狀況下,一個數據包從發出去到回來的時間RTT——Round Trip Time,那麼根據這個RTT我們就可以方便設置TimeOut——RTO(Retransmission TimeOut)了。


為了計算這個RTO,RFC793中定義了一個經典算法,算法如下:

  • 1)首先採樣計算RTT值;
  • 2)然後計算平滑的RTT,稱為Smoothed Round Trip Time (SRTT),SRTT = ( ALPHA SRTT ) + ((1-ALPHA) RTT);
  • 3)RTO = min[UBOUND,max[LBOUND,(BETA*SRTT)]]。


其中:UBOUND是RTO值的上限;例如:可以定義為1分鐘,LBOUND是RTO值的下限,例如,可以定義為1秒;ALPHA is a smoothing factor (e.g., .8 to .9), and BETA is a delay variance factor (e.g., 1.3 to 2.0)。
然而這個算法有個缺點就是:在算RTT樣本的時候,是用第一次發數據的時間和ack回來的時間做RTT樣本值,還是用重傳的時間和ACK回來的時間做RTT樣本值?不管是怎麼選擇,總會造成會要麼把RTT算過長了,要麼把RTT算過短了。如下圖:(a)就計算過長了,而(b)就是計算過短了。

淺析TCP協議中的疑難雜症(下篇)


針對上面經典算法的缺陷,於是提出Karn / Partridge Algorithm對經典算法進行了改進(算法大特點是——忽略重傳,不把重傳的RTT做採樣),但是這個算法有問題:如果在某一時間,網絡閃動,突然變慢了,產生了比較大的延時,這個延時導致要重轉所有的包(因為之前的RTO很小)。於是,因為重轉的不算,所以,RTO就不會被更新,這是一個災難。
於是,為解決上面兩個算法的問題,又有人推出來了一個新的算法,這個算法叫Jacobson / Karels Algorithm(參看RFC6289),這個算法的核心是:除了考慮每兩次測量值的偏差之外,其變化率也應該考慮在內,如果變化率過大,則通過以變化率為自變量的函數為主計算RTT(如果陡然增大,則取值為比較大的正數,如果陡然減小,則取值為比較小的負數,然後和平均值加權求和),反之如果變化率很小,則取測量平均值。
公式如下:(其中的DevRTT是Deviation RTT的意思)

SRTT = SRTT + α (RTT – SRTT) —— 計算平滑RTT;
DevRTT = (1-β)DevRTT + β(|RTT-SRTT|) ——計算平滑RTT和真實的差距(加權移動平均);
RTO= μ SRTT + ∂ DevRTT —— 神一樣的公式。
(其中:在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4 ——這就是算法中的“調得一手好參數”,nobody knows why, it just works…)


最後的這個算法在被用在今天的TCP協議中並工作非常好。
知道超時怎麼計算後,很自然就想到定時器的設計問題。一個簡單直觀的方案就是為TCP中的每一個數據包維護一個定時器,在這個定時器到期前沒收到確認,則進行重傳。這種設計理論上是很合理的,但是實現上,這種方案將會有非常多的定時器,會帶來巨大內存開銷和調度開銷。既然不能每個包一個定時器,那麼多少個包一個定時器才好呢,這個似乎比較難確定。可以換個思路,不要以包量來確定定時器,以連接來確定定時器會不會比較合理呢?
目前,採取每一個TCP連接單一超時定時器的設計則成了一個默認的選擇,並且RFC2988給出了每連接單一定時器的設計建議算法規則:

  • 1)每一次一個包含數據的包被髮送(包括重發),如果還沒開啟重傳定時器,則開啟它,使得它在RTO秒之後超時(按照當前的RTO值);
  • 2)當接收到一個ACK確認一個新的數據, 如果所有的發出數據都被確認了,關閉重傳定時器;
  • 3)當接收到一個ACK確認一個新的數據,還有數據在傳輸,也就是還有沒被確認的數據,重新啟動重傳定時器,使得它在RTO秒之後超時(按照當前的RTO值);
  • 4)當重傳定時器超時後,依次做下列3件事情:
    - 4.1)重傳最早的尚未被TCP接收方ACK的數據包
    - 4.2)重新設置RTO 為 RTO * 2(“還原定時器”),但是新RTO不應該超過RTO的上限(RTO有個上限值,這個上限值最少為60s)
    - 4.3)重啟重傳定時器。


上面的建議算法體現了一個原則:沒被確認的包必須可以超時,並且超時的時間不能太長,同時也不要過早重傳。規則[1][3][4.3]共同說明了只要還有數據包沒被確認,那麼定時器一定會是開啟著的(這樣滿足 沒被確認的包必須可以超時的原則)。規則[4.2]說明定時器的超時值是有上限的(滿足 超時的時間不能太長 )。規則[3]說明,在一個ACK到來後重置定時器可以保護後發的數據不被過早重傳;因為一個ACK到來了,說明後續的ACK很可能會依次到來,也就是說丟失的可能性並不大。規則[4.2]也是在一定程度上避免過早重傳,因為,在出現定時器超時後,有可能是網絡出現擁塞了,這個時候應該延長定時器,避免出現大量的重傳進一步加劇網絡的擁塞。


2TCP的重傳機制


通過上面我們可以知道,TCP的重傳是由超時觸發的,這會引發一個重傳選擇問題,假設TCP發送端連續發了1、2、3、4、5、6、7、8、9、10共10包,其中4、6、8這3個包全丟失了,由於TCP的ACK是確認最後連續收到序號。
這樣發送端只能收到3號包的ACK,這樣在TIME_OUT的時候,發送端就面臨下面兩個重傳選擇:

  • 1)僅重傳4號包;
  • 2)重傳3號後面所有的包,也就是重傳4~10號包。


對於,上面兩個選擇的優缺點都比較明顯:

  • 方案[1]-優點:按需重傳,能夠最大程度節省帶寬。缺點:重傳會比較慢,因為重傳4號包後,需要等下一個超時才會重傳6號包;
  • 方案[2]-優點:重傳較快,數據能夠較快交付給接收端。缺點:重傳了很多不必要重傳的包,浪費帶寬,在出現丟包的時候,一般是網絡擁塞,大量的重傳又可能進一步加劇擁塞。


上面的問題是由於單純以時間驅動來進行重傳的,都必須等待一個超時時間,不能快速對當前網絡狀況做出響應,如果加入以數據驅動呢?TCP引入了一種叫Fast Retransmit(快速重傳 )的算法,就是在連續收到3次相同確認號的ACK,那麼就進行重傳。這個算法基於這麼一個假設,連續收到3個相同的ACK,那麼說明當前的網絡狀況變好了,可以重傳丟失的包了。
快速重傳解決了timeout的問題,但是沒解決重傳一個還是重傳多個的問題。出現難以決定是否重傳多個包問題的根源在於,發送端不知道那些非連續序號的包已經到達接收端了,但是接收端是知道的,如果接收端告訴一下發送端不就可以解決這個問題嗎?
於是,RFC2018提出了Selective Acknowledgment (SACK,選擇確認)機制,SACK是TCP的擴展選項,包括:

  • 1)SACK允許選項(Kind=4,Length=2,選項只允許在有SYN標誌的TCP包中);
  • 2)SACK信息選項(Kind=5,Length)。


一個SACK的例子如下圖,紅框說明:接收端收到了0-5500,8000-8500,7000-7500,6000-6500的數據了,這樣發送端就可以選擇重傳丟失的5500-6000,6500-7000,7500-8000的包:

淺析TCP協議中的疑難雜症(下篇)


SACK依靠接收端的接收情況反饋,解決了重傳風暴問題,這樣夠了嗎?接收端能不能反饋更多的信息呢?顯然是可以的,於是,RFC2883對對SACK進行了擴展,提出了D-SACK,也就是利用第一塊SACK數據中描述重複接收的不連續數據塊的序列號參數,其他SACK數據則描述其他正常接收到的不連續數據。這樣發送方利用第一塊SACK,可以發現數據段被網絡複製、錯誤重傳、ACK丟失引起的重傳、重傳超時等異常的網絡狀況,使得發送端能更好調整自己的重傳策略。
D-SACK,有幾個優點:

  • 1)發送端可以判斷出,是發包丟失了,還是接收端的ACK丟失了。(發送方,重傳了一個包,發現並沒有D-SACK那個包,那麼就是發送的數據包丟了;否則就是接收端的ACK丟了,或者是發送的包延遲到達了);
  • 2)發送端可以判斷自己的RTO是不是有點小了,導致過早重傳(如果收到比較多的D-SACK就該懷疑是RTO小了);
  • 3)發送端可以判斷自己的數據包是不是被複制了。(如果明明沒有重傳該數據包,但是收到該數據包的D-SACK);
  • 4)發送端可以判斷目前網絡上是不是出現了有些包被delay了,也就是出現先發的包卻後到了。


疑症9:TCP的流量控制


我們知道TCP的窗口(window)是一個16bit位字段,它代表的是窗口的字節容量,也就是TCP的標準窗口最大為2^16-1=65535個字節。另外在TCP的選項字段中還包含了一個TCP窗口擴大因子,option-kind為3,option-length為3個字節,option-data取值範圍0-14。窗口擴大因子用來擴大TCP窗口,可把原來16bit的窗口,擴大為31bit。這個窗口是接收端告訴發送端自己還有多少緩衝區可以接收數據。於是發送端就可以根據這個接收端的處理能力來發送數據,而不會導致接收端處理不過來。也就是,發送端是根據接收端通知的窗口大小來調整自己的發送速率的,以達到端到端的流量控制。儘管流量控制看起來簡單明瞭,就是發送端根據接收端的限制來控制自己的發送就好了。
但是細心的同學還是會有些疑問的:

  • 1)發送端是怎麼做到比較方便知道自己哪些包可以發,哪些包不能發呢?
  • 2)如果接收端通知一個零窗口給發送端,這個時候發送端還能不能發送數據呢?如果不發數據,那一直等接收端口通知一個非0窗口嗎,如果接收端一直不通知呢?
  • 3)如果接收端處理能力很慢,這樣接收端的窗口很快被填滿,然後接收處理完幾個字節,騰出幾個字節的窗口後,通知發送端,這個時候發送端馬上就發送幾個字節給接收端嗎?發送的話會不會太浪費了,就像一艘萬噸油輪只裝上幾斤的油就開去目的地一樣。對於發送端產生數據的能力很弱也一樣,如果發送端慢吞吞產生幾個字節的數據要發送,這個時候該不該立即發送呢?還是累積多點在發送?


1疑問1)的解決


發送方要知道那些可以發,哪些不可以發,一個簡明的方案就是按照接收方的窗口通告,發送方維護一個一樣大小的發送窗口就可以了,在窗口內的可以發,窗口外的不可以發,窗口在發送序列上不斷後移,這就是TCP中的滑動窗口。

如下圖所示,對於TCP發送端其發送緩存內的數據都可以分為4類:

淺析TCP協議中的疑難雜症(下篇)


  • [1]-已經發送並得到接收端ACK的;
  • [2]-已經發送但還未收到接收端ACK的;
  • [3]-未發送但允許發送的(接收方還有空間);
  • [4]-未發送且不允許發送(接收方沒空間了)。


其中,[2]和[3]兩部分合起來稱之為發送窗口。
下面兩圖演示的窗口的滑動情況,收到36的ACK後,窗口向後滑動5個byte:

淺析TCP協議中的疑難雜症(下篇)


淺析TCP協議中的疑難雜症(下篇)


2疑問2)的解決


由問題1)我們知道,發送端的發送窗口是由接收端控制的。下圖,展示了一個發送端是怎麼受接收端控制的:

淺析TCP協議中的疑難雜症(下篇)


由上圖我們知道,當接收端通知一個zero窗口的時候,發送端的發送窗口也變成了0,也就是發送端不能發數了。如果發送端一直等待,直到接收端通知一個非零窗口在發數據的話,這似乎太受限於接收端,如果接收端一直不通知新的窗口呢?顯然發送端不能幹等,起碼有一個主動探測的機制。為解決0窗口的問題,TCP使用了Zero Window Probe技術,縮寫為ZWP。發送端在窗口變成0後,會發ZWP的包給接收方,來探測目前接收端的窗口大小,一般這個值會設置成3次,每次大約30-60秒(不同的實現可能會不一樣)。
如果3次過後還是0的話,有的TCP實現就會發RST掉這個連接。正如有人的地方就會有商機,那麼有等待的地方就很有可能出現DDoS攻擊點。攻擊者可以在和Server建立好連接後,就向Server通告一個0窗口,然後Server端就只能等待進行ZWP,於是攻擊者會併發大量的這樣的請求,把Server端的資源耗盡。

3疑問點3)的解決


疑點3)本質就是一個避免發送大量小包的問題。造成這個問題原因有二:1)接收端一直在通知一個小的窗口; 2)發送端本身問題,一直在發送小包。這個問題,TCP中有個術語叫Silly Window Syndrome(糊塗窗口綜合症)。解決這個問題的思路有兩,1)接收端不通知小窗口,2)發送端積累一下數據在發送。


思路1)是在接收端解決這個問題,David D Clark’s 方案,如果收到的數據導致window size小於某個值,就ACK一個0窗口,這就阻止發送端在發數據過來。等到接收端處理了一些數據後windows size 大於等於了MSS,或者buffer有一半為空,就可以通告一個非0窗口。思路2)是在發送端解決這個問題,有個著名的Nagle’s algorithm。
Nagle 算法的規則:

  • [1]如果包長度達到 MSS ,則允許發送;
  • [2]如果該包含有 FIN ,則允許發送;
  • [3]設置了 TCP_NODELAY 選項,則允許發送;
  • [4]設置 TCP_CORK 選項時,若所有發出去的小數據包(包長度小於 MSS )均被確認,則允許發送;
  • [5]上述條件都未滿足,但發生了超時(一般為 200ms ),則立即發送。


規則[4]指出TCP連接上最多隻能有一個未被確認的小數據包。從規則[4]可以看出Nagle算法並不禁止發送小的數據包(超時時間內),而是避免發送大量小的數據包。由於Nagle算法是依賴ACK的,如果ACK很快的話,也會出現一直髮小包的情況,造成網絡利用率低。TCP_CORK選項則是禁止發送小的數據包(超時時間內),設置該選項後,TCP會盡力把小數據包拼接成一個大的數據包(一個 MTU)再發送出去,當然也不會一直等,發生了超時(一般為 200ms ),也立即發送。Nagle 算法和CP_CORK 選項提高了網絡的利用率,但是增加是延時。從規則[3]可以看出,設置TCP_NODELAY 選項,就是完全禁用Nagle 算法了。


這裡要說一個小插曲,Nagle算法和延遲確認(Delayed Acknoledgement)一起,當出現( write-write-read)的時候會引發一個40ms的延時問題,這個問題在HTTP svr中體現的比較明顯。
場景如下:

  • 客戶端在請求下載HTTP svr中的一個小文件,一般情況下,HTTP svr都是先發送HTTP響應頭部,然後在發送HTTP響應BODY(特別是比較多的實現在發送文件的實施採用的是sendfile系統調用,這就出現write-write-read模式了)。當發送頭部的時候,由於頭部較小,於是形成一個小的TCP包發送到客戶端,這個時候開始發送body,由於body也較小,這樣還是形成一個小的TCP數據包,根據Nagle算法,HTTP svr已經發送一個小的數據包了,在收到第一個小包的ACK後或等待200ms超時後才能在發小包,HTTP svr不能發送這個body小TCP包;
  • 客戶端收到http響應頭後,由於這是一個小的TCP包,於是客戶端開啟延遲確認,客戶端在等待Svr的第二個包來在一起確認或等待一個超時(一般是40ms)在發送ACK包;這樣就出現了你等我、然而我也在等你的死鎖狀態,於是出現最多的情況是客戶端等待一個40ms的超時,然後發送ACK給HTTP svr,HTTP svr收到ACK包後在發送body部分。大家在測HTTP svr的時候就要留意這個問題了。


疑症10:TCP的擁塞控制


談到擁塞控制,就要先談談擁塞的因素和本質。本質上,網絡上擁塞的原因就是大家都想獨享整個網絡資源,對於TCP,端到端的流量控制必然會導致網絡擁堵。這是因為TCP只看到對端的接收空間的大小,而無法知道鏈路上的容量,只要雙方的處理能力很強,那麼就可以以很大的速率發包,於是鏈路很快出現擁堵,進而引起大量的丟包,丟包又引發發送端的重傳風暴,進一步加劇鏈路的擁塞。另外一個擁塞的因素是鏈路上的轉發節點,例如路由器,再好的路由器只要接入網絡,總是會拉低網絡的總帶寬,如果在路由器節點上出現處理瓶頸,那麼就很容易出現擁塞。由於TCP看不到網絡的狀況,那麼擁塞控制是必須的並且需要採用試探性的方式來控制擁塞,於是擁塞控制要完成兩個任務:[1]公平性;[2]擁塞過後的恢復。
TCP發展到現在,擁塞控制方面的算法很多,其中Reno是目前應用最廣泛且較為成熟的算法,下面著重介紹一下Reno算法(RFC5681)。介紹該算法前,首先介紹一個概念duplicate acknowledgment(冗餘ACK、重複ACK)。
一般情況下一個ACK被稱為冗餘ACK,要同時滿足下面幾個條件(對於SACK,那麼根據SACK的一些信息來進一步判斷):


  • [1] 接收ACK的那端已經發出了一些還沒被ACK的數據包
  • [2] 該ACK沒有捎帶data
  • [3] 該ACK的SYN和FIN位都是off的,也就是既不是SYN包的ACK也不是FIN包的ACK。
  • [4] 該ACK的確認號等於接收ACK那端已經收到的ACK的最大確認號
  • [5] 該ACK通知的窗口等接收該ACK的那端上一個收到的ACK的窗口。


Reno算法包含4個部分:

  • [1]慢熱啟動算法 – Slow Start;
  • [2]擁塞避免算法 – Congestion Avoidance;
  • [3]快速重傳 - Fast Retransimit;
  • [4]快速恢復算法 – Fast Recovery。


TCP的擁塞控制主要原理依賴於一個擁塞窗口(cwnd)來控制,根據前面的討論,我們知道有一個接收端通告的接收窗口(rwnd)用於流量控制;加上擁塞控制後,發送端真正的發送窗口=min(rwnd, cwnd)。關於cwnd的單位,在TCP中是以字節來做單位的,我們假設TCP每次傳輸都是按照MSS大小來發送數據,因此你可以認為cwnd按照數據包個數來做單位也可以理解,下面如果沒有特別說明是字節,那麼cwnd增加1也就是相當於字節數增加1個MSS大小。


1慢熱啟動算法 – Slow Start


慢啟動體現了一個試探的過程,剛接入網絡的時候先發包慢點,探測一下網絡情況,然後在慢慢提速。不要一上來就拼命發包,這樣很容易造成鏈路的擁堵,出現擁堵了在想到要降速來緩解擁堵這就有點成本高了,畢竟無數的先例告誡我們先汙染後治理的成本是很高的。
慢啟動的算法如下(cwnd全稱Congestion Window):

  • 1)連接建好的開始先初始化cwnd = N,表明可以傳N個MSS大小的數據。
  • 2)每當收到一個ACK,++cwnd; 呈線性上升
  • 3)每當過了一個RTT,cwnd = cwnd*2; 呈指數讓升
  • 4)還有一個慢啟動門限ssthresh(slow start threshold),是一個上限,當cwnd >= ssthresh時,就會進入"擁塞避免算法 - Congestion Avoidance"。


根據RFC5681,如果MSS > 2190 bytes,則N = 2;如果MSS < 1095 bytes,則N = 4;如果2190 bytes >= MSS >= 1095 bytes,則N = 3;一篇Google的論文《An Argument for Increasing TCP’s Initial Congestion Window》建議把cwnd 初始化成了 10個MSS。Linux 3.0後採用了這篇論文的建議。


2擁塞避免算法 – Congestion Avoidance


慢啟動的時候說過,cwnd是指數快速增長的,但是增長是有個門限ssthresh(一般來說大多數的實現ssthresh的值是65535字節)的,到達門限後進入擁塞避免階段。
在進入擁塞避免階段後,cwnd值變化算法如下:

  • 1)每收到一個ACK,調整cwnd 為 (cwnd + 1/cwnd) * MSS個字節;
  • 2)每經過一個RTT的時長,cwnd增加1個MSS大小。


TCP是看不到網絡的整體狀況的,那麼TCP認為網絡擁塞的主要依據是它重傳了報文段。
前面我們說過TCP的重傳分兩種情況:

  • 1)出現RTO超時,重傳數據包。這種情況下,TCP就認為出現擁塞的可能性就很大,於是它反應非常'強烈':
    - [1] 調整門限ssthresh的值為當前cwnd值的1/2;
    - [2] reset自己的cwnd值為1;

    - [3] 然後重新進入慢啟動過程。
  • 2)在RTO超時前,收到3個duplicate ACK進行重傳數據包。這種情況下,收到3個冗餘ACK後說明確實有中間的分段丟失,然而後面的分段確實到達了接收端,因為這樣才會發送冗餘ACK,這一般是路由器故障或者輕度擁塞或者其它不太嚴重的原因引起的,因此此時擁塞窗口縮小的幅度就不能太大,此時進入快速重傳。


3快速重傳 - Fast Retransimit


快速重傳做的事情有:

  • 1) 調整門限ssthresh的值為當前cwnd值的1/2;
  • 2) 將cwnd值設置為新的ssthresh的值;
  • 3) 重新進入擁塞避免階段。


在快速重傳的時候,一般網絡只是輕微擁堵,在進入擁塞避免後,cwnd恢復的比較慢。針對這個,“快速恢復”算法被添加進來,當收到3個冗餘ACK時,TCP最後的[3]步驟進入的不是擁塞避免階段,而是快速恢復階段。


4快速恢復算法 – Fast Recovery


快速恢復的思想是“數據包守恆”原則,即帶寬不變的情況下,在網絡同一時刻能容納數據包數量是恆定的。當“老”數據包離開了網絡後,就能向網絡中發送一個“新”的數據包。既然已經收到了3個冗餘ACK,說明有三個數據分段已經到達了接收端,既然三個分段已經離開了網絡,那麼就是說可以在發送3個分段了。於是只要發送方收到一個冗餘的ACK,於是cwnd加1個MSS。
快速恢復步驟如下(在進入快速恢復前,cwnd 和 sshthresh已被更新為:sshthresh = cwnd /2,cwnd = sshthresh):

  • 1)把cwnd設置為ssthresh的值加3,重傳Duplicated ACKs指定的數據包
  • 2)如果再收到 duplicated Acks,那麼cwnd = cwnd +1
  • 3)如果收到新的ACK,而非duplicated Ack,那麼將cwnd重新設置為【3】中1)的sshthresh的值。然後進入擁塞避免狀態。


細心的同學可能會發現快速恢復有個比較明顯的缺陷就是:它依賴於3個冗餘ACK,並假定很多情況下,3個冗餘的ACK只代表丟失一個包。但是3個冗餘ACK也很有可能是丟失了很多個包,快速恢復只是重傳了一個包,然後其他丟失的包就只能等待到RTO超時了。超時會導致ssthresh減半,並且退出了Fast Recovery階段,多個超時會導致TCP傳輸速率呈級數下降。出現這個問題的主要原因是過早退出了Fast Recovery階段。為解決這個問題,提出了New Reno算法,該算法是在沒有SACK的支持下改進Fast Recovery算法(SACK改變TCP的確認機制,把亂序等信息會全部告訴對方,SACK本身攜帶的信息就可以使得發送方有足夠的信息來知道需要重傳哪些包,而不需要重傳哪些包)。


具體改進如下:

  • 1)發送端收到3個冗餘ACK後,重傳冗餘ACK指示可能丟失的那個包segment1,如果segment1的ACK通告接收端已經收到發送端的全部已經發出的數據的話,那麼就是隻丟失一個包,如果沒有,那麼就是有多個包丟失了;
  • 2)發送端根據segment1的ACK判斷出有多個包丟失,那麼發送端繼續重傳窗口內未被ACK的第一個包,直到sliding window內發出去的包全被ACK了,才真正退出Fast Recovery階段。


我們可以看到,擁塞控制在擁塞避免階段,cwnd是加性增加的,在判斷出現擁塞的時候採取的是指數遞減。為什麼要這樣做呢?這是出於公平性的原則,擁塞窗口的增加受惠的只是自己,而擁塞窗口減少受益的是大家。這種指數遞減的方式實現了公平性,一旦出現丟包,那麼立即減半退避,可以給其他新建的連接騰出足夠的帶寬空間,從而保證整個的公平性。
至此,TCP的疑難雜症基本介紹完畢了,總的來說TCP是一個有連接的、可靠的、帶流量控制和擁塞控制的端到端的協議。TCP的發送端能發多少數據,由發送端的發送窗口決定(當然發送窗口又被接收端的接收窗口、發送端的擁塞窗口限制)的,那麼一個TCP連接的傳輸穩定狀態應該體現在發送端的發送窗口的穩定狀態上,這樣的話,TCP的發送窗口有哪些穩定狀態呢?


TCP的發送窗口穩定狀態主要有上面三種穩定狀態:

  • 【1】接收端擁有大窗口的經典鋸齒狀:
    大多數情況下都是處於這樣的穩定狀態,這是因為,一般情況下機器的處理速度就是比較快,這樣TCP的接收端都是擁有較大的窗口,這時發送端的發送窗口就完全由其擁塞窗口cwnd決定了;網絡上擁有成千上萬的TCP連接,它們在相互爭用網絡帶寬,TCP的流量控制使得它想要獨享整個網絡,而擁塞控制又限制其必要時做出犧牲來體現公平性。於是在傳輸穩定的時候TCP發送端呈現出下面過程的反覆:
    - [1]用慢啟動或者擁塞避免方式不斷增加其擁塞窗口,直到丟包的發生;
    - [2]然後將發送窗口將下降到1或者下降一半,進入慢啟動或者擁塞避免階段(要看是由於超時丟包還是由於冗餘ACK丟包);
    過程如下圖:
  • 【2】接收端擁有小窗口的直線狀態:這種情況下是接收端非常慢速,接收窗口一直很小,這樣發送窗口就完全有接收窗口決定了。由於發送窗口小,發送數據少,網絡就不會出現擁塞了,於是發送窗口就一直穩定的等於那個較小的接收窗口,呈直線狀態。
  • 【3】兩個直連網絡端點間的滿載狀態下的直線狀態:這種情況下,Peer兩端直連,並且只有位於一個TCP連接,那麼這個連接將獨享網絡帶寬,這裡不存在擁塞問題,在他們處理能力足夠的情況下,TCP的流量控制使得他們能夠跑慢整個網絡帶寬。


通過上面我們知道,在TCP傳輸穩定的時候,各個TCP連接會均分網絡帶寬的。相信大家學生時代經常會發生這樣的場景,自己在看視頻的時候突然出現視頻卡頓,於是就大叫起來,哪個開了迅雷,趕緊給我停了。其實簡單的下載加速就是開啟多個TCP連接來分段下載就達到加速的效果,假設宿舍的帶寬是1000K/s,一開始兩個在看視頻,每人平均網速是500k/s,這速度看起視頻來那叫一個順溜。突然其中一個同學打打開迅雷開著99個TCP連接在下載愛情動作片,這個時候平均下來你能分到的帶寬就剩下10k/s,這網速下你的視頻還不卡成幻燈片。在通信鏈路帶寬固定(假設為W),多人公用一個網絡帶寬的情況下,利用TCP協議的擁塞控制的公平性,多開幾個TCP連接就能多分到一些帶寬(當然要忽略有些用UDP協議帶來的影響),然而不管怎麼最多也就能把整個帶寬搶到,於是在佔滿整個帶寬的情況下,下載一個大小為FS的文件,那麼最快需要的時間是FS/W,難道就沒辦法加速了嗎?


答案是有的,這樣因為網絡是網狀的,一個節點是要和很多幾點互聯的,這就存在多個帶寬為W的通信鏈路,如果我們能夠將要下載的文件,一半從A通信鏈路下載,另外一半從B通信鏈路下載,這樣整個下載時間就減半了為FS/(2W),這就是p2p加速。相信大家學生時代在下載愛情動作片的時候也遇到過這種情況,明明外網速度沒這麼快的,自己下載的愛情動作片的速度卻達到幾M/s,那是因為,你的左後或右後的宿友在幫你加速中。我們都知道P2P模式下載會快,並且越多人下載就越快,那麼問題來了,P2P下載加速理論上的加速比是多少呢?

附加題1:P2P理論上的加速比


傳統的C/S模式傳輸文件,在跑滿Client帶寬的情況下傳輸一個文件需要耗時FS/BW,如果有n個客戶端需要下載文件,那麼總耗時是n(FS/BW),當然啦,這並不一定是串行傳輸,可以並行來傳輸的,這樣總耗時也就是FS/BW了,但是這需要服務器的帶寬是n個client帶寬的總和nBW。C/S模式一個明顯的缺點是服務要傳輸一個文件n次,這樣對服務器的性能和帶寬帶來比較大的壓力,我可以換下思路,服務器將文件傳給其中一個Client後,讓這些互聯的Client自己來交互那個文件,那服務器的壓力就減少很多了。這就是P2P網絡的好處,P2P利用各個節點間的互聯,提倡“人人為我,我為人人”。


知道P2P傳輸的好處後,我們來談下理論上的最大加速比,為了簡化討論,一個簡單的網絡拓撲圖如下,有4個相互互聯的節點,並且每個節點間的網絡帶寬是BW,傳輸一個大小為FS的文件最快的時間是多少呢?假設節點N1有個大小為FS的文件需要傳輸給N2,N3,N4節點,一種簡單的方式就是:節點N1同時將文件傳輸給節點N2,N3,N4耗時FS/BW,這樣大家都擁有文件FS了。大家可以看出,整個過程只有節點1在發送文件,其他節點都是在接收,完全違反了P2P的“人人為我,我為人人”的宗旨。那怎麼才能讓大家都做出貢獻了呢?
解決方案是切割文件:

  • [1]首先:節點N1 文件分成3個片段FS2,FS3,FS4 ,接著將FS2發送給N2,FS3發送給N3,FS4發送給N4,耗時FS/(3BW);
  • [2]然後:N2,N3,N4執行“人人為我,我為人人”的精神,將自己擁有的F2,F3,F4分別發給沒有的其他的節點,這樣耗時FS/(3BW)完成交換。


於是總耗時為2FS/(3BW)完成了文件FS的傳輸,可以看出耗時減少為原來的2/3了,如果有n個節點,那麼時間就是原來的2/(n-1),也就是加速比是2/(n-1),這就是加速的理論上限了嗎?還沒發揮最多能量的,相信大家已經看到分割文件的好處了,上面的文件分割粒度還是有點大,以至於,在第二階段[2]傳輸過程中,節點N1無所事事。為了最大化發揮大家的作用,我們需要將FS2,FS3,FS4在進行分割,假設將它們都均分為K等份,這樣就有FS21,FS22…FS2K、FS31,FS32…FS3K、FS41,FS42…FS4K,一共3K個分段。


於是下面就開始進行加速分發:

  • [1]節點N1將分段FS21,FS31,FS41分別發送給N2,N3,N4節點。耗時,FS/(3KBW)
  • [2]節點N1將分段FS22,FS32,FS42分別發送給N2,N3,N4節點,同時節點N2,N3,N4將階段[1]收到的分段相互發給沒有的節點。耗時,FS/(3KBW)
  • 。。。。。。
  • [K]節點N1將分段FS2K,FS3K,FS4K分別發送給N2,N3,N4節點,同時節點N2,N3,N4將階段[K-1]收到的分段相互發給沒有的節點。耗時,FS/(3KBW)
  • [K+1]節點N2,N3,N4將階段[K]收到的分段相互發給沒有的節點。耗時,FS/(3KBW)。


於是總的耗時為(K+1) (FS/(3KBW)) = FS/(3BW) + FS/(3KBW),當K趨於無窮大的時候,文件進行無限細分的時候,耗時變成了FS/(3*BW),也就是當節點是n+1的時候,加速比是n。這就是理論上的最大加速比了,最大加速比是P2P網絡節點個數減1。

淺析TCP協議中的疑難雜症(下篇)


附加題2:系統調用listen() 的backlog參數指的是什麼


要說明backlog參數的含義,首先需要說一下Linux的協議棧維護的TCP連接的兩個連接隊列:

  • [1]SYN半連接隊列;
  • [2]accept連接隊列。


[1] SYN半連接隊列:
Server端收到Client的SYN包並回復SYN,ACK包後,該連接的信息就會被移到一個隊列,這個隊列就是SYN半連接隊列(此時TCP連接處於 非同步狀態 )。
[2] accept連接隊列:
Server端收到SYN,ACK包的ACK包後,就會將連接信息從[1]中的隊列移到另外一個隊列,這個隊列就是accept連接隊列(這個時候TCP連接已經建立,三次握手完成了)。
用戶進程調用accept()系統調用後,該連接信息就會從[2]中的隊列中移走。


相信不少同學就backlog的具體含義進行爭論過,有些認為backlog指的是[1]和[2]兩個隊列的和。而有些則認為是backlog指的是[2]的大小。其實,這兩個說法都對,在linux kernel 2.2之前backlog指的是[1]和[2]兩個隊列的和。而2.2以後,就指的是[2]的大小,那麼在kernel 2.2以後,[1]的大小怎麼確定的呢?兩個隊列的作用分別是什麼呢?
SYN半連接隊列的作用:
對於SYN半連接隊列的大小是由(/proc/sys/net/ipv4/tcp_max_syn_backlog)這個內核參數控制的,有些內核似乎也受listen的backlog參數影響,取得是兩個值的最小值。當這個隊列滿了,Server會丟棄新來的SYN包,而Client端在多次重發SYN包得不到響應而返回(connection time out)錯誤。但是,當Server端開啟了syncookies,那麼SYN半連接隊列就沒有邏輯上的最大值了,並且/proc/sys/net/ipv4/tcp_max_syn_backlog設置的值也會被忽略。
accept連接隊列:
accept連接隊列的大小是由backlog參數和(/proc/sys/net/core/somaxconn)內核參數共同決定,取值為兩個中的最小值。當accept連接隊列滿了,協議棧的行為根據(/proc/sys/net/ipv4/tcp_abort_on_overflow)內核參數而定。 如果tcp_abort_on_overflow=1,server在收到SYN_ACK的ACK包後,協議棧會丟棄該連接並回復RST包給對端,這個是Client會出現(connection reset by peer)錯誤。如果tcp_abort_on_overflow=0,server在收到SYN_ACK的ACK包後,直接丟棄該ACK包。這個時候Client認為連接已經建立了,一直在等Server的數據,直到超時出現read timeout錯誤。

另外還有一些關於c++ Linux後臺服務器開發的一些知識點分享:Linux,Nginx,MySQL,Redis,P2P,K8S,Docker,TCP/IP,協程,DPDK,webrtc,音視頻等等視頻。

喜歡的朋友可以後臺私信【1】獲取學習視頻


淺析TCP協議中的疑難雜症(下篇)


分享到:


相關文章: