TCP系列45—擁塞控制—8、SACK關閉的擁塞撤銷與虛假快速重傳


一、概述

這篇文章介紹一下TCP從Recovery狀態恢復到Open狀態的時候cwnd的更新。我們在tcp重傳部分的文章中曾經介紹過虛假重傳的概念,Linux在探測到虛假重傳的時候就會執行擁塞撤銷操作。所謂的擁塞撤銷是指撤銷虛假的快速重傳或者RTO超時重傳對擁塞窗口的影響。有多種方法可能會觸發擁塞撤銷如前面介紹的DSACK和FRTO以及后面要介紹的Eifel算法以及本文介紹的SACK關閉場景下的擁塞撤銷,本文先介紹一種SACK關閉場景下的擁塞撤銷。首先在介紹幾個新的linux狀態變量

undo_marker:這個是擁塞撤銷標記,進入Recovery狀態的時候進行標記,設置為SND.UNA,擁塞撤銷后設置為0取消標記,以防止多次進行擁塞撤銷。

retrans_stamp:第一次重傳的時間戳,快速恢復的時候可以用來標記是否可以進行擁塞撤銷,若值為0則可以進行擁塞撤銷。Open狀態下每次收到ack都會將這個變量置為0。

prior_ssthresh:從名字就可以看到prior_ssthresh這個狀態變量用來保存ssthresh的先前值,當探測到虛假重傳的時候,就可以用prior_ssthresh來更新ssthresh從而達到擁塞撤銷的效果。prior_ssthresh的值一般在進入Recovery狀態或者Loss狀態時候更新,更新的時候如果當前TCP擁塞狀態處於CWR或者Recovery狀態那么設置prior_ssthresh=ssthresh,否則prior_ssthresh=max(ssthresh,3/4*cwnd)

當Recovery狀態下的TCP收到ack number大於等於high_seq的ACK報文的時候

  1. 如果retrans_stamp標記可以進行擁塞撤銷或者Eifel探測算法探測到虛假重傳,並且undo_marker標記有效,則嘗試執行擁塞撤銷,更新cwnd=max(cwnd,2*ssthresh),ssthresh=max(ssthresh,prior_ssthresh),並取消undo_marker的標記,即設置undo_marker=0,防止后續重復進行擁塞撤銷,並繼續執行下面的第2步。本篇文章只介紹retrans_stamp觸發的擁塞撤銷,Eifel探測后面進行介紹。

  2. 如果收到的Ack=high_seq,而且當前SACK處於關閉狀態,更新cwnd=min(cwnd,in_flight+dupthresh)。同時判斷當前是否有未被ack number確認的重傳,如果沒有則設置retrans_stamp=0,表示可以進行擁塞撤銷。不在執行下面的第2步狀態切換,TCP繼續停留在Recovery狀態以避免false fast retransmits。但是不會在進行上一篇文章中描述的Recovery狀態的cwnd的更新過程,也不會再去嘗試快速重傳過程。

  3. 那么更新TCP進入Open狀態,並更新cwnd=ssthresh

其中第二點中false fast retransmits是指,如果SACK關閉,在Ack=high_seq時候就切換TCP到Open狀態,有可能會導致在high_seq上收到dup ACK觸發虛假快速重傳,SACK使能的時候則不會有這種問題。本篇的主要目的是通過示例2說明三個問題:並不是所有的partial ACK都會觸發快速重傳;虛假快速重傳的避免;SACK關閉場景下虛假快速重傳的撤銷;估計看了上面的說明還是沒明白到底為什么這么處理,下面我們會通過示例2來演示說明這些操作背后的原因。


二、wireshark示例

這篇文章的示例不會在像上一篇一樣詳細的分析每個數據包的處理過程,因此,在讀本篇示例前建議先讀上一篇文章的示例。同樣如上一篇文章一樣,在執行示例前如下設置TCP參數。設置tcp_sack=0關閉SACK功能。

 
 
 
         
  1. ******@Inspiron:~$ sudo ip route add local 127.0.0.2 dev lo congctl reno initcwnd 12 ssthresh lock 10     #參考本系列destination metric文章
  2. ******@Inspiron:~$ sudo ethtool -K lo tso off gso off  #關閉tso gso以方便觀察cwnd變化

1、SACK關閉場景下發送緩存不受限

在前一篇文章中我們說No48是因為發送緩存受限而不能發送新的數據包,因此這里我們先通過一個對比示例來看一下,這一次的業務模型與上一次完全相同即:server端建立連接后,休眠1000ms,然后應用層連續write30次,每次寫入50bytes,寫入間隔為3ms。client與server端建立連接后,立即發送一個請求報文,另外server端第4、5、11次write寫入的數據(分別對應下圖中的No9、No10和No16報文)在傳輸的過程中丟失。client端對收到的每個報文都會回復一個ACK確認包,client與server端的時延為50ms。但不同的是,我們通過SO_SNDBUF這個套接字選項,設置發送緩存為一個足夠大的數據,下面示例SO_SNDBUF選項設置為40000(之前說過實際TCP內部會把這個設置值翻倍的)。最終TCP數據流交互結果如下圖所示

No1-No47:我們可以看到這些數據流與上篇示例中的數據流是一樣的,因此不再重復介紹,server端最終發出No47數據包后,packets_out=19,sacked_out=13, lost_out=1, retrans_out=1,cwnd = 6,ssthresh=6, cwnd_cnt=0,prr_delivered=12, prr_out=8, high_seq=851, retransmit_high=251。


No48-No49:上篇中的示例,在收到No48的時候,server端的發送緩存中因為存放有未釋放的tcp數據,導致緩存受限,應用層write休眠,因而沒能發出新的數據包。但是本示例中的發送緩存設置了一個足夠大的值,因此server端收到No48的時候,server端的緩存中仍然有待發送的數據,所以No48-No49的處理與No47-No48相同,最終發出No49后,sacked_out=sacked_out+1=14、  prr_delivered=prr_delivered+1=13、 packets_out=packets_out+1=20、prr_out=prr_out+1=9 、cwnd = 6。

No50-No52:這三個數據包的處理與上一篇中No49-No51相似。可以看到No50仍然是一個partial ACK,server收到這個partial ACK后,更新retrans_out=0, packets_out=packets_out-6=14, lost_out=0, sacked_out=sacked_out-5=9,接着server根據partial ACK把Seq為501(對應No16)的數據包標記為lost,更新lost_out=lost_out+1=1,retransmit_high = 551,此時 newly_acked_sacked =1, prr_delivered=prr_delivered+newly_acked_sacked=14、 in_flight = packets_out - ( sacked_out + lost_out) + retrans_out = 14 - (9+1) + 0=4,delta = ssthresh - in_flight = 2>0。因為No50的ack number確認了新的數據包且沒有RACK重傳丟失標記,因此sndcnt = min(delta, max(prr_delivered - prr_out,newly_acked_sacked) + 1) = min(2,max(14-9)+1)=2,sndcnt = max(sndcnt, (fast_rexmit ? 1 : 0)) = max(2,0)=2,最終更新cwnd = in_flight + sndcnt = 4+2=6。可以看到此時允許發出兩個數據包,接着server端進入快速重傳流程,發出No51數據包,退出重傳后server嘗試發送新的未發送數據包,發出No52數據包。此時packets_out=15,sacked_out=9, lost_out=1, retrans_out=1,cwnd = 6,ssthresh=6, cwnd_cnt=0,prr_delivered=14, prr_out=11, high_seq=851, retransmit_high=551

No53-No54:server端收到No53這個dup ACK后,更新sacked_out=sacked_out+1=10,prr_delivered = prr_delivered+1=15,此時in_flight=15-(10+1)+1=5, delta = ssthresh - in_flight =1,delta>0,因此sndcnt = min(delta, newly_acked_sacked)=min(1,1)=1,sndcnt = max(sndcnt, (fast_rexmit ? 1 : 0))=max(1,0)=1, cwnd = in_flight + sndcnt = 6,此時擁塞窗口還允許繼續發送一個新的數據包,接着server端的tcp嘗試進行快速重傳,但是因為retransmit_high的限制退出快速重傳流程,然后嘗試發送新數據,即對應No54。然后更新prr_out=12,packets_out=16。

No55-No56:這組數據包的處理與No53-No54相同,發出No56后,packets_out=17,sacked_out=11, lost_out=1, retrans_out=1,cwnd = 6,ssthresh=6, cwnd_cnt=0,prr_delivered=16, prr_out=13, high_seq=851, retransmit_high=551

No57-No58:處理與上面No53-No54相同,發出No58后,packets_out=18,sacked_out=12, lost_out=1, retrans_out=1,cwnd = 6,ssthresh=6, cwnd_cnt=0,prr_delivered=17, prr_out=14, high_seq=851, retransmit_high=551

No59-No60:處理與上面No53-No54相同,發出No60后,packets_out=19,sacked_out=13, lost_out=1, retrans_out=1,cwnd = 6,ssthresh=6, cwnd_cnt=0,prr_delivered=18, prr_out=15, high_seq=851, retransmit_high=551

No61-No62:No61的Ack=1201>high_seq=851,因此server端的tcp快速回復過程結束,No61的Ack新確認了14個數據包,因此更新packets_out=packets_out-14=5,sacked_out = sacked_out-13=0,lost_out=0,retrans_out=0,cwnd_cnt=14。接着server端tcp從Recovery狀態切換到Open狀態,切換的時候,會重置sacked_out=0(實際上此時已經是0了),此時滿足概述里面描述的從Recovery切換到Open狀態cwnd更新的第三種場景,會更新cwnd=ssthresh=6。此時處於Open態,就不會像之前Recovey狀態一樣進入快速重傳流程了。server端接着進入reno的擁塞避免處理過程,cwnd_cnt/cwnd向下取整后為2,因此更新cwnd_cnt=cwnd_cnt-2*cwnd=2,cwnd=cwnd+2=8。接着server端把緩存中最后的一個數據包發出,對應No62,更新packets_out=6。

No63:server端收到No63報文后,packets_out=5,接着進入reno的擁塞避免,但是因為此時處於application-limited狀態,因此不會更新cwnd_cnt,其值依然為2。

No64-No68:這幾個數據包的處理與No63相同,最終packets_out=0,sacked_out=0, lost_out=0, retrans_out=0,cwnd = 8,ssthresh=6, cwnd_cnt=2

實際上tcp_notsent_lowat設置項也可以達到類似發送緩存受限而讓應用層write操作休眠的效果,這個參數限制了TCP緩存中已經存在的但是還沒有發出去的數據量。

2、SACK關閉場景下的擁塞撤銷

執行下面示例前,按照如下命令改變initcwnd和ssthresh的設置

 
 
 
         
  1. ip route change local 127.0.0.2 dev lo initcwnd 6 ssthresh 5 congctl reno

如前面概述所說,這個示例的主要目的是說明三個問題:並不是所有的partial ACK都會觸發快速重傳;虛假快速重傳的避免;SACK關閉場景下虛假快速重傳的撤銷;在執行這個示例前我們設置tcp_timestamps=0,以關閉Eifel探測算法。后面文章會介紹Eifel探測算法。

業務場景:server端在建立TCP連接后,休眠1s,然后以5ms為間隔連續寫入9個數據包,隨后休眠71ms,然后寫入50bytes的數據,在休眠5ms寫入50bytes的數據。server端開始寫入的9個數據包第1個(No6)正常到達了client端,但是隨后的數據包傳輸中發生了亂序,第2個數據包在第3、4、5個數據包之后到達,即No8、No9、No10這三個數據包依次到達client后,No7數據包才到達client,隨后server端發出的其它數據包則沒有發生亂序傳輸。client對server端的每個數據包都會回復一個ACK確認包,RTT大約為50ms。這種場景下,亂序傳輸觸發快速重傳,這意味這server端由dup ACK和partial ACK觸發的都是虛假重傳。最終如下圖所示,對於非重點數據包前面文章都有對應的類似描述,因此此處我只會進行簡要的描述,如果對於相關的狀態變量或者看不懂簡要描述的數據包請先從之前的文章看起。重點包我用紅色字體標記了。

No1-No3:client與server建立連接,server端初始處於Open狀態,cwnd=6,ssthresh=5。

No4-No5:client發送請求,server端回復一個ACK。

No6-No11:路由表中設置了初始cwnd=6,因此這六個數據包在應用層write操作的時候就可以立即發出。No11發出后packets_out=6, cwnd=6, ssthresh=5, server端處於擁塞避免階段。

No12-No13:server端收到client回復的No6報文的確認包后,packets_out=5,更新cwnd_cnt=cwnd_cnt+1=1,此時擁塞控制允許發出一個新的數據包,因此發出No13報文,更新packets_out=6。

No14-No15:接着server端收到No14 dup ACK報文,進入Disorder狀態,發出No15。更新sacked_out=1,packets_out=7。

No16-No17:與No14-No15處理類似,更新sacked_out=2,packets_out=8。

No18-No19:server收到No18后,更新sacked_out=3,此時已經滿足當前的dupthresh門限,因此server端從Disorder狀態切換到Recovery狀態,初始化prr_delivered=0,prr_out=0,重置cwnd_cnt = 0(這個變量在進入Disorder狀態的時候並沒有清空,因此Disorder只是懷疑可能會丟包,還可以切換回到Open狀態),更新high_seq=451,undo_marker=SND.UNA=51,prior_ssthresh=max(ssthresh,3/4*cwnd) =5, ssthresh=max(cwnd/2,2)=3, prior_cwnd=cwnd=6。接着把No7數據包標記為lost,更新lost_out=1,retransmit_high=101。然后更新prr_delivered=1,此時in_flight=packets_out - ( sacked_out + lost_out) + retrans_out=8-(3+1)=4,delta = ssthresh - in_flight = 3-4=-1<0,sndcnt = (ssthresh * prr_delivered + prior_cwnd - 1)/prior_cwnd - prr_out=(3*1+6-1)/6-0=1, sndcnt = max(sndcnt, (fast_rexmit ? 1 : 0))=max(1,1)=1,因此更新cwnd=in_flight+1=5,然后進行快速重傳發出No19數據包。重傳完No19后,更新retrans_stamp為當前的TCP時間戳,prr_out=1,retrans_out=1,此時in_flight=5>=cwnd,因此退出快速重傳流程(實際上此時retransmit_high也不允許linux在額外重傳多余的數據包了,但是linxu實現上先判斷擁塞窗口然后在判斷retransmit_high的限制)。

No20:接着這里有意思了,No20是一個partial ACK,經常會有人說partial ACK會立即觸發重傳,我們來實際更新計算更新一下相關狀態變量看看。server端在收到No20數據包后,No20新確認了四個報文,因此更新packets_out=packets_out-4=4,sacked_out=sacked_out-3=0,lost_out=1(實際上lost_out是先更新為0,然后又更新為1的,並不是保持1不變),retrans_out=0,prr_delivered=2,此時in_flight=packets_out - ( sacked_out + lost_out) + retrans_out=4-(0+1)+0=3,delta = ssthresh - in_flight = 3-3=0>=0,而且No20這個partial ACK確認了新的數據,而沒有被RACK標記為丟失,因此更新sndcnt = min(delta, max(prr_delivered - prr_out,newly_acked_sacked) + 1)=min(0,max(2-1,1)+1)=min(0,2)=0,sndcnt = max(sndcnt, (fast_rexmit ? 1 : 0))=max(0,0)=0,cwnd=in_flight+0=3。可以看到此時擁塞窗口已經不允許TCP重傳報文或者發送新數據了。所以說partial ACK下也不一定會立即進行重傳。

No21-No22:server端在收到No21的時候,更新packets_out=3,retrans_out=0,lost_out=1(同樣lost_out是先更新為0,然后又更新為1的,並不是保持1不變), retrans_out=0,prr_delivered=3,in_flight=packets_out - ( sacked_out + lost_out) + retrans_out=3-(0+1)+0=2,delta=1>=0,同樣No21確認了新的數據包且沒有被RACK標記為丟失,因此sndcnt = min(delta, max(prr_delivered - prr_out,newly_acked_sacked) + 1)=min(1,max(3-1,1)+1)=min(1,3)=1,sndcnt = max(sndcnt, (fast_rexmit ? 1 : 0))=max(1,0)=1,cwnd=in_flight+1=3。因此此時擁塞窗口允許TCP發出一個數據包。接着server端的TCP進入快速重傳流程,發出No22數據包,更新retrans_out=1,prr_out=2。

No23-No24:這組數據包的處理與No21-No22相同,發出No24后,packets_out=2,sacked_out=0,lost_out=1,retrans_out=1,prr_delivered=4, prr_out=3,cwnd=3, ssthresh=3。

No25-No26:這組數據包的處理與No21-No22相同,發出No26后,packets_out=1,sacked_out=0,lost_out=1,retrans_out=1,prr_delivered=5, prr_out=4,cwnd=3, ssthresh=3。

No27:此時server端休眠喚醒,開始進行write操作,先寫入50bytes數據后在休眠5ms。No27即對應這次write操作,發出No27后,packets_out=2,sacked_out=0,lost_out=1,retrans_out=1,prr_delivered=5, prr_out=5,cwnd=3, ssthresh=3。

No28:server端收到No28后,更新packets_out=1,lost_out=0,retrans_out=0,之前初始進入Recovery狀態的時候,high_seq=451,正好與No28的ack number相同,這時候已經滿足概述里面"當Recovery狀態下的TCP收到ack number大於等於high_seq的ACK報文的時候"這個條件了,此時先進行第一步的處理但是retrans_stamp當前值不為0(No19時候設置的),Eifel探測算法也不允許進行擁塞撤銷,因此接着進行第二步驟,此時No28的Ack=high_seq,而且當前SACK處於關閉狀態,因此更新cwnd=min(cwnd,in_flight+dupthresh)=min(3,1+3)=3。此時已經沒有還未被ack number確認的重傳了,因此更新retrans_stamp=0,server端繼續停留在Reovery狀態,但是此場景下不再嘗試進行快速重傳,接着server端嘗試發送新的數據包,但是當前發送緩存為空,因而沒有數據包發出。

No29:server端的應用休眠醒來,進行最后一次write操作,數據包發出后即對應No29。此時packets_out=2, sacked_out=0, lost_out=0,retrans_out=0,prr_delivered=5, prr_out=4,cwnd=3, ssthresh=3。prr_delivered=5, prr_out=6,prr_delivered和prr_out這兩個變量實際上已經沒有用了,因為當Ack=high_seq的時候已經不會在如前一篇文章介紹的那樣更新Recovery狀態下的cwnd了。當Recovery切換到Open狀態的時候這兩個變量也不會清空,但是每次進入Recovery狀態,這兩個變量會重置為0。后面不再關注這兩個變量了。

No30:No30實際上是client對No19的ACK確認包,server端收到這個ACK確認包的時候,先判斷第一步,此時undo_marker標記(在No19處進行的標記)允許進行擁塞撤銷,並且retrans_stamp=0,當前可以進行擁塞撤銷操作,因此更新cwnd=max(cwnd,2*ssthresh)=max(3,2*3)=6, ssthresh=max(ssthresh,prior_ssthresh)=max(3,5)=5,然后更新undo_marker=0,以防止后續重復進行撤銷操作。接着進行第二步處理,此時No19的Ack=high_seq,而且當前SACK處於關閉狀態,因此更新cwnd=min(cwnd,in_flight+dupthresh)=min(6,5)=5,這里第二步同樣會設置retrans_stamp=0,不過retrans_stamp這個變量本來就是0的。server端繼續停留在Reovery狀態,但是此場景下不再嘗試進行快速重傳,接着server端嘗試發送新的數據包,但是當前發送緩存為空,因而沒有數據包發出。

No31:server端收到這個ACK后,同樣先判斷第一步,但是此時undo_marker=0,已經不允許在進行擁塞撤銷操作了。接着同樣執行第二步更新cwnd=min(cwnd,in_flight+dupthresh)=min(5,5)=5,retrans_stamp=0,同樣繼續停留在Recovery狀態,嘗試發送新的數據,但是沒有待發送的數據。

No32-No33:這兩個數據包的處理與No31相同,不再重復描述。

No34:server端收到No34這個數據包的時候,更新packets_out=1,判斷第一步undo_marker不允許撤銷,判斷第二步,但是No34的Ack>high_seq,因此不會執行第二步,接着執行第三步更新TCP進入Open狀態,並更新cwnd=ssthresh=5。

No35:No35與No34處理類似,最后packets_out=0, sacked_out=0, lost_out=0,retrans_out=0, cwnd=5, ssthresh=5。


從上面這個示例可以看到關閉SACK的情況下,亂序傳輸觸發的快速重傳會產生大量的partial ACK,從而觸發虛假快速重傳。TCP在探測到這種虛假重傳的時候會進行擁塞撤銷操作。另外關閉SACK的情況下,如果TCP在Ack=high_seq情況下就退出Recovery狀態進入Open后,可能會收到大量的dup ACK(如圖中No30-No33),然后又立即虛假觸發快速重傳進入Recovery狀態,因此要避免這種false fast retransmits,即在Ack=high_seq的時候繼續停留在Recovery狀態下。而在SACK功能打開的情況下,因為SACK下的快速重傳是使用SACK塊來認定快速重傳的(不清楚的話請參考前面SACK下快速重傳的文章),因此就不會觸發虛假快速重傳。最后給出這次業務交互的時序圖,試着從里面找出Recovery point、初始快速重傳、沒有觸發快速重傳的partial ACK、觸發了快速重傳的partial ACK這幾個關鍵點吧。


補充說明:

1、本篇主要代碼點tcp_try_undo_recovery







免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM