TCP在慢啟動階段,每一個RTT擁塞窗口按指數級增長,TCP在擁塞避免階段,每一個RTT擁塞窗口線性增加1。這些都是書上講的,不必太認真,真實的情況要比這個復雜的多!
首先我們看大部分的資料裡講的TCP是怎麼實現每RTT增窗的,一切都是扯理論,沒什麼現實意義!
在慢啟動階段,每收到一個ACK(數據包從發出到收到其ACK,就是一個RTT),窗口增加1,在擁塞避免階段,每收到前一窗口整窗的ACK,窗口增加1,也就是說,每收到一個ACK,窗口增加1/cwnd!然而這都是理想情況,萬一ACK丟了怎麼辦?萬一接收端啟用了delay ACK怎麼辦?
根據標准規定,接收端最多只能延遲2*MSS這麼多的ACK,如果再多了就是stretch ACK了!也就是說,如果接收端啟用了delay ACK,每收到兩個數據包的時候才發一個ACK的話,發送端在一個RTT時間段內可能增加的窗口只有預期的1/2,這符合大多數資料裡講的邏輯嗎?
標准其實並沒有規定在擁塞避免階段窗口一定是一個RTT增加1,而只是用了一個近似的算法:
Traditionally, TCPs have approximated this increase by increasing cwnd by 1/cwnd for each arriving ACK. This algorithm opens cwnd by roughly 1 segment per RTT if the receiver ACKs each incoming segment and no ACK loss occurs. However, if the receiver implements delayed ACKs [Bra89], the receiver returns roughly half as many ACKs, which causes the sender to open cwnd more conservatively(by approximately 1 segment every second RTT).
如果TCP真的表現為一包一ACK,那麼大多數情況下確實可以做到慢啟動指數增窗,擁塞避免線性增窗。但是正如上面所說,如果考慮到ACK丟失,或者接收端delay ACK等,理論邏輯就難免失真了。
TCP的ACK機制本身就是一種反饋,理論上“被ACK的數據越多,意味著可以發送的越多”這種猜測總是合理的,於是RFC3465提出了一個ABC算法,即通過收到的ACK中被ACK的字節數來計算如何增加窗口。
1.safe area和dangerous area
TCP作為一種端到端的協議並沒有帶寬反饋的能力,因此其擁塞控制機制完全是基於探測的,即不斷地試探帶寬的極限,不管多麼好的擁塞控制算法,其本質也不外乎以上這種探測。在擁塞控制看來,所謂的帶寬探測最終落實到擁塞窗口探測上。
如今的TCP實現基本延續了NewReno的內核,在擁塞窗口探測的過程中,它會經歷兩個區域,一個是safe區域,一個是dangerous區域,兩個區域由ssthresh來分割。在safe區域中,執行指數增窗的慢啟動,在dangerous區域執行線性增窗的擁塞避免。ssthresh事實上是安全增窗的上界,也可以理解為保守的滿帶寬窗口,既然是安全的區域,那麼就可以盡可能快的增加窗口,於是理論上每來一個ACK(這說明網絡是通的),窗口就可以增加1,而不必等待一窗數據均被ACK才增窗,在越過了ssthresh之後,TCP會認為隨時都有可能超過網絡的承載能力,於是只有在一窗數據都被ACK了之後,才可以增窗。
2.如何利用ACK的反饋信息
以上的關於safe/dangerous區域的描述中,所有的增窗行為均是通過ACK來反饋的,RFC2581建議使用ACK的數量來作為增窗的信號,然而面對ACK丟失或者delay ACK的時候,RFC3465給出了ABC算法,在ABC中,使用被ACK的字節數而不是ACK的數量來作為增窗的反饋信號。這樣的話,以下過程將被執行:
1.慢啟動階段:只要被ACK的數據字節數達到了一個MSS大小,窗口增加一個MSS;
2.擁塞避免階段:只要被ACK的數據字節數達到了一窗的大小,窗口增加一個MSS。
除此之外,ABC可以讓TCP在慢啟動階段更加激進,它可以讓TCP每收到ACK了一個MSS的確認包後,增加N個而不是1個MSS窗口的大小,因為這是在安全區域,激進不為過!
3.ABC與突發
ABC貌似解決了delay ACK以及ACK丟失帶來的窗口增加緩慢的問題,然而卻帶來了突發問題,這個突發問題是利還是弊,並不絕對!本質上來講,ABC算法會帶來突發的原因是它會“記住”那些遲到的或者丟失的確認,並積累起來日後使用,這非常類似於網絡流量控制的突發令牌桶的原理,令牌是可以積累使用的,在TCP中,每一個針對一個MSS大小數據段的確認,不管其是顯式的還是隱式的,都相當於一個可以積累的令牌。
1.異常帶來的突發
假設ACK大面積丟失,對於發送端而言,ACK到來的頻率會降低,造成的效果就是窗口長時間由於收不到ACK而僵住,一旦收到一個ACK,發送端會發現它ACK了大量(甚至巨量)的數據,窗口一下子得到了補償,可能會增加很多,這種突發對於慢啟動階段可能會更嚴重,因為慢啟動階段每確認MSS大小的數據,窗口就會增加N(取決於配置),而對於擁塞避免階段,情況會緩和很多。
這種異常帶來的突發,在單向擁塞的情況下,問題不大,如果發生了雙向擁塞,發送端的激進增窗帶來的就是更多丟包了,發送端在無法區別對待ACK丟失和delay ACK的情況下,會造成很多的誤判,幸好,TCP規范了stretch ACK的定義,這或許會給發送端的判斷帶來一些暗示,比如收到了連續ACK了超過2個MSS數據的確認包,就判斷為是ACK丟失,保守增窗。
為了保證發生上述問題的時候異常突發不會帶來嚴重的後果,RFC3465規定慢啟動階段,N的最大值為2,即每收到一個MSS大小的確認,窗口最多增加2個MSS!
2.正常突發
相對於異常突發,正常突發就顯得更加像是一個正反饋無級變速系統了,簡單來講,如果不考慮delay ACK和ACK丟失以及接收端的實現bug,ABC算法執行地會相當平滑,即ACK覆蓋面越廣,增窗就越快,不考慮擁塞的情況下,這意味著你發送的數據越多,窗口增加的越快!
RFC3465有一個例子很好,比如你登錄了ssh進行交互式作業,大多數情況下都是小數據的交互,此時窗口增加比較緩慢(ABC算法按照被ACK的字節數大小來決定是否增窗以及增加多少),此時如果你需要在終端顯示一個大文件,那麼交互的數據量幾乎是瞬間變大,如果按照RFC2581的方式增窗,窗口增加完全按照ACK的數量來的話,就等於說是有了一個最高的限速,然而如果使用ABC的話,隨著大塊數據的發送和被確認,窗口的增速也隨之增加。這種情況下,ACK到達的速率是不變的,然而ACK覆蓋的字節數卻變大了,窗口增速也就隨之變大。
3.穿越ssthresh
在慢啟動階段,窗口按照指數增長,如果ssthresh的值比較大,在窗口穿越ssthresh的時候,其可能已經是十分大了,在最後一次慢啟動增窗中,很大的可能性會使窗口一下子升到ssthresh以上很多,超過了網絡的承載能力造成丟包。在這種情況下,ssthresh根本就沒有起到阈值的作用,在ABC算法中N為2的情況下,問題更加嚴重。
之所以會有這個問題,是因為TCP沒有在窗口增加到ssthresh附近的時候沒有更細化的控制。不過這不是什麼問題,Linux 4.x+已經完美修正了這個問題(具體我不知道是哪個版本引入的,但是我確定3.10中沒有,而4.3中有)。
4.ABC與Linux TCP實現
Linux關於ABC算法的實現經歷了三個階段。
階段一:ABC作為一個sysctl選項
以2.6.32內核版本為例,Linux有一個sysctl_tcp_abc選項,它會選擇是否使用ABC算法,如果啟用ABC,一個ACK包確認的字節數會被保存在bytes_acked字段裡,在擁塞避免階段,TCP是這樣使用bytes_acked的:
if (tp->bytes_acked >= tp->snd_cwnd*tp->mss_cache) {
tp->bytes_acked -= tp->snd_cwnd*tp->mss_cache;
if (tp->snd_cwnd < tp->snd_cwnd_clamp)
tp->snd_cwnd++;
}
而bytes_acked會在收到ACK的時候被更新:
if (icsk->icsk_ca_state < TCP_CA_CWR)
tp->bytes_acked += ack - prior_snd_una;
else if (icsk->icsk_ca_state == TCP_CA_Loss)
/* we assume just one segment left network */
tp->bytes_acked += min(ack - prior_snd_una,
tp->mss_cache);
然而,如果沒有使用ABC的話,在每收到ACK的時候會執行下面的邏輯:
if (tp->snd_cwnd_cnt >= tp->snd_cwnd) {
if (tp->snd_cwnd < tp->snd_cwnd_clamp)
tp->snd_cwnd++;
tp->snd_cwnd_cnt = 0;
} else {
// 可見,不使用ABC是通過ACK的數量來計數的
tp->snd_cwnd_cnt++;
}
在慢啟動階段:
if (sysctl_tcp_abc > 1 && tp->bytes_acked >= 2*tp->mss_cache)
cnt <<= 1; // 可以增加2倍窗口
// 慢啟動階段完全按照一次ACK的MSS倍數來決定增窗大小,因此需要清除bytes_acked
tp->bytes_acked = 0;
tp->snd_cwnd_cnt += cnt;
// 注意,下面的算法可能會出現ssthresh的穿越問題!
while (tp->snd_cwnd_cnt >= tp->snd_cwnd) {
tp->snd_cwnd_cnt -= tp->snd_cwnd;
if (tp->snd_cwnd < tp->snd_cwnd_clamp)
tp->snd_cwnd++;
}
階段二:ABC可選實現
以3.10為例,你會發現沒有了tcp_abc選項,在代碼中,也再也沒有了關於bytes_acked的計數,而且在擁塞避免階段,也只剩下了tcp_cong_avoid_ai(tp, tp->snd_cwnd)的邏輯,而這個邏輯是按照ACK的數量來計數的。
那麼如果想實現ABC怎麼辦呢?只好在自己的擁塞模塊裡面自己寫了。每個ACK所確認的數據量(即bytes_acked)可以通過pkts_acked回調函數獲取(在清理傳輸隊列的數據包後調用)。
階段三:ABC內置並優化實現
到了Linux 4.4內核,依然還是沒有tcp_abc選項,然而如果你看代碼,發現基本已經完全實現了ABC算法:
void tcp_reno_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
struct tcp_sock *tp = tcp_sk(sk);
if (!tcp_is_cwnd_limited(sk))
return;
/* In "safe" area, increase. */
if (tcp_in_slow_start(tp)) {
// slow_start函數有了返回值,返回慢啟動結束後,還剩下多少被確認的數據可以用來增加擁塞避免增窗計數器
acked = tcp_slow_start(tp, acked);
// 如果返回0,說明此次增窗還沒有穿越ssthresh。可見新版本完美捕捉到了ssthresh穿越問題
if (!acked)
return;
}
/* In dangerous area, increase slowly. */
// acked參數被傳入,作為增窗條件的計數被累加。
tcp_cong_avoid_ai(tp, tp->snd_cwnd, acked);
}
如果我們看下tcp_slow_start,會發現它異常簡潔:
u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
// 慢啟動階段,窗口最多增加到ssthresh。
// acked累加到窗口,雖然不是ABC的標准算法(沒有實現N增窗),但基本就是那個意思
u32 cwnd = min(tp->snd_cwnd + acked, tp->snd_ssthresh);
// 剩下的穿越ssthresh的部分交給擁塞避免來處理
acked -= cwnd - tp->snd_cwnd;
tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp);
return acked;
}
函數tcp_cong_avoid_ai的注釋也多了一句話:
/* In theory this is tp->snd_cwnd += 1 / tp->snd_cwnd (or alternative w),
* 下面這句話是新增的...
* for every packet that was ACKed.
*/
void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked)
{
/* If credits accumulated at a higher w, apply them gently now. */
if (tp->snd_cwnd_cnt >= w) {
tp->snd_cwnd_cnt = 0;
tp->snd_cwnd++;
}
// 累加被確認的數據量而不是每收到ACK簡單加1
tp->snd_cwnd_cnt += acked;
...
}
TCP是一個復雜無比的協議,Linux的實現也歷經著無比大的變化,從2.6.8到4.4,你會發現很多細節連基本思想都改變了,這背後,如果不了解RFC中闡釋的思路,將很難理解其所以然,因此RFC才是王道,內功,心法!