本文主要阐述TCP拥塞控制中ssthresh的来历以及为什么拥塞避免探测到丢包的时候,ssthresh会被设置为当前窗口的一半。
进入证实内容之前,不得不再次吐槽!目前在网上搜的,任何资料上看的,甚至RFC上,都没有讲明白到底什么是ssthresh,它的值有什么讲究,几乎所有的资料都是在说,如果窗口大于ssthresh,那么就执行线性增窗的拥塞避免阶段,否则执行慢启动...这让几乎所有人记住了这个结论,并且在长期被洗涤之后,很多人对这个不知所以然的事实却表现的不以为然,其实也包括我自己。因此当我明白了ssthresh到底是怎么一回事的时候,当我知道了丢包后ssthresh的1/2系数与公平性之间的关系的时候,我便迫不及待地想把这些东西分享出来!
TCP数据段填满端节点之间的网络
假设端系统A和B之间在进行TCP通信,那么只要A和B之间存在空间距离,由于光速的传播时延,一定意味着A和B之间存在一定的容量,可以容纳若干的数据段,你可以将其想做是一般的缓存,另外,为了更具普遍性,我们认为A和B之间的所有路由器,交换机等中间节点的队列缓存,也包含在A和B的网络缓存之中。如下图所示:
为了达到最高的网络利用率,我们希望A和B之间的缓存(包括节点队列以及网络本身)中完全充盈着TCP的数据段,并且是持续维持。
TCP数据段无间隙地持续流动
如上图所示,当A,B之间的网络被填满时,A和B之间一共有N个数据段,发送端还可以继续发送数据吗?事实上是可以的,因为在网络被填满之后,发送端每发送一个数据段,接收端同时也会消费掉一个数据段,同时发出一个ACK,直到填满A,B间网络的那个数据段的ACK到达发送端为止,依照假定,ACK的速率和数据段的速率一致,我们算一下,发送端一共可以持续发送2*N个数据段,这2*N个数据段发送的开始时间点是第1个数据段发送的时间,结束时间点是第一个数据段的ACK回到发送端的时间,正好是一个RTT,设发送速率为r,那么以下的等式显而易见:
2*N = r*RTT
过程如下图所示:
我们还可以看到,前N个段是为了填满A,B之间的网络,后N个段是在“A,B之间已经满载的情况下”TCP的ACK clock驱动的pacing。紧随着这2*N个数据段的是一个新的周期,又是一个RTT内2*N个数据段,这就是理想情况下的情景,数据段充盈着网络,不间断地源源不断从发送端发出,ACK亦不间断地从接收端返回。
两个区域(safe & dangerous)
我其实不想现在就把谜底揭穿,但是我也不想卖关子,毕竟现在也不早了。
请注意上图中的t28这个时间点,在t28之前,A和B之间并不总是被充满,而在t28之后,却总是满的。这意味着,t28之前的数据段是可以缓冲的,即网络中还存在一些空闲的空间可以提供给数据进行缓冲,从而不至于数据被丢弃,然而在t28之后,网络满载了,我们看到没有丝毫的空白区域可供数据包缓冲,这意味一旦发生拥塞,数据包必然丢失!
显而易见,t28之前是安全的,而t28之后则是危险的,这就是safe area和dangerous area的由来!划分二者的是什么?正是网络的容量!在上述图示的例子中,就是4!当Flight的数据段小于4的时候,意味着可以激进的传输,当Flight数据一旦越过4,就必须保守传输了!
何谓激进?何谓保守?激进就是可以让窗口最快的速度增加到safe和dangerous的边界,由于TCP由ACK来驱动,只要收到一个ACK,就意味着通道是畅通的,窗口就可以递增一个MSS,而所谓的保守就是,必须等待当前窗口的数据全部都被ACK了之后,说明刚才发送的数据是可以到达对端的,此时才能将窗口增加一个MSS。现在来总结以下两个区域的增窗方式:
safe区域:
dangerous区域:
我们来比对一下窗口在这两个区域时的特性,如果说安全区域的目标是填满网络的话,那么在安全区域我们已知的是网络并未被填满,此时只要有ACK到来就可以增窗,直到网络被填满,现在我们来看危险区域,此时我们已知的事实是网络已经满了,我们希望让它继续满下去,能满则保持,提高网络的利用率,我们知道TCP的发送窗口(即可以发送多少数据)是ACK驱动的,ACK就像时钟一样,我们希望数据可以持续发送以保持网络满载,就要保证ACK源源不断地到来,因此在这个阶段继续发送数据的目标仅仅是为了消除ACK时钟的空白期,或者称作停摆期,因为只有这样,源源不断的ACK才能驱动数据源源不断地被发送。
回过头来再看一下增窗过程图的t20这个时间点,网络被4,5,6,7这四个数据包已经充满,但是在下一个时刻t21,随着4被接收,由于没有到达的ACK,网络被清空了1个MSS,理论上,我们知道最终的窗口肯定就是2*N,此例中,N就是4,在例子中,最终也确实窗口增加到了8,然而在现实中,拥塞随时都会发生,也就是说在窗口从4增加到8的期间,随时会发生丢包,这也就意味着窗口可能永远都增加不到8!那么我们怎么可以知道当前的窗口是合理的,然后尝试继续增加窗口呢?答案就是前一个窗口的数据全部被确认!这就是拥塞避免的由来,这个过程很慢,并不是设计的问题,而是它必须这么慢。拥塞避免这个名字非常好,确实在避免!
理解了上面的描述,我们可以给出TCP管道的概念了,然后所有的真相就大白了。
TCP管道的概念
事实上,TCP管道包含两个部分,按照公式2*N=r*RTT,2*N便是管道的容量,这两部分的第一部分是网络被填满之前的容量,只要知道尚未被填满,尽情地填,使用慢启动足矣,第二部分是网络被填满之后的容量,完全按照ACK来驱动,采用拥塞避免方式探测窗口,理想情况下,理论上,这两部分的容量是相等的。因此,我们可以就可以知道事情的真相了。
问题1:N是什么?
答:N就是ssthresh!
问题2:为什么探测到拥塞后,ssthresh要下降为当前窗口的一半?
答:探测到拥塞说明管道的容量为当前窗口C,而C=2*N,因此N=(1/2)*C!
问题3:为什么在拥塞避免阶段要加性增?即AI
答:只有当一窗的数据被确认,才可以确保之前的窗口是有效的,毕竟网络已经塞满,而ACK有可能不对称返回,拥塞随时可能发生。
问题4:为什么乘性减?
答:见问题2.
问题5:慢启动阶段为什么可以指数级增窗?
答:因为此时可以确保窗口小于N,即网络还没有塞满,即便发生拥塞,也还有富余的缓存可用。
问题6:还有问题吗?
答:如果一切都如上那般美好,当然就没有问题了,问题是,ssthresh从来就没有估计准确过。
回到现实中的TCP
TCP在设计之处的愿景十分美好,然而现实世界并不是一个友好的世界,不过,TCP本身就是自适应的,它并没有规定ssthresh的值的大小,甚至都没有建议,它完全靠在TCP自行发现丢包或者拥塞后,以当前窗口的一半作为ssthresh的当前值,随着连接的继续,ssthresh也会动态调整,因为不管现实多么残酷,理想中的反馈系统总归是一个万物收敛的目标,那就是实际的带宽总是趋向于ssthresh的2倍的大小,令人惊讶的是,ssthresh就是依靠拥塞避免算法计算出来的。当然,随着TCP的发展,这个C=2*N=r*RTT经典公式也历经了诸多的变化形成了各种变体,比如cubic就不再以2作为ssthresh的系数来计算管道容量。
慢启动的hystatr优化
我们知道,ssthresh的设置是以丢包作为反馈信号的,现在问题是,连接刚刚建立的时候,没有丢包作为反馈信号的时候,如何来设置ssthresh?
一般而言,默认的实现都是将其设置为一个巨大的值,然后最快的速度历经一次丢包,然后设置ssthresh为丢包时窗口的一半,然后像ssthresh的2倍缓慢逼近。但是这会带来问题,由于没有ssthresh作为阈值限制,用丢包作为代价,太高昂。因此在慢启动过程中如果可以探测到ssthresh的值,那就可以随时退出慢启动状态了。那么我们如何来探测呢?
还是C=2*N=r*RTT这个公式,关键看看我们怎么使用它。由于我们只是探测网络被塞满时的情况,即N的值,因此:
N=r*(RTT/2)
我们看看r是什么?所谓速率其实就是一定的事务量除以做这些事务的时间,如果说我们发出去了N‘个数据包,一共用了时间段T,那么:
r=N‘/T
代入后得到:
N=(N‘/T)*(RTT/2)
理想情况下,在毕竟网络容量的时候,N=N‘,那么就可以很简单得到T等于RTT/2的时候,就说明达到了ssthresh,该退出慢启动了!
那么如何来实现它呢?由于我们无法单独探测N个数据段到达接收端并计时,我们可以变相等价使用ACK来计算,以一个窗口的第一个数据段作为计时开始Tstart,每收到一个ACK即更新以下数值:
RTTmin:采样周期内最小的RTT,以最大限度地表示A和B之间的理想往返时延。
Tcurr:当前时间
如果下列条件成立,则可以退出慢启动了:
Tcurr - Tstart >= RTTmin/2
非常简单易懂。然而现实并不是理想的,大多数情况下,以上的算法并没有带来比较好的效果,为什么呢?因为整个带宽不是一个TCP连接独享的,而是全世界的所有TCP连接甚至包括UDP共享的,因此以上的公式基本上无法表示任何真实的情况,所以实际当中,更倾向于使用RTT来预估网络已经被塞满。使用RTT来估算网络容量ssthresh更加实际一些,因为它充分考虑了拥塞时的排队延时,因此在该方法下,退出慢启动的条件便成了:
Tcurr_rtt > RTTmin + fixed_value
以上旨在解决首次慢启动在还没有ssthresh值的时候预测ssthresh的方式,其实在此后的任何时候,只要是慢启动,都可以用以上的算法来预测当前的ssthresh,而不是说必须要用拥塞算法给出的ssthresh或者说仅仅是1/2丢包窗口(虽然你已经看到,这个1/2是多么地合理!)
ssthresh的快速穿越问题
我们知道,慢启动的时候,增窗速度非常快(基本就是根据ACK的反馈,将数据段翻倍突突出去的),那么在窗口增加到接近到仍然小于ssthresh的时候,会出现如下图所示的情况:
然而这在实现中是不易发生的,是什么限制住它的发生呢?以下几点:
1.TCP的延迟确认机制最多只能延迟2个MSS
慢启动增窗,收到一个ACK递增1个MSS,即便在使用ABC的时候,也就是说窗口最多只能超越ssthresh 2个MSS,这是由下述代码保证的:
if (sysctl_tcp_abc > 1 && tp->bytes_acked >= 2*tp->mss_cache) cnt <<= 1;
2.即便发生了ACK大量丢失,TCP的默认实现也是数ACK的个数,而不是数被ACK的字节数
3.发生大量ACK丢失又启用ABC时,见方法1.
4.两段处理方式
Linux的4.x版本内核中默认使用ACK的字节数来计数增窗值(ABC方案),在穿越ssthresh的时候,TCP拥塞控制逻辑会将被ACK的字节数分为两个部分,ssthresh以下的部分用来计数慢启动,而ssthresh以上的部分用来计数拥塞避免。综上所述,下图总结了ssthresh穿越的情况:
AIMD的公平性收敛
为了简单起见,我们假设有TCP1和TCP2两个连接共享一个链路,现在看它们是怎么“收敛到公平”的,下面的图示清晰显示了一切:
如果你看不懂这个图,请自行google。我们可以肯定,在公平线的下方,红色的减窗线的斜率是恒小于公平线(斜45度角)的斜率的,两个链接的每一次降窗,其降窗线的斜率都会越来越接近公平线的斜率,即收敛到公平,最终,它将在绿色粗线上震荡,永葆公平(虽然利用率不是那么高!)。
我们还可以看到,TCP1和TCP2是等比例降窗的,在此例中比例是0.5,它们非得是0.5吗?非也!只要保持等比例,图中的注解1就永远成立,最终的收敛也会永远成立,不同的仅仅是最终收敛额绿色粗线的长度和范围!虽然说按照最初的Reno TCP,保持0.5的降窗比例是多么得合理(见上述推论),然而考虑到现实的复杂情况,比例不再是0.5也是合理的。
现在我们来看看如果TCP1和TCP2的降窗比例不同会怎样。假设TCP2降窗依然为0.5的比例,而TCP2则小于0.5,那么上图将会变成下面的样子:
我们可以看到,竞争者中降窗比率最小的将会最终抢占几乎所有的带宽,它会将所有的其它连接的带宽逐渐往左上角挤兑,最终归零。这么说来,如果想让自己的TCP具有侵略性,减少降窗比率是不是就可以了呢?没这么这简单!要知道,我上面的两幅图有一个共同的前提,那就是竞争者的RTT是相等的!但是现实中,会这样吗??非常难!如果RTT相等,比如它们的源头和目标都在同一个地点,那么它们十有八九是合作关系,而不是竞争!爆炸!
那么,RTT将会是一个十分重要的角色!确实是这样,实际的TCP在运行中,RTT的波动非常大,这就几乎将我上面的论述全部推翻了,显然很令人心碎!然而,上述的分析作为一个理论模型还是有意义的,它起码让你理解了TCP的本质行为。至于说实际情况,RTT的波动是一个有意义的信号,它让端系统看到了中间路由器交换机的排队行为,因此会出现RTT所谓的“噪点”,很多人想除掉它们,平滑掉它们,但是这同时也意味着你屏蔽了重要的信号。
RTT的波动非常具有动感且性感,它用数值表征了整个排队理论,或者你可以推出马尔科夫到达过程,或者你只是觉得它们是令人难过的噪点...于是,现实中的TCP几乎完全改进了Reno的指导,除了Reno几乎没有什么拥塞算法在发现丢包时把ssthresh降为当前窗口的一半。这就是TCP的进化,但是这种进化始终围绕着一个内核,这个内核就是我上面说的这些,简单,易懂,然而却令人惊讶的东西。