首页 > 代码库 > QVegas-一个升级版的TCP Vegas拥塞算法

QVegas-一个升级版的TCP Vegas拥塞算法

拥塞避免带来了很多疑惑,本文解开这个疑惑并给出一个实实在在但却很简陋的算法。
        其实在基于丢包的拥塞算法中,拥塞避免的过程总是伴随着AI和MD的,不能光说AI而忽略MD。
        如果考虑的是基于时延的拥塞算法,AI和MD事实上是不需要的,因为算法会根据时延的变化来调整窗口。
        所以说,AIMD是基于丢包的算法中采取的窗口调整方法,其中AI保证了带宽利用率,而MD则保证了公平性。对于基于时延的算法而言,带宽的利用率和公平性保证均分散性体现在算法的执行过程中了,其中:
时延增加:说明路由器交换机开始排队了,此时降窗,保证了公平性
时延降低:说明路由器交换机的排队正在缓解,可以适当增窗,保证带宽满负荷
出现丢包:重传丢包,不调整窗口,因为算法过程完全接管窗口调整,丢包与窗口无关

看起来,这绝对是一个非常完美的“拥塞避免”方案,但是却有个前提,那就是路由器交换机排队是由自己或者同样执行基于时延算法的流导致的,因为每一个流都会预期,只要某一个连接造成了出现排队,那么大家都会排队,排队时延会增加RTT,按照算法,大家就会降窗缓解。如果说排队是一个CUBIC流造成的,那么CUBIC在完全占满缓存之前是不会降窗的(忽略AQM,假设是尾部丢包),而在队列越来越长的过程中,基于时延的算法会持续降窗退让,这里的问题和BBR遇到深队列的问题一样。
        TCP Vegas就是这样的一种基于时延的算法,它的思想是非常正确的,有人说如果玩得好,Vegas的效率要高于BBR的,因为BBR是主动使用最小RTT,旨在不排队,而Vegas则不关注具体的RTT,只是关注RTT的变化率,结果就是Vegas不能避免排队,但是它会把队列深度限制在一个范围内。我确信这一点,所以我对其做了改动。
先说一个我对Vegas的疑惑吧。
        为什么在Vegas的cong_avoid回调函数tcp_vegas_cong_avoid中会有reno的AI调用:
if (!vegas->doing_vegas_now) {
    tcp_reno_cong_avoid(sk, ack, acked);
    return;
}
if (after(ack, vegas->beg_snd_nxt)) {
    ...
    if (vegas->cntRTT <= 2) {
        tcp_reno_cong_avoid(sk, ack, acked);
    } else {
        ...
    }
}
就是说在一定程度上不满足Vegas的调用条件时,仍然要回退到Reno的AI过程,而不是仅仅的返回处理。我起初觉得这是实现的问题,这种实现并不纯粹,更类似于是一种混合的Hybrid算法,而不是完全的Vegas算法,然而事情并非如此简单。
        大家都知道Vegas面对Reno,CUBIC时会吃亏,原因上面已经说过了,但是有没有办法缓解一下呢?简单的解决之道就是将Reno算法插入Vegas其中,也就是当前Linux对Vegas的实现。可是问题在于,当我实测Vegas的时候,发现其对带宽的利用率极低,我想知道为什么。所以我打出了一幅时间-窗口图,发现了令人遗憾的锯齿!为什么会有锯齿呢?基于时延的算法难道不该是波浪形曲线吗?
        问题出在,在Vegas面对丢包时,窗口会下降1或者2个MSS,这意味着当丢包恢复后,窗口会从一个低值重新开涨,这在浅队列情况下尤其严重。正确的做法应该是,从丢包时Vegas计算出来的窗口值开始涨。不幸的是,Linux实现的Vegas并没有区分对待一个窗口的哪一部分是Vegas算法过程增长的,哪部分是间隙中Reno算法增长的。我要改的很简单,就是区分两者,然后在丢包时区分对待两者。
        主要修改了三部分:
1.增加了reno_inc计数器,表明在连续两次异常状态间一共靠Reno算法涨了多少窗口;
2.增加last_cwnd,记录“上一次”Vegas结束时cwnd的大小,在当次进入Vegas逻辑的时候,恢复之,注意,可能包含了Reno的增窗量;
3.在ssthresh回调中,将last_cwnd减去响应的Reno的MD降窗量作为新的last_cwnd,这意味着出现丢包时把Reno给予的还了回去。

我来解释一下为什么Vegas不必Care丢包的原因,因为Vegas的算法是正确的,它真正做到了拥塞避免,所以不会因为拥塞而丢包,真的由于别的流填满了缓存导致了丢包,也不是Vegas的全责,所以丢包肯定不是由于Vegas自己造成的拥塞导致的。在TCP拥塞控制算法中,谁的责任谁弥补一定要落到实处。注意,这不是我不减窗的理由,这是我的权利。
        我老婆去年11月初回上海,我家的车在地库停了10来天,她回来时发现车子右前方被撞了,调取监控发现了那个肇事逃逸的傻逼并找到了他,对方无奈赔偿了损失,请注意,这个损失不光包含车辆损坏的损失,还包含这段修车的时间不能开车的时间损失,责任方一定要理清,我无辜受害者躺枪了干嘛还要降窗?!经过修改之后,时间-窗口曲线变成了以下的豁口形状:


技术分享


我现在的想法是,其实,在Linux的Vegas实现中,Vegas在ssthresh中为Reno背了黑锅,Vegas的拥塞避免根本就不需要罪与罚的循环,即不需要AI(罪)和MD(罚),因为Vegas的算法根本就不会犯罪,它的收敛分布在整个算法执行的过程中了。
        Vegas的排队问题介于理想的不排队和排队排到满之间。
        Vegas性能差并不仅仅是因为其受到了CUBIC之流的欺负,更多的是因为它采取了基于丢包算法的那一套罪与罚的框架,对于Vegas而言,如果发生丢包,窗口也是要减的,然而这是没有必要的,Vegas自己已经在探测到RTT变大时主动减法降窗了,并且不止一次...在这里贴代码是不合时宜的,具体的代码请参见github:https://github.com/marywangran/qvegas 我就在这里贴一个代码细节,那就是Vegas借了别人的好处,记得还:
1.avoid过程:
if (!vegas->doing_vegas_now) {
    u32 cwnd = tp->snd_cwnd;
    tcp_reno_cong_avoid(sk, ack, acked);
    qvegas->reno_inc += tp->snd_cwnd - cwnd;
    return;
}
if (after(ack, vegas->beg_snd_nxt)) {
    ...
    if (vegas->cntRTT <= 2) {
        u32 cwnd = tp->snd_cwnd;
        tcp_reno_cong_avoid(sk, ack, acked);
        qvegas->reno_inc += tp->snd_cwnd - cwnd;
    } else {
        tp->snd_cwnd = qvegas->lost_cwnd;
        ...
        qvegas->lost_cwnd = tp->snd_cwnd;
    }
} 
2.ssthresh过程:
static inline u32 tcp_qvegas_ssthresh(struct tcp_sock *tp)
{
    struct sock *sk = (struct sock *)tp;
    struct qvegas *qvegas = inet_csk_ca(sk);

    if (tp->lost_out) // 只有在丢包的时候,返还Reno的增量,且按照AIMD的方式返还
        qvegas->lost_cwnd = max(tp->snd_cwnd - qvegas->reno_inc>>1U, 2U);
    return  max(min(tp->snd_ssthresh, tp->snd_cwnd-1), 2U);
}
修改之后,通过你对网络的认识,调整alpha,beta,gamma到一个相对大的区间,在不丢包的前提下从容应对深队列RTT的增加。但是前提是你对网络有一个比较深刻的认识,但我认为大部分人是没有这个能力的。你想啊,一帮大学毕业就到格子间编程的,怎么可能知道网络是怎么运作的。我是一个实实在在的工程派,如果有机会的话,看核心网路由交换的统计数据以及日志是不错的选择,不然无论如何讽刺TCP相关的一切都不为过。
        我不得不恶心一把马可.波罗这个傻逼,他把道听途说的东西很不真实的传递到了西方,说什么杭州的房子都是金子造的...结果人家西方人到了中国,除了发现一群群贫困但勤勤恳恳的农民自建的茅草屋之外,别无太大的惊喜,说什么白银外流,贸易逆差,那是西方工业革命以后的事了...说白了,TCP的ACK就是马可.波罗,就是一个又瞎又傻还喜欢造谣的傻逼,敢问如果你被它蛊惑了的话,你的一辈子能幸福吗?你怀着一种憧憬扑向一堆根本就不存在的东西,你看到了海市蜃楼但你认为它就是终点,一次又一次被骗仍然不改。
....
连续看了《大秦帝国》的裂变,纵横,崛起,我觉得,我能欣赏的人物中,包括所有的秦王,以及商鞅和李斯,其它的国君将相都只不过在完成KPI而已!总结一句的话,那就是苏秦之流完成KPI,而赳赳老秦奋六世之余烈憋大招。至于是什么大招,就不用我说了。
....

从源头抑制Bufferbloat

关于Bufferbloat,将问题扼杀在源头是一个比错的想法,让本地的Buffer限制数据包的一次性突发,总比数据包突发到中间路由器交换机缓存要好得多。那么怎么办呢?很简单,把自己网卡的发送队列设置得小一点哦,这个可以通过以下的命令简单设置:
ifconfig eth2 txqueuelen 16
当然,你也可以用tc设置更加复杂的策略。

再谈Hybrid算法

在此,我得说一下微软的Compound TCP拥塞控制算法,简称CTCP。
        微软的CTCP算法是与我们常见的那些诸如Reno,CUBIC,Vegas等截然不同的算法,因为你很难说出“它是基于什么”来控制窗口的,所以说它是一种启发式的混合算法更加合适,正如其名字所表达的那样。
        其实微软的这个算法跟我的这个算法思路非常像,不同点在于:

1.CTCP:以基于丢包的(比如Reno)窗口为主,辅助以时延(比如Vegas)窗口发现带宽被用尽

CTCP的目标是在长肥管道的时候,以公平性以及不主动添堵为前提快速逼近可用的带宽,一旦发现有排队,将会退回到基于丢包的AIMD过程。在发现排队之前,则可以使用基于时延的算法计算的窗口叠加到基于丢包的窗口之上,快速涨窗。
        这个算法的目标和Scalable TCP以及High speed TCP算法的目标是非常一致的,但是在效果上据说会更好,我并没有实际进行过测试,所以不敢有更进一步的结论。仅仅从算法的逻辑上看,CTCP是比较合理的,它减少了带宽的浪费,反正只要还有带宽是空闲的,CTCP就会快速去占用,一旦发现没有带宽空闲了,就会退到AIMD来和其它的连接公平共享所有的带宽。
        TCP最关键的一点,收敛,CTCP做到了。CTCP的Paper在:https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-2005-86.pdf

2.QVegas:以时延窗口为主,辅助以基于丢包的窗口增加抢占性

QVegas的目标在于可以公平地和其它的算法共存,既然其它算法欺负Vegas,那么Vegas何不自己来呢。所以就融合了Reno。但是公平性并没有被破坏,QVegas最终在丢包的时候,不是把Reno的增量通过乘性减窗的方式还回去了么?
        不管是CTCP还是我的QVegas,都属于Hybrid算法,它们的总的窗口值都有两个分量组成,一个为主一个为辅,二者的不同点在于其出发点完全相反。所以说,看到CTCP的Paper中下面一段话,就不足为奇了:
... it should also react to packet losses. It is because packet losses may still be an indicator of heavy congestion,  
and hence reducing sending rate upon packet loss is a necessary conservative behavior to avoid congestion collapse.

我一开始是奇怪的,我在想既然使用了时延计算了窗口,为什么还要激烈地乘性减窗呢?不过我瞬间就想到了,我是站在自己的QVegas的立场上来评估CTCP算法的,对于CTCP而言,情况跟我的QVegas完全相反,CTCP就是一个基于丢包的算法,所以它发现了丢包,还是要MD的,而我的QVegas算法则是一个基于时延的算法,所以在发现丢包时,只要把Reno分量减下去即可。

从BBR窗口控制的一个细节再谈TCP收敛

参见前文《让人很容易误解的TCP拥塞控制算法》,我一直在强调公平性。公平性是TCP收敛的保证,如果TCP不收敛,那么这个协议就是完全不可用的。
港真,所谓的TCP收敛,要求TCP连接做到的只有一点,那就是悬崖勒马。
        这里我再次讲一个关于我对BBR理解过程中的一个插曲,同样性质的插曲遍布在我对OpenVPN以及REUSEPORT的学习和理解的过程中。这些插曲都源自于我对它们最初的误解。
        初看了BBR的模型后,我以为BBR是不在乎丢包状态的,也就是说,作为拥塞算法来讲,BBR是看不到TCP层面的各种拥塞状态(比如是否处在快速重传等)的,它只是不断地采集带宽和RTT这两项,并把它们放进两个各自的时间窗口内,然后分别地挑出最大带宽和最小RTT,以最大带宽作为基准Pacing rate,以最小RTT和最大带宽的乘积这个基准BDP作为基准窗口。
        然后再将Pacing Rate和窗口经过内在引擎的GAIN调和成最终的发送速率和拥塞窗口...


技术分享


我觉得以上的理解是正确且简单的理解,并可以教别人迅速理解BBR算法的执行过程与传统算法不同。在这个过程中,我们没有看到cong avoid,ssthresh,prr这类东西,也没有看到BBR针对丢包时的反应,这些就是区别,BBR对丢包无反应,如果按照传统的观点,TCP在1秒内成功发送(即全部穿过网络到达对端)了10个一模一样的数据包,每一个数据包大小为1024字节,那么TCP会认为此时的速率是1024Bps,按照BBR的观点,如果TCP在1秒内成功发送了10个一模一样的数据包,每一个大小1024B,那么BBR会认为此时的速率是1024*10Bps,这就是最根本的区别。
        事实真的如此吗?
        是的,真的如此!理由很简单,只有TCP的发送端和接收端才能理解TCP,中间的网络是不理解TCP的,因此指导拥塞控制的速率采集不应该区分数据包,这个速率与TCP的传输速率是有区别的。虽然事实如此,但是在细节上真的没有别的坑吗?BBR对于丢包重传真的没有反应吗?
        我一开始认为是没有反应的,因为这样可以保持BBR的简单却又不影响公平性指标(BBR的收敛性体现在Drain Less以及Probe RTT)。
        我曾经试着写过一个跟BBR算法类似的,思路就是上面说的那种,对丢包,重传,乱序这种不做任何反应。与BBR原生实现相比,我的简版实现单流测试效果非常不错!那个时候我可能还没有深入分析过BBR的实现代码。
        但是后来发现,这好像不合伦理...幸亏我知道悬崖勒马这个词,同时也知道一路走到黑的后果。到底哪里出了问题呢?
        于是,我采用了多个流进行测试,果然,丢包数据急剧增加,造成的重传率也急剧增加。此时,我不得不深入分析BBR的实现细节了。这里就直接说结论吧。
        BBR在设置窗口大小的时候,并非一直按照最大速率与最小RTT的乘积来设置的,如果TCP在Recovery或者Loss状态,那就保持窗口守恒,虽然并不是按照PRR算法来降窗,但是维持当前的窗口不变也有一种悬崖勒马的味道了。随后在TCP进入Open状态后,就会直接恢复窗口到进入Recovery或者Loss状态之前的值。关于这段代码,请看:
static void bbr_set_cwnd(struct sock *sk, const struct rate_sample *rs,
             u32 acked, u32 bw, int gain)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct bbr *bbr = inet_csk_ca(sk);
    u32 cwnd = 0, target_cwnd = 0;

    if (!acked)
        return;
    // 如果进入了Recovery或者Loss状态,就不再执行BDP来计算窗口了,而是直接僵住窗口,必要时还会缩减。
    if (bbr_set_cwnd_to_recover_or_restore(sk, rs, acked, &cwnd))
        goto done;
    // 如果在Open状态,才会执行max-bw*min-rtt来计算窗口
    /* If we‘re below target cwnd, slow start cwnd toward target cwnd. */
    target_cwnd = bbr_target_cwnd(sk, bw, gain);
    if (bbr_full_bw_reached(sk))  /* only cut cwnd if we filled the pipe */
        cwnd = min(cwnd + acked, target_cwnd);
    else if (cwnd < target_cwnd || tp->delivered < TCP_INIT_CWND)
        cwnd = cwnd + acked;
    cwnd = max(cwnd, bbr_cwnd_min_target);

done:
    tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp);    /* apply global cap */
    if (bbr->mode == BBR_PROBE_RTT)  /* drain queue, refresh min_rtt */
        tp->snd_cwnd = min(tp->snd_cwnd, bbr_cwnd_min_target);
}
其中,bbr_set_cwnd_to_recover_or_restore这个函数比较有意思,它的调用实际上表达的就是悬崖勒马,大致意思就是我上面文字所描述的,进入Recovery或者Loss状态前,保存当前的窗口值,然后在整个异常状态维持的期间,以该窗口为基准,在缩减丢包量的基础上维持窗口的守恒:
static bool bbr_set_cwnd_to_recover_or_restore(
    struct sock *sk, const struct rate_sample *rs, u32 acked, u32 *new_cwnd)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct bbr *bbr = inet_csk_ca(sk);
    u8 prev_state = bbr->prev_ca_state, state = inet_csk(sk)->icsk_ca_state;
    u32 cwnd = tp->snd_cwnd;

    /* An ACK for P pkts should release at most 2*P packets. We do this
     * in two steps. First, here we deduct the number of lost packets.
     * Then, in bbr_set_cwnd() we slow start up toward the target cwnd.
     */
     // 注意,在Recovery状态,losses字段也可以是0!具体看NewReno针对Reno的改进以应对丢多包。
    if (rs->losses > 0)
        cwnd = max_t(s32, cwnd - rs->losses, 1);

    if (state == TCP_CA_Recovery && prev_state != TCP_CA_Recovery) {
        /* Starting 1st round of Recovery, so do packet conservation. */
        bbr->packet_conservation = 1;
        bbr->next_rtt_delivered = tp->delivered;  /* start round now */
        /* Cut unused cwnd from app behavior, TSQ, or TSO deferral: */
        cwnd = tcp_packets_in_flight(tp) + acked;
    } else if (prev_state >= TCP_CA_Recovery && state < TCP_CA_Recovery) {
        /* Exiting loss recovery; restore cwnd saved before recovery. */
        bbr->restore_cwnd = 1;
        bbr->packet_conservation = 0;
    }
    bbr->prev_ca_state = state;
    // 退出Recovery或者Loss状态的时候,恢复窗口值。这个意思也包含在了我的QVegas算法中,只是我并没有使用BBR带来的新框架,所以说我的实现看上去不是那么直接。
    if (bbr->restore_cwnd) {
        /* Restore cwnd after exiting loss recovery or PROBE_RTT. */
        cwnd = max(cwnd, bbr->prior_cwnd);
        bbr->restore_cwnd = 0;
    }

    if (bbr->packet_conservation) {
        *new_cwnd = max(cwnd, tcp_packets_in_flight(tp) + acked);
        return true;    /* yes, using packet conservation */
    }
    *new_cwnd = cwnd;
    return false;
}
如果按照BBR的原始模型,以上这个细节是不存在的,诚然,这是一个不可或缺的优化。从这个优化中可以看出,在出现异常的时候悬崖勒马是多么的重要。不管怎样,你可以试试,忽略上述的细节,在带宽不稳定或者出现全局同步的时候,你的连接的丢包和重传会像脱缰的疯马一样不受控制,而加上上述细节,不管在什么情况下,BBR均可以保证最终收敛。
----------------------------
在BBR窗口控制的细节展示的其收敛性之后,我写这一段的目的在于说明,理论上的模型和实际的实现细节差距还是蛮大的,在接触BBR之前,我先后在OpenVPN和REUSEPORT上看到过类似的坑。
OpenVPN
我一直以为OpenVPN是用TLS的记录协议或者DTLS来承载的,后来发现OpenVPN其实区分了数据通道和控制通道,而只有其控制通道采用了TLS协议,数据通道使用自己的私有协议。
REUSEPORT
我一直以为选择socket的哈希算法就是五元组混合后按照socket个数取模的,可是后来才知道不是这样的,具体参见《Linux 4.6内核对TCP REUSEPORT的优化》,
        还好,最终Linux对REUSEPORT的实现回归了我最初的想法。毕竟那是一个正确的想法。

且听下回分解!

QVegas-一个升级版的TCP Vegas拥塞算法