Linux TCP在3.18核心引入的一個慢啟動相關的問題或者說Bug
又到了週末,本週把國慶假期遺留的一個問題進行一個總結。我把形而上的討論放在本文的最後,這裡將快速進入正題,只說一句,浙江溫州皮鞋溼!
我們先來看一個標準TCP最簡單的AIMD CC過程,這裡以Reno為例,簡單直接:
但是,在Linux3.18rc5之後,如果在關閉SACK(後面會講為什麼要關閉SACK)的前提下重新模擬上述的AIMD過程,將會是下面的樣子:
事實上,不管你用的是不是Reno演算法,即便是Cubic,BIC這種,也依然是上面的結果,即在3.18rc5核心以後,ssthresh的值總是保持著初始值。
出現這種奇怪的現象,就必須要解釋一下為什麼了。
好,我先描述一下事情的來龍去脈。
國慶節前,有網友Email給我,諮詢一個問題,說是在使用Reno演算法時出現了比較奇怪的現象,即:
- 3.17核心:在模擬超時之後,cwnd會慢啟動增加到ssthresh,之後執行AI過程。
- 3.18核心:在模擬超時之後,cwnd始終保持慢啟動狀態,沒有進入AI過程。
確實詭異,這讓我想起了兩個月前有個微信好友諮詢的另一個問題,即在他使用2.6.32或者3.10核心的時候,一切都正常,而在使用4.9核心的時候,cwnd總是不經意間從1開始,他問我 3.10以後到4.9之間,Linux關於TCP慢啟動的實現是不是有什麼變化 …當時由於在忙工作和小小出國旅遊的事情,就有點心不在焉,忽略了。
把這兩個問題一起來看的話,似乎有些關聯,不過國慶期間回深圳探親一直沒顧得上回復先前那位給我發Email的網友,休假結束後準備把這個問題一探究竟。
感謝這位網友告訴我變化是從3.18rc5開始的。
擼了一遍3.18rc5的patch,和TCP相關的有如下:
[net] tcp: zero retrans_stamp if all retrans were acked:https://patchwork.ozlabs.org/patch/406624/
先看一下這個patch是幹嘛的。
patch描述上說的非常清楚,我簡單引用一下:
Ueki Kohei reported that when we are using NewReno with connections that
have a very low traffic, we may timeout the connection too early if a
second loss occurs after the first one was successfully acked but no
data was transfered later. Below is his description of it:
When SACK is disabled, and a socket suffers multiple separate TCP
retransmissions, that socket’s ETIMEDOUT value is calculated from the
time of the first retransmission instead of the latest
retransmission.
This happens because the tcp_sock’s retrans_stamp is set once then never
cleared. Take the following connection:Linux remote-machine | | send#1---->(*1)|--------> data#1 --------->| | | | RTO : : | | | ---(*2)|----> data#1(retrans) ---->| | (*3)|<---------- ACK <----------| | | | | : : | : : | : : 16 minutes (or more) : | : : | : : | : : | | | send#2---->(*4)|--------> data#2 --------->| | | | RTO : : | | | ---(*5)|----> data#2(retrans) ---->| | | | | | | RTO*2 : : | | | | | | ETIMEDOUT<----(*6)| |
(*1) One data packet sent.
(*2) Because no ACK packet is received, the packet is retransmitted.
(*3) The ACK packet is received. The transmitted packet is acknowledged.At this point the first “retransmission event” has passed and been
recovered from. Any future retransmission is a completely new “event”.(*4) After 16 minutes (to correspond with retries2=15), a new data
packet is sent. Note: No data is transmitted between (*3) and (*4).The socket’s timeout SHOULD be calculated from this point in time, but
instead it’s calculated from the prior “event” 16 minutes ago.(*5) Because no ACK packet is received, the packet is retransmitted.
(*6) At the time of the 2nd retransmission, the socket returns
ETIMEDOUT.
Therefore, now we clear retrans_stamp as soon as all data during the
loss window is fully acked.
那麼這個patch是如何影響本文一開始描述的問題的呢?這還得從實現上看起。
我一直說TCP的程式碼像屎一樣,確實是,所以我一向不推薦上來就分析程式碼,而是先看RFC。
本文描述的ssthresh被重置問題背後是 反過來的一個花開兩朵各表一枝的故事,我們一個一個說,先說一下相關的RFC,然後再說說上述3.18rc5的這個patch,最後兩個合起來,就導致了ssthresh被重置的問題。
和這個問題有關的RFC是RFC6582:https://tools.ietf.org/html/rfc6582
不過也可以看RFC2582這個原始一點的版本:https://tools.ietf.org/html/rfc2582
不管是哪個,和本文的問題相關的就一點,即 對重複ACK的處理:
- Three duplicate ACKs:
When the third duplicate ACK is received, the TCP sender first
checks the value of recover to see if the Cumulative
Acknowledgment field covers more than recover. If so, the value
of recover is incremented to the value of the highest sequence
number transmitted by the TCP so far. The TCP then enters fast
retransmit (step 2 of Section 3.2 of [RFC5681]). If not, the TCP
does not enter fast retransmit and does not reset ssthresh.
總的來講,NewReno對Reno的改進主要就是為了避免重複連續進入降cwnd的狀態,從而保持pipe的儘可能滿載,而導致降cwnd的事件,就是CC狀態機進入了超時或者快速重傳這些狀態。
所以說,不管是超時,還是快速重傳,其狀態退出的條件是ACK必須完全覆蓋進入該狀態時傳送的最大包,否則就保持該狀態不變。
我們假設一次丟包被發現時,傳送的最大包為P,那麼如果ACK剛剛好覆蓋到P這個臨界包時,要不要退出丟包恢復狀態呢?RFC2582裡有這麼一段描述:
There are two separate scenarios in which the TCP sender could
receive three duplicate acknowledgements acknowledging “send_high”
but no more than “send_high”. One scenario would be that the data
sender transmitted four packets with sequence numbers higher than
“send_high”, that the first packet was dropped in the network, and
the following three packets triggered three duplicate
acknowledgements acknowledging “send_high”. The second scenario
would be that the sender unnecessarily retransmitted three packets
below “send_high”, and that these three packets triggered three
duplicate acknowledgements acknowledging “send_high”. In the absence
of SACK, the TCP sender in unable to distinguish between these two
scenarios.
針對這個問題的得失權衡,就出現了兩種實現方案,Linux顯然選擇了保守的方案而不是激進的方案,我們在下面的這段程式碼中可以找到關於這個保守方案的身影,程式碼來自Linux 3.17版本:
/* People celebrate: "We love our President!" */
static bool tcp_try_undo_recovery(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
if (tcp_may_undo(tp)) {
// 這裡很重要,但不是現在.所以我先忽略!
}
//僅僅會影響未開啟SACK的流,這一點在RFC中有描述.之所以會採用這種保守的措施,是因為在不支援SACK的情況下,TCP協議無法區分重複ACK的觸發緣由.
if (tp->snd_una == tp->high_seq && tcp_is_reno(tp)) {
/* Hold old state until something *above* high_seq
* is ACKed. For Reno it is MUST to prevent false
* fast retransmits (RFC2582). SACK TCP is safe. */
tcp_moderate_cwnd(tp);
// 如果剛剛覆蓋到high_seq這個臨界點,那麼退出函式,暫且不將狀態恢復到Open,而是保持丟包恢復狀態.
return true;
}
tcp_set_ca_state(sk, TCP_CA_Open);
return false;
}
看到上述程式碼,這意味著 如果當前的TCP流的ACK剛剛等於high_seq,那麼將會在下次更新的ACK到來時恢復到Open狀態,這是顯然的。 還有一個顯然的事實是,下次依然會進入到這個tcp_try_undo_recovery函式中,下次將不再進入if分支而退出,進而將狀態設定為Open。
第一個故事到此結束,我們已經把程式碼流程理清楚了。
好,現在來看3.18rc5的那個patch:
diff --git a/net/ipv4/tcp_input.c b/net/ipv4/tcp_input.c
index a12b455928e52211efdc6b471ef54de6218f5df0..65686efeaaf3c36706390d3bfd260fd1fb942b7f 100644
--- a/net/ipv4/tcp_input.c
+++ b/net/ipv4/tcp_input.c
@@ -2410,6 +2410,8 @@ static bool tcp_try_undo_recovery(struct sock *sk)
* is ACKed. For Reno it is MUST to prevent false
* fast retransmits (RFC2582). SACK TCP is safe. */
tcp_moderate_cwnd(tp);
+ if (!tcp_any_retrans_done(sk))
+ tp->retrans_stamp = 0;
return true;
}
tcp_set_ca_state(sk, TCP_CA_Open);
修改的正是函式 tcp_try_undo_recovery,在恰好ACK臨界包high_seq的時候,退出tcp_try_undo_recovery前,將retrans_stamp 進行了清零處理,從而解決了ETIMEDOUT的問題,但是,現在我們看一下同為tcp_try_undo_recovery函式邏輯的最開始的tcp_may_undo分支。
tcp_may_undo的實現如下:
static inline bool tcp_may_undo(const struct tcp_sock *tp)
{
return tp->undo_marker && (!tp->undo_retrans || tcp_packet_delayed(tp));
}
static inline bool tcp_packet_delayed(const struct tcp_sock *tp)
{
return !tp->retrans_stamp ||
(tp->rx_opt.saw_tstamp && tp->rx_opt.rcv_tsecr &&
before(tp->rx_opt.rcv_tsecr, tp->retrans_stamp));
}
顯然,第一次進入tcp_try_undo_recovery時,undo條件是不滿足的,可是第一次進入tcp_try_undo_recovery時卻把retrans_stamp 給置為0了!
這意味著第二次進入tcp_try_undo_recovery的時候,會進入undo分支,後果就是tcp_undo_cwnd_reduction被呼叫:
#define TCP_INFINITE_SSTHRESH 0x7fffffff
static void tcp_undo_cwnd_reduction(struct sock *sk, bool unmark_loss)
{
...
// prior_ssthresh最開始進入丟包狀態時儲存初始值TCP_INFINITE_SSTHRESH
if (tp->prior_ssthresh) {
...
if (tp->prior_ssthresh > tp->snd_ssthresh) {
tp->snd_ssthresh = tp->prior_ssthresh;
tcp_ecn_withdraw_cwr(tp);
}
}
...
}
一切成了下面的樣子:
問題以及問題的成因就是這樣子,然而3.18釋出很久了,幾乎沒有人發現這個問題,我覺得原因大概有這麼幾點:
- 如今不開啟SACK的很少了;
- 很少有需要注意到細節的場景;
- 這其實並不是問題。
我仔細想了一下上述第三個,反問,這是問題嗎?
引一篇很早以前寫的文章:
TCP核心概念-慢啟動,ssthresh,擁塞避免,公平性的真實含義:https://blog.csdn.net/dog250/article/details/51439747
ssthresh是什麼?
ssthresh本質就是一個 “公平性下界” 的度量,如果把丟包視為擁塞的訊號,那麼發生超時或快速重傳時的cwnd正是一個撐爆管道的BDP,那麼一個下界相當於從當前BDP的1/2處開始CA(擁塞避免)就是正確的,這個之前我有過數學證明。
3.18後的核心TCP實現把超時恢復後的ssthresh恢復成了 ***“上一個下界”***,這是不合理的,然而這可能是無心之過。
3.18rc5的這個patch是為了解決ETIMEDOUT這個bug的,我想作者應該是解決了該bug,但是卻引入了本文描述的ssthresh被重置這另外一個問題,這個問題雖然不影響TCP的正確性,但確實是不合理的。
最後給出兩個我的模擬問題的packetdrill指令碼,首先一個是模擬超時的:
+0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0.000 bind(3, ..., ...) = 0
+0.000 listen(3, 1) = 0
+0.000 < S 0:0(0) win 32792 <mss 1000,sackOK,nop,nop,nop,wscale 7>
+0.000 > S. 0:0(0) ack 1 <...>
+0.000 < . 1:1(0) ack 1 win 2000
+0.000 accept(3, ..., ...) = 4
+0.000 %{
print "init ssthresh:",tcpi_snd_ssthresh
print "init cwnd:",tcpi_snd_cwnd
}%
+0.000 write(4, ..., 10000) = 10000
+0.000 < . 1:1(0) ack 4001 win 2000
+0.000 %{
print "brto cwnd:",tcpi_snd_cwnd
}%
+0.250 %{
print "ssthresh timeout", tcpi_snd_ssthresh
print "cwnd:",tcpi_snd_cwnd
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 6001 win 2000
+0 %{
print "ssthresh ack 6001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 7001 win 2000
+0 %{
print "ssthresh ack 7001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 8001 win 2000
+0.100 %{
print "ssthresh ack 8001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 9001 win 2000
+0.100 %{
print "ssthresh ack 9001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 10001 win 2000
+0.100 %{
print "ssthresh ack 10001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 11001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 12001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 13001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
// done!
然後一個是模擬快速重傳的:
+0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0.000 bind(3, ..., ...) = 0
+0.000 listen(3, 1) = 0
+0.000 < S 0:0(0) win 32792 <mss 1000,sackOK,nop,nop,nop,wscale 7>
+0.000 > S. 0:0(0) ack 1 <...>
+0.000 < . 1:1(0) ack 1 win 2000
+0.000 accept(3, ..., ...) = 4
+0.000 %{
print "init ssthresh:",tcpi_snd_ssthresh
print "init cwnd:",tcpi_snd_cwnd
}%
+0.000 write(4, ..., 10000) = 10000
+0.000 < . 1:1(0) ack 4001 win 2000
+0.000 %{
print "brto cwnd:",tcpi_snd_cwnd
}%
+0.000 < . 1:1(0) ack 4001 win 2000
+0.000 < . 1:1(0) ack 4001 win 2000
+0.000 < . 1:1(0) ack 4001 win 2000
+0.000 %{
print "ssthresh timeout", tcpi_snd_ssthresh
print "cwnd:",tcpi_snd_cwnd
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 6001 win 2000
+0 %{
print "ssthresh ack 6001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0 < . 1:1(0) ack 10001 win 2000
+0.100 %{
print "ssthresh ack 10001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 11001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 12001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
+0.000 write(4, ..., 1000) = 1000
+0 < . 1:1(0) ack 13001 win 2000
+0.100 %{
print "ssthresh ack 11001", tcpi_snd_ssthresh
print "ca_state:", tcpi_ca_state
print "lost:", tcpi_lost
}%
// done!
有破要有立,方成正道。
那麼怎麼解決這個問題呢?其實也簡單,在確認是丟包狀態自然恢復而不是undo恢復時,將tcp_sock物件的prior_ssthresh清除即可。
我們知道,這個地點就是本文最開始那個patch的地方,只需要加一行程式碼,將:
if (!tcp_any_retrans_done(sk))
tp->retrans_stamp = 0;
改為:
if (!tcp_any_retrans_done(sk)) {
tp->retrans_stamp = 0;
tp->prior_ssthresh = 0;
}
即可!
每寫一篇技術文章,背後都是有一個連貫的小故事,這記載了我自己的一些經歷或者記錄了我和另外一些同好進行交流的細節。顯然這類文章並不能算是技術文件,而只能算是隨筆或者技術散文一類。
本就是性情中人,喝酒吃肉舞文弄墨算是還可以,然而思路卻還是比較跳躍,被很多人說是沒有邏輯,可能箇中邏輯也只有我自己能串起來吧,比如皮鞋,比如經理,比如座椅爆炸…這就是我為什麼連一篇簡單的技術白皮書都懶得寫,卻可以寫十年部落格的原因吧,在這十年之前,我還有將近二十年的日記。
不會倒酒,不會乾杯,不會敬酒,不會划拳,卻有時可以喝倒一桌人,也許箇中原因和寫部落格而不寫文件有些類似吧。
最後,我想說說關於 選擇 的話題。
為什麼TCP的程式碼像屎一樣,因為有太多的邏輯分支不得不採用if-else來不斷迭代,處處穿插這微妙的trick!
很多女的衣服鞋子非常多,這就導致她們出門的效率極其低下,不僅僅是糾結穿哪件上衣,還要糾結穿哪條裙子,還要糾結哪雙鞋子更好看,甚至還有髮型,但這不是最要命的,最要命的是上述這些如何搭配,這可是一個叉乘啊!
像我就不用糾結,光頭長髮roundrobin,一件上衣一條短褲一雙鞋,沒得選擇,自然就可以說走就走。
每一個if語句都會帶來效能的損失,選擇了一個就意味著放棄了其它,而你必須選擇一個,所以你必須有所放棄,放棄意味著失落,失落意味著損耗,不管是損耗你的心情,還是CPU的指令週期,所以,選擇並不是一件好事。
選擇意味著低效!
TCP的CC同樣是複雜而令人噁心的,原因在於有太多的選擇,看下面的一篇Wiki:
TCP congestion control:https://en.wikipedia.org/wiki/TCP_congestion_control
請看完它。
太多了太多了。如果我用Cubic,你用BBR,那麼ICCRG就要考慮Cubic如何和BBR協調公平性,收斂自己的優勢,平滑同伴的劣勢,這便在演算法實現的時候,增加了一個trick,表現為一條或者多條if-else語句,ICCRG不得不考慮所有這些演算法共存的時候,網際網路如何看起來和聲稱的一樣優秀。
不幸的是,即便是ICCRG也不知道這些演算法分別在整個網際網路的佔比情況和地域部署的資訊,這便很難開展全域性優化這樣的工作。
更為不幸的是,很多人並不按照章法出牌,類似一個包發兩邊以侵略性爭搶頻寬的 演算法 層出不窮,這便是端到端自主擁塞控制固有的缺陷帶來的永遠無法解決的問題,隨之,TCP擁塞控制變成了一個社會學博弈問題,而不再是一個技術問題。
不幸中的萬幸,早就有人意識到了這一點,並且採取了行動。
這便是CAAI所做的工作,CAAI的全程是 TCP Congestion Avoidance Algorithm
Identification
關於CAAI的詳情,這裡有一篇文件:http://digitalcommons.unl.edu/cgi/viewcontent.cgi?article=1159&context=cseconfwork
在綜述中,作者進行了一個相當形象的類比:
As an analogy, if we consider the Internet as a country, an Internet node as a house, and a TCP algorithm running at a node as a person living at a house, the process of obtaining the TCP deployment information can be considered as the TCP algorithm census in the country of the Internet. Just like the population census is vital for the study and planning of the society, the TCP algorithm census is vital for the study and planning of the Internet.
是的,CAAI就是在做 網際網路上的‘人口普查’ 工作。這是一個非常好的開始,但是,我對其是否能持續下去持悲觀態度。
我們回望我們的500年,有多少類似的失敗。《烏托邦》始終停留在幻想中,巴黎公社失敗了,孫中山失敗了,…這所有的失敗,其根源只有一個,即 把容器裡的東西看成了整齊劃一的同質的東西,事實上,最終它們實質的 異構性無法完美的相似相溶。每個獨立的個體都是與眾不同的個體,不相似,則不相容,而社會學的任務就是研究這背後的模式。
回到我們的TCP擁塞控制工程學上,幾乎所有的CC演算法在設計的時候都有一種假設,即 網際網路上所有的節點都在運行同一個演算法,在這個基本原則之後,才會做 如果有不執行該演算法的節點,我該怎麼辦 這種Bugfix,然後引入一系列的trick,讓事情趨於複雜。
這便解釋了為什麼Google的BBR演算法在其SDN全域性控制的B4網路上為什麼如魚得水而到了國內三大運營商的網路裡卻是一塌糊塗。因為國內的網路沒有全域性控制,也沒有全部部署BBR。
我們從BBR 2.0中可以看到,BBR已經開始在引入trick了,然而這並不是一件好事。
怎麼辦?穿上皮鞋吧。
皮鞋進水不會胖,旋轉座椅會爆炸。
這是一篇沒有喝真露而寫好的文章。