長肥管道(LFT)中TCP的艱難處境與打法

一年多沒有深夜驚起而作文了,又逢雨夜,總結一些思路。

帶寬一定的情況下,網絡的吞吐理論上不受時延的影響,雖然管道長了一點,但截面積是一定的。

然而當你用TCP去驗證這一結論時,往往得不到你想要的結果:

  • 一個長肥管道很難被單條TCP連接填滿(一條TCP流很難在長肥管道中達到額定帶寬)!

我們做以下拓撲:
在這裏插入圖片描述

首先我測算裸帶寬作爲基準。

測試接收端爲172.16.0.2,執行iperf -s,測試發送端執行:

iperf -c 172.16.0.2  -i 1  -P 1 -t 2

結果如下:

------------------------------------------------------------
Client connecting to 172.16.0.2, TCP port 5001
TCP window size: 3.54 MByte (default)
------------------------------------------------------------
[  3] local 172.16.0.1 port 41364 connected with 172.16.0.2 port 5001
[ ID] Interval       Transfer     Bandwidth
[  3]  0.0- 1.0 sec   129 MBytes  1.09 Gbits/sec
[  3]  1.0- 2.0 sec   119 MBytes   996 Mbits/sec
[  3]  0.0- 2.0 sec   248 MBytes  1.04 Gbits/sec
...

很顯然,當前直連帶寬爲1 Gbits/Sec。

爲了模擬一個RTT爲100ms的長肥管道,我在測試發送端用netem模擬一個100ms的延時(僅僅模擬延時,無丟包):

tc qdisc add dev enp0s9 root netem delay 100ms limit 10000000

當然,也可以在測試發送端和測試接收端各用netem模擬一個50ms的延時以分擔hrtimer的開銷。

再次執行iperf,結果慘不忍睹:

------------------------------------------------------------
Client connecting to 172.16.0.2, TCP port 5001
TCP window size:  853 KByte (default)
------------------------------------------------------------
[  3] local 172.16.0.1 port 41368 connected with 172.16.0.2 port 5001
[ ID] Interval       Transfer     Bandwidth
[  3]  0.0- 1.0 sec  2.25 MBytes  18.9 Mbits/sec
[  3]  1.0- 2.0 sec  2.00 MBytes  16.8 Mbits/sec
[  3]  2.0- 3.0 sec  2.00 MBytes  16.8 Mbits/sec
[  3]  3.0- 4.0 sec  2.00 MBytes  16.8 Mbits/sec
[  3]  4.0- 5.0 sec  2.88 MBytes  24.1 Mbits/sec
[  3]  0.0- 5.1 sec  11.1 MBytes  18.3 Mbits/sec

和理論推論完全不符!長肥管道的傳輸帶寬看起來並非不受時延的限制…

那麼問題來了,到底是什麼在阻止一個TCP連接填滿一個長肥管道呢?

爲了讓事情變得簡單和可分析,我使用最簡單的Reno算法進行測試:

net.ipv4.tcp_congestion_control = reno

並且我關掉了兩臺機器的TSO/GSO/GRO等Offload特性。在此前提下,故事開始了。

需要明確的一個常識就是,一個長肥管道的容量等於帶寬和時延的乘積,即BDP。

那麼,單條TCP流填滿一個長肥管道看起來只需要滿足三個條件即可:

  • 爲了讓發送端可以發送BDP這麼多的數據,接收端需要通告一個BDP大小的窗口。
  • 爲了讓發送端有BDP這麼多的數據可發,發送端需要有BDP這麼大的發送緩衝區。
  • 爲了讓BDP這麼多的數據背靠背順利通過長肥管道,網絡不能擁塞(即需要單流獨享帶寬)。

OK,如何滿足以上三個條件呢?

我先來計算100ms的1 Gbits/Sec管道的BDP有多大,這很容易計算,以字節爲單位,它的值是:
1024 × 1024 × 1024 8 × 0.1 = 13421772 \dfrac{1024\times 1024\times 1024}{8}\times 0.1=13421772 81024×1024×1024×0.1=13421772字節
大概13MB的樣子。

以MTU 1500字節爲例,我將BDP換算成數據包的數量,它的值是:
13421772 1500 = 8947 \dfrac{13421772}{1500}=8947 150013421772=8947
即8947個數據包,這便是填滿整個長肥管道所需的擁塞窗口的大小。

爲了滿足第一個要求,我將接收端的接收緩衝區設置爲BDP的大小(可以大一些但不能小):

net.core.rmem_max = 13420500
net.ipv4.tcp_rmem = 4096 873800 13420500

同時在啓動iperf的時候,指定該BDP值作爲窗口,爲了讓接收窗口徹底不成爲限制,我將窗口指定爲15M而非剛剛好的13M(Linux將內核協議棧的skb本身的開銷也會計算在內):

iperf -s -w 15m

提示符顯示設置成功:

------------------------------------------------------------
Server listening on TCP port 5001
TCP window size: 25.6 MByte (WARNING: requested 14.3 MByte)
------------------------------------------------------------

OK,接下來看發送端,我將發送端的發送緩衝同樣設置成BDP的大小:

net.core.wmem_max = 13420500
net.ipv4.tcp_wmem = 4096 873800 13420500

現在我來測試一把,結論依然不盡如人意:

...
[  3] 38.0-39.0 sec  18.5 MBytes   155 Mbits/sec
[  3] 39.0-40.0 sec  18.0 MBytes   151 Mbits/sec
[  3] 40.0-41.0 sec  18.9 MBytes   158 Mbits/sec
[  3] 41.0-42.0 sec  18.9 MBytes   158 Mbits/sec
[  3] 42.0-43.0 sec  18.0 MBytes   151 Mbits/sec
[  3] 43.0-44.0 sec  19.1 MBytes   160 Mbits/sec
[  3] 44.0-45.0 sec  18.8 MBytes   158 Mbits/sec
[  3] 45.0-46.0 sec  19.5 MBytes   163 Mbits/sec
[  3] 46.0-47.0 sec  19.6 MBytes   165 Mbits/sec
...

現在的限制已經不是緩衝區了,現在的限制是TCP的擁塞窗口!擁塞窗口似乎很矜持,並沒有完全打開。

我對TCP Reno擁塞控制算法太熟悉了,以至於我可以肯定這個如此之低的帶寬利用率正是它AIMD行爲的結果。那麼很好辦,我繞開它便是了。

這是一個無丟包的場景,於是我可以親自手工指定一個擁塞窗口,這隻需要一個stap腳本即可完成:

#!/usr/bin/stap -g

%{
   
   
#include <linux/skbuff.h>
#include <net/tcp.h>
%}

function alter_cwnd(skk:long, skbb:long)
%{
   
   
	struct sock *sk = (struct sock *)STAP_ARG_skk;
	struct sk_buff *skb = (struct sk_buff *)STAP_ARG_skbb;
	struct tcp_sock *tp = tcp_sk(sk);
	struct iphdr *iph;
	struct tcphdr *th;

	if (skb->protocol != htons(ETH_P_IP))
		return;

	th = (struct tcphdr *)skb->data;
	if (ntohs(th->source) == 5001) {
   
   
		// 手工將擁塞窗口設置爲恆定的BDP包量。
		tp->snd_cwnd = 8947;
	}
%}

probe kernel.function("tcp_ack").return
{
   
   
	alter_cwnd($sk, $skb);
}

以下是指定擁塞窗口爲BDP後的測試結果:

...
[  3]  0.0- 1.0 sec   124 MBytes  1.04 Gbits/sec
[  3]  1.0- 2.0 sec   116 MBytes   970 Mbits/sec
[  3]  2.0- 3.0 sec   116 MBytes   977 Mbits/sec
[  3]  3.0- 4.0 sec   117 MBytes   979 Mbits/sec
[  3]  4.0- 5.0 sec   116 MBytes   976 Mbits/sec
...

嗯,差不多是把BDP填滿了。但事情並沒有結束。

現在問題又來了,我知道,TCP Reno是鋸齒狀上探,那麼應該是探到BDP時纔會丟包,進而執行乘性減窗的,其平均帶寬至少也應該是50%纔對(對於Reno,合理值應該在75%上下),爲什麼測試數據顯示帶寬利用率只有10%左右呢?

Reno在檢測到丟包時纔會執行乘性減窗的,既然擁塞窗口遠沒有達到帶寬時延所允許的最大容量,爲什麼會丟包呢?是什麼阻止Reno繼續上探窗口呢?

無論是收發端的ifconfig tx/rx統計,還是tc qdisc的統計,均無丟包,並且我仔細覈對了兩端的抓包,確實有包沒有到達接收端:
在這裏插入圖片描述
在這裏插入圖片描述

答案就是 噪聲丟包! 也就是鏈路誤碼,比特反轉,信號衰亡之類。

一般而言,當帶寬利用率達到一定百分比時,幾乎總會出現偶然的,概率性的噪聲丟包,這非常容易理解,事故總會發生,概率高低而已。這個噪聲丟包在獨享帶寬的鏈路上使用ping -f就可以測試:

# ping -f 172.16.0.2
PING 172.16.0.2 (172.16.0.2) 56(84) bytes of data.
.......^C
--- 172.16.0.2 ping statistics ---
1025 packets transmitted, 1018 received, 0.682927% packet loss, time 14333ms
rtt min/avg/max/mdev = 100.217/102.962/184.080/4.698 ms, pipe 10, ipg/ewma 13.996/104.149 ms

然而,哪怕只是一個偶然的丟包,對TCP Reno的打擊也是巨大,好不容易緩慢漲上來的窗口將立即減爲一半!這就是爲什麼在長肥管道中,TCP連接的擁塞窗口很難爬上來的原因。

OK,在我的測試環境中,我敢肯定丟包是噪聲引發的,因爲測試TCP連接完全獨享帶寬,沒有任何排隊擁塞,因此我可以屏蔽掉乘性減窗這個動作,這並不難:

#!/usr/bin/stap -g

%{
   
   
#include <linux/skbuff.h>
#include <net/tcp.h>
%}

function dump_info(skk:long, skbb:long)
%{
   
   
	struct sock *sk = (struct sock *)STAP_ARG_skk;
	struct sk_buff *skb = (struct sk_buff *)STAP_ARG_skbb;
	struct tcp_sock *tp = tcp_sk(sk);
	struct iphdr *iph;
	struct tcphdr *th;

	if (skb->protocol != htons(ETH_P_IP))
		return;

	th = (struct tcphdr *)skb->data;
	if (ntohs(th->source) == 5001) {
   
   
		int inflt = tcp_packets_in_flight(tp);
		// 這裏打印窗口和inflight值,以觀測窗口什麼時候能漲到BDP。
		STAP_PRINTF("RTT:%llu  curr cwnd:%d  curr inflight:%d \n", tp->srtt_us/8, tp->snd_cwnd, inflt);
	}
%}

probe kernel.function("tcp_reno_ssthresh").return
{
   
   
	// 這裏恢復即將要減半的窗口
	$return = $return*2
}

probe kernel.function("tcp_ack")
{
   
   
	dump_info($sk, $skb);
}

現在繼續測試,先讓上述腳本執行起來,然後跑iperf。

這次我給iperf一個足夠久的執行時間,等它慢慢將擁塞窗口漲到BDP:

iperf -c 172.16.0.2  -i 1  -P 1 -t 1500

在100ms RTT的1 Gbits/Sec長肥管道中,擁塞窗口漲到BDP這麼大將是一個漫長的過程,由於噪聲丟包的存在,慢啓動將很快結束,忽略起初佔比很小的慢啓動,按照每一個RTT窗口增加1個包估算,一秒鐘大概增窗10個,一分鐘就是600個,窗口爬升到8000多需要十幾分鍾。

去給蒸個雞蛋順便再開一罐劉致和臭豆腐,回來確認一下。

經過了大概15分鐘,擁塞窗口不再增長,穩定在了8980,這個值與我最初算出的BDP相當,下面的打印是我上面那個stap腳本的輸出:

RTT:104118  curr cwnd:8980  curr inflight:8949
RTT:105555  curr cwnd:8980  curr inflight:8828
RTT:107122  curr cwnd:8980  curr inflight:8675
RTT:108641  curr cwnd:8980  curr inflight:8528

而此時的帶寬爲:

...
[  3] 931.0-932.0 sec  121 MBytes   1.02 Gbits/sec
[  3] 932.0-933.0 sec  121 MBytes   1.02 Gbits/sec
[  3] 933.0-934.0 sec  121 MBytes   1.02 Gbits/sec
...

窗口爬升到BDP所需的時間也符合預期。

看來,只要忽略了噪聲丟包導致的丟包乘性減窗,Reno事實上是可以探滿BDP的!輸出1 Gbits/Sec是如此的穩定,恰恰說明之前的丟包確實是噪聲丟包,否則由於需要重傳排隊,帶寬早就掉下去了。

那麼,接下來的問題是,如果TCP已經探測到了最大的窗口值,它還會繼續向上探測嗎?也就是說,上面的輸出中爲什麼最終擁塞窗口穩定在8980呢?

細節是,當TCP發現即便是增加窗口,也不會帶來inflight的提升時,它就會認爲已經到頂了,就不再增加擁塞窗口了。詳見下面的代碼段:

is_cwnd_limited |= (tcp_packets_in_flight(tp) >= tp->snd_cwnd);

由於數據包守恆,當inflight的值小於擁塞窗口時,說明窗口的配額尚未用盡,不必再增窗,而inflight的最終最大值是受限於BDP的物理限制的,因此它最終會是一個穩定值,從而擁塞窗口也趨於穩定。

OK,現在你已經知道如何用TCP單流灌滿BDP了,這真的好難!

對於Reno而言,即便是我有能力忽略噪聲丟包(事實上我們區分不出丟包是噪聲引起的還是擁塞引起的),在長肥管道的大RTT環境中,每一個RTT時間才增窗1個數據包,這真是太慢了!長肥管道的“長”可以理解爲增窗時間長,而“肥”則可以理解爲距離目標最大窗口太遠,這個理解非常尷尬地解釋了TCP在長肥管道中是多麼艱難!

在Reno之後,人們想到了很多優化手段,企圖讓TCP在長肥管道中的日子好過一點,但是無論是BIC還是CUBIC,都是治標不治本,其思路和Reno幾乎是一致的。

在最後窗口爬升的漫長等待中,事實上,噪聲丟包仍然在持續進行,我只是當一隻鴕鳥掩耳盜鈴罷了,就當什麼事都沒有發生。然而,噪聲丟包似乎並沒有影響傳輸帶寬的持續攀升,結果真的就像什麼都沒有發生。

由於開啓了SACK,偶然的噪聲丟包被很快重傳,帶寬依然保持原速。 隨着擁塞窗口的緩慢增加,發包量也在緩慢增加,隨之而來的就是iperf測量傳輸速率的緩慢增加。大約等了15分鐘,BDP被填滿,背靠背的數據流和背靠背的ACK流終於首尾相接,測量帶寬達到了極限,這也完成了本文一開始的需求,即讓一個TCP流填滿一個長肥管道。

然而我很清楚,在現實環境中,首先,不允許我對協議棧採用stap HOOK的方式進行邏輯修改,比方說我不能去修改一條TCP連接的擁塞窗口,我只能無條件信任當前的擁塞控制算法,其次,即便我修改了擁塞窗口,比如我自己寫了一個新的擁塞控制模塊,其中將擁塞窗口寫成了很大的值,或者我只是簡單得屏蔽掉了乘性減窗,我會馬上認識到,這種修改在現實中會讓情況變得更糟!

我曾經就是如此不自量力。

一切似乎又回到了原點,這是一個老問題,我根本無法區分一次丟包是因爲噪聲還是因爲擁塞,如果是後者,就必須乘性減窗。

BBR繞開了這個難題。如前面實驗數據顯示,噪聲丟包並不會影響實際的傳輸帶寬和RTT,但擁塞則會影響傳輸帶寬和RTT。BBR正是通過直接測量傳輸帶寬和RTT來計算髮包速率的。我把擁塞算法切換成BBR後,再次iperf測試:

root@zhaoya:/home/zhaoya# iperf -c 172.16.0.2  -i 1  -P 1 -t 5
------------------------------------------------------------
Client connecting to 172.16.0.2, TCP port 5001
TCP window size:  853 KByte (default)
------------------------------------------------------------
[  3] local 172.16.0.1 port 41436 connected with 172.16.0.2 port 5001
[ ID] Interval       Transfer     Bandwidth
[  3]  0.0- 1.0 sec  62.4 MBytes   523 Mbits/sec
[  3]  1.0- 2.0 sec   117 MBytes   980 Mbits/sec
[  3]  2.0- 3.0 sec   128 MBytes  1.08 Gbits/sec
[  3]  3.0- 4.0 sec   129 MBytes  1.08 Gbits/sec
[  3]  4.0- 5.0 sec   133 MBytes  1.12 Gbits/sec
[  3]  0.0- 5.0 sec   570 MBytes   955 Mbits/sec

5秒的測試就能填滿整個BDP。

好吧,解決TCP在長肥管道中效率低下問題的方案似乎已經非常直接了,切換到BBR唄,但事情真的這麼簡單嗎?

除了帶寬利用率,擁塞控制還有一個重要指標,即公平性!

Reno/BIC/CUBIC的AIMD背後是成熟的控制論模型,在數學上可以證明Reno(以及其後代BIC,CUBIC)可以快速收斂到公平,然而BBR卻不能!BBR無法從數學上被證明是公平性友好的,這便是它和Reno家族擁塞控制算法的本質差距。

盜一個我之前文章裏的圖吧:
在這裏插入圖片描述

這個就是Reno家族AIMD爲什麼能收斂到公平的圖解,BBR畫不出這樣的圖。

當我們面對問題的時候,需要看看歷史,我們需要知道Reno是怎麼來的。在1980年代第一次出現大規模網絡擁塞之前,TCP是沒有擁塞控制的,之後,Reno的引入並非爲了效率,而是爲了在出現擁塞的時候快速收斂到公平。而CUBIC在嚴格收斂的基礎上用三次凸凹曲線來優化窗口探測的效率。

沒有任何擁塞控制算法是爲了提高單流吞吐而被引入的,相反,如果是爲了保證公平性,它們的效果往往是降低了單流的吞吐。如果想提高單流的吞吐,回到1986年之前就是了。

如果你是想通過超發來保持高效,那你和沒有擁塞控制那個混亂的的年代大家所期求的一致。

到此爲止,我一直沒有脫離我自己搭建的理想測試環境,在獨享帶寬的直連環境下TCP的表現尚且如此,如果是公共網絡,其中的隨機噪聲以及真正的擁塞更加難以預測,期待TCP單流吞吐爬升到一個比較高的水平更加不能指望。

那麼,如果解決公共網絡TCP在長肥管道中性能低下的問題呢?

見招拆招,把長管道切成短的唄!它基於以下的認知:

  • 管道長,意味着窗口爬升慢,丟包感知慢,丟包恢復慢。
  • 管道肥,意味着目標很遠大,結合管道長的弱點,達到目標更加不易。

SDWAN可以應對長肥管道中的這種尷尬!

有兩種方法可以拆分長肥管道:

  • TCP代理模式。可以通過多個透明代理接力將數據傳輸到遙遠的目的地。
  • TCP隧道模式。可以將大時延的TCP流封裝在小時延的TCP隧道里接力傳輸。

無論哪種方式都是將路徑進行了分割,既然處理不好長路徑,那就分別處理短路徑唄。具體可以看:
https://blog.csdn.net/dog250/article/details/83997773

爲了保持端到端的透明性,我比較傾向於使用TCP隧道來切分長肥管道。

關於TCP隧道本身的評價,這個話題我之前評論了很多:
https://blog.csdn.net/dog250/article/details/81257271
https://blog.csdn.net/dog250/article/details/106955747

爲了獲得一種既視感,接下來我來用一個實驗驗證上面的文字。

我做下面的測試拓撲:
在這裏插入圖片描述

爲了將一個模擬出來的長肥管道拆成兩段,我構建了兩條隧道,讓測試的TCP流依次經過這兩條隧道的封裝。爲了構建TCP隧道,我使用簡單現成的simpletun來完成。

這裏是我的simpletun版本:https://github.com/marywangran/simpletun
正如README裏的示例,其實沒有必要爲tun0配置IP地址的,但是給個IP地址思路更清晰,因此我的實驗中均爲tun設備指定了IP地址。下圖是實驗環境:
在這裏插入圖片描述

配置都寫在圖中了,下面是測試case:

  • case1:測試TCP直接通過S-D長肥管道的吞吐。
  • case2:測試TCP從S經由隧道T1,T2到達D的吞吐。

下面是case1的測試。

D上執行iperf -s,S上執行:

# 綁定 172.16.0.1
iperf -c 172.18.0.1 -B 172.16.0.1 -i 1  -P 1 -t 15

結果摘要如下:

...
[  3]  9.0-10.0 sec  63.6 KBytes   521 Kbits/sec
[  3] 10.0-11.0 sec  63.6 KBytes   521 Kbits/sec
[  3] 11.0-12.0 sec  17.0 KBytes   139 Kbits/sec
[  3] 12.0-13.0 sec  63.6 KBytes   521 Kbits/sec
[  3] 13.0-14.0 sec  63.6 KBytes   521 Kbits/sec
[  3] 14.0-15.0 sec  80.6 KBytes   660 Kbits/sec
[  3]  0.0-15.2 sec  1.27 MBytes   699 Kbits/sec

下面是case2的測試。

D上執行iperf -s,S上執行:

# 綁定 172.16.0.3
iperf -c 172.18.0.3 -B 172.16.0.3 -i 1  -P 1 -t 15

結果摘要如下:

...
[  3]  9.0-10.0 sec  127 KBytes  1.04 Mbits/sec
[  3] 10.0-11.0 sec  445 KBytes  3.65 Mbits/sec
[  3] 11.0-12.0 sec  127 KBytes  1.04 Mbits/sec
[  3] 12.0-13.0 sec  382 KBytes  3.13 Mbits/sec
[  3] 13.0-14.0 sec  127 KBytes  1.04 Mbits/sec
[  3] 14.0-15.0 sec  90.5 KBytes   741 Kbits/sec
[  3]  0.0-15.2 sec  2.14 MBytes  1.18 Mbits/sec

結論顯而易見,長路徑拆分成短路徑後,RTT顯然更小了,丟包感知,丟包恢復隨之變得迅速。此外一條長肥管道得以以更小的段爲單位分而治之,再也沒有必要在全局使用同一個擁塞控制策略了。

分而治之非同質化的長肥管道, 這意味着什麼?

這意味着,不同TCP隧道段根據所跨越鏈路的網絡實際情況可以採用不同的擁塞控制算法:
在這裏插入圖片描述

當我們的廣域網用這種或者類似這種Overlay網絡連接在一起的時候,我們有能力定義每一個小段的傳輸策略,這種細粒度的控制能力正是我們所需要的。

我們需要定義廣域網,我們需要SDWAN。

不知不覺,已經快八點半了,這個內卷的社會讓人與人之間的關係好陌生!好不容易到了週末,小小又去上補習班了,一年基本上一家人很少能醒着在一起。


浙江溫州皮鞋溼,下雨進水不會胖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章