从TCP拥塞本质看BBR算法及其收敛性(附CUBIC的改进/NCL机制)

Posted dog250

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从TCP拥塞本质看BBR算法及其收敛性(附CUBIC的改进/NCL机制)相关的知识,希望对你有一定的参考价值。

本文试图给出一些与BBR算法相关但却是其之外的东西。

1.TCP拥塞的本质

注意,我并没有把题目定义成网络拥塞的本质,不然又要扯泊松到达和排队论了。事实上,TCP拥塞的本质要好理解的多!TCP拥塞绝大部分是由于其”加性增,乘性减“的特性造成的!
        也就是说,是TCP自己造成了拥塞!TCP加性增乘性减的特性引发了丢包,而丢包的拥塞误判带来了巨大的代价,这在深队列+AQM情形下尤其明显。
        我尽可能快的解释。争取用一个简单的数学推导过程和一张图搞定。
        除非TCP端节点之间的网络带宽是均匀点对点的,否则就必然要存在第二类缓存。TCP并无法直接识别这种第二类缓存。正是这第二类缓存的存在导致了拥塞的代价特别严重。我依然用经典的图作为基准来解释:




第二类缓存的时间墙特征导致了排队的发生,而排队会导致一个TCP连接中数据包的RTT变大。为了讨论方便,我们假设TCP端节点之间管道最细处(即Bottleneke处)的带宽为B,那么正如上图所表明的,我把TCP端节点之间的网络中,凡是带宽比B大的网络均包含在第二类缓存中,也就是说,凡是会引起排队的路径,均是第二类缓存。

假设TCP端节点之间的BDP为C,那么:
C = C1 + C2  (其中C1是网络本身的管道容量,而C2是节点缓存的容量)
由于路径中最小带宽为B,那么整个链路的带宽将由B决定,在排队未发生时(即没有发生拥塞时),假设测量RTT为rtt0,发送速率为B0=B,则:
C1 = B0*rtt0
C = B0*rtt0 +C2 > B*rtt0

此时,任何事情均为发生,一切平安无事!继续着TCP”加性增“的行为,此时发送端继续线性增加发送速率,到达B1,此时:
B0*rtt0 < B1*rtt1
C是客观的不变量,这会导致C2开始被填充,即开始轻微排队。排队会造成RTT的增加。假设C2已经被加性增特性填充到满载的临界,此时发送带宽为B2,即:
C = B2*rtt2 = B*rtt0 + C2

B2*rtt2是定值,rtt2在增大,B2则必须减小!但是”临界值已经达到“这件事反馈到发送端,至少要经过1/2个RTT,在忽略延迟ACK和ACK丢失等反馈失灵情形下,最多的反馈时间要1个RTT。问题是,TCP发送端怎么知道C2已经被填满了??它不知道!除非再增加一些窗口,多发一个数据包!这行为是如此的小心翼翼,以至于你会认为这是多么正确的做法!在发送端不知情的情况下,会持续增加或者保持当前的拥塞窗口,但是绝对不会降低,然而此时RTT已经增大,必须降速了!事实上,在丢包事件发生前,TCP是一定会加性增窗的,也就是说,丢包是TCP唯一可以识别的事件!

        TCP在临界点的加性增窗行为,目的只是为了探测C2是不是已经被填满。我们来根据以上的推导计算一下这次探测所要付出的代价。由于反馈C2已满的时间是1/2个RTT到1个RTT,取决于C2的位置,那么将会在1/2个RTT到1个RTT的时间内面临着丢包!注意,这里的代价随着C2的增加而增加,因为C2越大,RTT的最终测量值,即rtt2则越大!这就是深队列丢包探测的问题。
        然而,在30多年前,正是这个”加性增“行为,直接导出了”基于丢包的拥塞控制算法“。那时没有深队列,问题貌似还不严重。但随着C2的增加,问题就越来越严重了,RTT的增大使得丢包处理的代价更大!
        记住,对丢包的敏感不是错误,基于丢包的拥塞探测的算法就是这样运作的,错误之处在于,丢包的代价太大-窗口猛降,造成管道被清空。这是由于深队列的BufferBloat引发的问题,在浅队列中问题并不严重。随着路由器AQM技术的发展,好的初衷会对基于丢包的拥塞探测产生反而坏的影响。

        现在,我们明白了,之所以基于丢包的拥塞控制算法的带宽利用率低,就是由于其填充第二类缓存所平添排队延迟造成的虚假且逐渐增大的RTT最终导致了BDP很大的假象,而这一切的目的,却仅仅是为了探测丢包,自以为在丢包前已经100%的利用了带宽,然而在丢包后,所有的一切都加倍还了回去!是丢包导致了带宽利用率的下降,而不是增加!!

        总结一句, 用第二类缓存来探测BDP是一种透支资源的行为。
        我一直觉得这不是TCP的错,但在发现BBR是如此简单之后,不再这么认为了,事实上,通过探测时间窗口内的最大带宽和最小RTT,就可以明确知道是不是已经填满了第一类缓存,并停止继续填充第二类缓存,即向最小化排队的方向收敛!曾经的基于时延的算法,比如Vegas,其实已经在走这条路了,它已经知道RTT的增加意味着排队了,只是它没有采用时间窗口过滤掉常规波动,而是采用了RTT增量窗口来过滤波动,最终甚至由于RTT抖动主动减少窗口,所以会造成竞争性不足。不管怎样,这是一种君子行为,它总是无力对抗基于丢包算法的流氓行为。
        BBR综合了二者,对待君子则君子(不会填充第二类缓存,造成排队,因为一旦排队,所有连接的RTT均会增加,对类似Vegas的不利),对待流氓则流氓(采用滑动时间窗口抗带宽噪声,采用固定超时时间窗口抗RTT噪声,时间窗口内,决不降速),这是一种什么行为?我觉得比较类似警察的行为...

        如果不是很理解,那么看看那些高速公路上随意变道或者占用应急车道的行为导致的后果吧(大多数没有什么后果,原因在于监管的不力,这就好像CUBIC遇到了Vegas一样!)。基于此,即便不使用BBR算法,最好也不要使用基于时延的Vegas等算法,但是也许,我们可以更好的改进CUBIC,我们也许已经知道了如何去更改CUBIC了。CUBIC的问题不是其算法本身导致的,而是TCP拥塞控制的框架导致的。见本文”CUBIC更改前奏-实现NCL(非拥塞丢包)“小节。

        本节的最后,我们来看点关于第二类缓存的特性。
        第二类缓存既然不是用来进行”BDP探测“(事实上,BDP的组成里根本就该有第二类缓存)的,那要它干什么??
        我想这里可以简单解释一下了。第二类缓存的作用是为了适配统计复用的分组交换网络上路由器处理不过来这个问题而引入的。如果没有路由器交换机节点的存在,那么第二类缓存这里什么也没有:




如果你想最快速度理解上图中泊松到达这个点的入口行为和固定速率发出的出口行为,请考虑丁字路由或十字路口,和路由器一样,只有在交叉点的位置才需要第二类缓存来平滑多方瞬时速率的不匹配特征!我以丁字路口为例:




不管哪里为应对瞬时到达率而加入的”缓存“,都是第二类缓存,这类缓存的目的是临时缓存瞬时到达过快的数据或者车流,这就是统计复用的分组交换网节点缓存的本质!然而一旦这些缓存被误用了,拥塞就一定会发生!误用行为很多,比如UDP毫无节制的发包,比如TCP依靠填满它而发现拥塞,讽刺的是,很大程度上,拥塞是TCP自己造成的,要想发现拥塞,就必须要先制造拥塞。
本节完!

2.突发特征与pacing

这里仅仅提一点,那就是突发最容易造成排队!这也是可以从泊松到达的排队论中推导出来的。为了不被人认为我在这里装逼,就不展示过程了,需要的请私下联系我。
        解决突发问题的方法有两种,一种就是边缘网络路由器上设置整形规则,这有效避免了汇聚层以及核心层路由器的排队。另外一种更加有效的方法就是直接在端主机做Pacing。Linux在3.12内核以后已经支持了FQ这个sched模块,它基于TCP连接发现的Pacing Rate来发送数据,取代了之前一窗数据突发出去的弊端。
        Pacing背后的思想就是尽量减少网络交换节点处队列的排队!通过上一节的最后,我们知道,交换节点出口的速率恒定,而入口可能会面临突发,虽然在统计意义上,出入口的处理能力匹配即可,然而即便大多数时候到达速率都小于出口速率,只要有一瞬间的突发就可能冲击队列到爆满!事实上队列缓存存在的理由就是为了应对这种情况!
传统意义上,TCP拥塞控制逻辑仅仅计算一个拥塞窗口,TCP发送按照这个拥塞窗口发送适当大小的数据,但这些数据几乎是一次性突发出去的,Linux 3.9之后的patch出现了TCP Pacing rate的概念,可以将一窗数据按照一定的速率平滑发送出去,然而TCP本身并没有实现实际的Pacing发送逻辑,Linux 3.12内核实现了FQ这个schedule,TCP可以依靠这个schedule来实现Pacing了。
        为什么不在TCP层实现这个Pacing,原因在于TCP层并不能控制严格的发送时序,它是属于软件层的。Pacing必须在数据包被发送到链路之前进行才比较有效,因为这时的Pacing是真实的!切记,Pacing目前可以通过TC来配置,要想Pacing起作用,在其之后就不能再有别的队列,否则,FQ的Pacing Rate就可能会被后面的队列给冲掉!
...
我们继续谈BBR算法的收敛特性。

3.BBR算法的收敛性

BBR算法的收敛性与之前基于加性增乘性减的算法的收敛性完全不同,比之前的更加优美!欲知如何,我先展示加性增乘性减的收敛图:



以下是根据上图总结出来的一幅抽象图:





这个图之前贴过,这个图来自于控制论的理论,每个连接是独立地向最终的收敛点去收敛,大家彼此不交互,只要都奔着平衡收敛点走就行。
        当我们认识BBR收敛性的时候,我们要换一种思路。即BBR收敛过程并不是独立的,它们是配合的,BBR算法根本就没有定义收敛点,只是大家互相配合,满足其带宽之和不超过第一类缓存的大小,即真正BDP的大小,在这个约束条件下,BBR最终自己找到了一个稳定的平衡点。
        在展示图解之前,为了简单起见,我们先假设BBR在PROBE_BW状态,讨论在该状态的收敛过程。我们先看一下PROBE_BW状态的增益系数数组:
static const int bbr_pacing_gain[] = {
    // 占据带宽,在带宽满之前,一直运行。效率优先,尽可能处在这里久一些...
    BBR_UNIT * 5 / 4,    /* probe for more available bw */  
    // 出让带宽,只要带宽不满了,则进入稳定状态平稳运行。兼顾公平,尽可能离开这里...
    BBR_UNIT * 3 / 4,    /* drain queue and/or yield bw to other flows */
    // 一方面平稳运行,一方面等待出让的带宽不被自己重新抢占!
    BBR_UNIT, BBR_UNIT, BBR_UNIT,    /* cruise at 1.0*bw to utilize pipe, */
    BBR_UNIT, BBR_UNIT, BBR_UNIT    /* without creating excess queue... */
};
仔细看这个数组,就会发现,bbr_pacing_gain[0],bbr_pacing_gain[1]以及后面的元素安排非常巧妙!bbr_pacing_gain[0]表明,BBR有机会获取更多的带宽,而bbr_pacing_gain[1]则表明,在获取了足够的带宽后,在需要的情况下要出让部分带宽,然后在出让了部分带宽后,循环6个周期,等待其它连接获取出让的带宽。那么,BBR如何安排以上三类增益系数的使能周期长度呢?
        很显然,BBR希望连接尽可能多的使用带宽,因此bbr_pacing_gain[0]的使能时间尽可能久些,其退出条件是:
已经运行超过了一个最小RTT时间并且要么发生了丢包,要么本次ACK到来前的inflight的值已经等于窗口值了。
虽然BBR希望一个连接尽可能占用带宽,但是BBR的原则是不能排队或者起码减少排队,当另一个连接发起时,额外的带宽占用会让处在正增益的连接inflight发生满载,因此bbr_pacing_gain[0]会让位给bbr_pacing_gain[1],进而出让带宽给新连接,随后进入长达6个RTT周期的平稳时期,等待出让的带宽被利用。总之,总结一点就是:
如果没有其它连接,一个连接会一直试图占满所有带宽,一旦有新连接,则老连接尽量一次性或者很短时间内出让部分带宽,然后在这些带宽被利用之前,老连接不再抢带宽,如果超过6个RTT周期之后,老连接重新开始新一轮抢占,出让,等待被利用的过程,从而和其它的连接一起收敛到平衡点。
        因此,和加性增乘性减的独立收敛方案不同,BBR一开始就是考虑到对方存在的收敛方案。我们看一个简单的例子,描述一下大致的收敛思想:
初始状态
连接1:10 & 连接2:0
1>.连接1在一个RTT出让1/4带宽,稳定6个RTT,带宽为7.5 & 连接2以4个RTT为一个PROBE周期分别的带宽为:1.25, 1.55,1.95,2.4
2>.连接1在bbr_pacing_gain[0]占据带宽失败,继续出让带宽,稳定在5.6 & 连接2以3个RTT为一个PROBE周期分别的带宽为3.0,3.75,4.6
3>.完成收敛。

最后,我们可以看一下BBR的收敛图了:




根据Google的测试,其收敛效果如下图: