一年多没有深夜惊起而作文了,又逢雨夜,总结一些思路。
带宽一定的情况下,网络的吞吐理论上不受时延的影响,虽然管道长了一点,但截面积是一定的。
然而当你用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流填满一个长肥管道看起来只需要满足三个条件即可:
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
#include
%}
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
#include
%}
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几乎是一致的。
在最后窗口爬升的漫长等待中,事实上,噪声丢包仍然在持续进行,我只是当一只鸵鸟掩耳盗铃罢了,就当什么事都没有发生。然而,噪声丢包似乎并没有影响传输带宽的持续攀升,结果真的就像什么都没有发生。
当我手工指定了窗口为BDP或者当我屏蔽了窗口减半效应的前提下,由于开启了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可以应对长肥管道中的这种尴尬!
有两种方法可以拆分长肥管道:
无论哪种方式都是将路径进行了分割,既然处理不好长路径,那就分别处理短路径呗。具体可以看:
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的测试。
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。
不知不觉,已经快八点半了,这个内卷的社会让人与人之间的关系好陌生!好不容易到了周末,小小又去上补习班了,一年基本上一家人很少能醒着在一起。
浙江温州皮鞋湿,下雨进水不会胖。