Linux TCP在3.18内核引入的一个慢启动相关的问题或者说Bug

版权声明:本文为博主原创,无版权,未经博主允许可以随意转载,无需注明出处,随意修改或保持可作为原创! https://blog.csdn.net/dog250/article/details/83035316

又到了周末,本周把国庆假期遗留的一个问题进行一个总结。我把形而上的讨论放在本文的最后,这里将快速进入正题,只说一句,浙江温州皮鞋湿!

我们先来看一个标准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 ackedhttps://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的处理

  1. 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 controlhttps://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了,然而这并不是一件好事。

怎么办?穿上皮鞋吧。


皮鞋进水不会胖,旋转座椅会爆炸。
这是一篇没有喝真露而写好的文章。

猜你喜欢

转载自blog.csdn.net/dog250/article/details/83035316