TCP在慢启动阶段,每一个RTT拥塞窗口按指数级增长,TCP在拥塞避免阶段,每一个RTT拥塞窗口线性增加1。这些都是书上讲的,不必太认真,真实的情况要比这个复杂的多!
首先我们看大部分的资料里讲的TCP是怎么实现每RTT增窗的,一切都是扯理论,没什么现实意义!
在慢启动阶段,每收到一个ACK(数据包从发出到收到其ACK,就是一个RTT),窗口增加1,在拥塞避免阶段,每收到前一窗口整窗的ACK,窗口增加1,也就是说,每收到一个ACK,窗口增加1/cwnd!然而这都是理想情况,万一ACK丢了怎么办?万一接收端启用了delay ACK怎么办?
根据标准规定,接收端最多只能延迟2*MSS这么多的ACK,如果再多了就是stretch ACK了!也就是说,如果接收端启用了delay ACK,每收到两个数据包的时候才发一个ACK的话,发送端在一个RTT时间段内可能增加的窗口只有预期的1/2,这符合大多数资料里讲的逻辑吗?
标准其实并没有规定在拥塞避免阶段窗口一定是一个RTT增加1,而只是用了一个近似的算法:
Traditionally, TCPs have approximated this increase by increasing cwnd by 1/cwnd for each arriving ACK. This algorithm opens cwnd by roughly 1 segment per RTT if the receiver ACKs each incoming segment and no ACK loss occurs. However, if the receiver implements delayed ACKs [Bra89], the receiver returns roughly half as many ACKs, which causes the sender to open cwnd more conservatively(by approximately 1 segment every second RTT).
如果TCP真的表现为一包一ACK,那么大多数情况下确实可以做到慢启动指数增窗,拥塞避免线性增窗。但是正如上面所说,如果考虑到ACK丢失,或者接收端delay ACK等,理论逻辑就难免失真了。
TCP的ACK机制本身就是一种反馈,理论上“被ACK的数据越多,意味着可以发送的越多”这种猜测总是合理的,于是RFC3465提出了一个ABC算法,即通过收到的ACK中被ACK的字节数来计算如何增加窗口。
1.safe area和dangerous area
TCP作为一种端到端的协议并没有带宽反馈的能力,因此其拥塞控制机制完全是基于探测的,即不断地试探带宽的极限,不管多么好的拥塞控制算法,其本质也不外乎以上这种探测。在拥塞控制看来,所谓的带宽探测最终落实到拥塞窗口探测上。
如今的TCP实现基本延续了NewReno的内核,在拥塞窗口探测的过程中,它会经历两个区域,一个是safe区域,一个是dangerous区域,两个区域由ssthresh来分割。在safe区域中,执行指数增窗的慢启动,在dangerous区域执行线性增窗的拥塞避免。ssthresh事实上是安全增窗的上界,也可以理解为保守的满带宽窗口,既然是安全的区域,那么就可以尽可能快的增加窗口,于是理论上每来一个ACK(这说明网络是通的),窗口就可以增加1,而不必等待一窗数据均被ACK才增窗,在越过了ssthresh之后,TCP会认为随时都有可能超过网络的承载能力,于是只有在一窗数据都被ACK了之后,才可以增窗。
2.如何利用ACK的反馈信息
以上的关于safe/dangerous区域的描述中,所有的增窗行为均是通过ACK来反馈的,RFC2581建议使用ACK的数量来作为增窗的信号,然而面对ACK丢失或者delay ACK的时候,RFC3465给出了ABC算法,在ABC中,使用被ACK的字节数而不是ACK的数量来作为增窗的反馈信号。这样的话,以下过程将被执行:
1.慢启动阶段:只要被ACK的数据字节数达到了一个MSS大小,窗口增加一个MSS;
2.拥塞避免阶段:只要被ACK的数据字节数达到了一窗的大小,窗口增加一个MSS。
除此之外,ABC可以让TCP在慢启动阶段更加激进,它可以让TCP每收到ACK了一个MSS的确认包后,增加N个而不是1个MSS窗口的大小,因为这是在安全区域,激进不为过!
3.ABC与突发
ABC貌似解决了delay ACK以及ACK丢失带来的窗口增加缓慢的问题,然而却带来了突发问题,这个突发问题是利还是弊,并不绝对!本质上来讲,ABC算法会带来突发的原因是它会“记住”那些迟到的或者丢失的确认,并积累起来日后使用,这非常类似于网络流量控制的突发令牌桶的原理,令牌是可以积累使用的,在TCP中,每一个针对一个MSS大小数据段的确认,不管其是显式的还是隐式的,都相当于一个可以积累的令牌。
1.异常带来的突发
假设ACK大面积丢失,对于发送端而言,ACK到来的频率会降低,造成的效果就是窗口长时间由于收不到ACK而僵住,一旦收到一个ACK,发送端会发现它ACK了大量(甚至巨量)的数据,窗口一下子得到了补偿,可能会增加很多,这种突发对于慢启动阶段可能会更严重,因为慢启动阶段每确认MSS大小的数据,窗口就会增加N(取决于配置),而对于拥塞避免阶段,情况会缓和很多。
这种异常带来的突发,在单向拥塞的情况下,问题不大,如果发生了双向拥塞,发送端的激进增窗带来的就是更多丢包了,发送端在无法区别对待ACK丢失和delay ACK的情况下,会造成很多的误判,幸好,TCP规范了stretch ACK的定义,这或许会给发送端的判断带来一些暗示,比如收到了连续ACK了超过2个MSS数据的确认包,就判断为是ACK丢失,保守增窗。
为了保证发生上述问题的时候异常突发不会带来严重的后果,RFC3465规定慢启动阶段,N的最大值为2,即每收到一个MSS大小的确认,窗口最多增加2个MSS!
2.正常突发
相对于异常突发,正常突发就显得更加像是一个正反馈无级变速系统了,简单来讲,如果不考虑delay ACK和ACK丢失以及接收端的实现bug,ABC算法执行地会相当平滑,即ACK覆盖面越广,增窗就越快,不考虑拥塞的情况下,这意味着你发送的数据越多,窗口增加的越快!
RFC3465有一个例子很好,比如你登录了ssh进行交互式作业,大多数情况下都是小数据的交互,此时窗口增加比较缓慢(ABC算法按照被ACK的字节数大小来决定是否增窗以及增加多少),此时如果你需要在终端显示一个大文件,那么交互的数据量几乎是瞬间变大,如果按照RFC2581的方式增窗,窗口增加完全按照ACK的数量来的话,就等于说是有了一个最高的限速,然而如果使用ABC的话,随着大块数据的发送和被确认,窗口的增速也随之增加。这种情况下,ACK到达的速率是不变的,然而ACK覆盖的字节数却变大了,窗口增速也就随之变大。
3.穿越ssthresh
在慢启动阶段,窗口按照指数增长,如果ssthresh的值比较大,在窗口穿越ssthresh的时候,其可能已经是十分大了,在最后一次慢启动增窗中,很大的可能性会使窗口一下子升到ssthresh以上很多,超过了网络的承载能力造成丢包。在这种情况下,ssthresh根本就没有起到阈值的作用,在ABC算法中N为2的情况下,问题更加严重。
之所以会有这个问题,是因为TCP没有在窗口增加到ssthresh附近的时候没有更细化的控制。不过这不是什么问题,Linux 4.x+已经完美修正了这个问题(具体我不知道是哪个版本引入的,但是我确定3.10中没有,而4.3中有)。
4.ABC与Linux TCP实现
Linux关于ABC算法的实现经历了三个阶段。
阶段一:ABC作为一个sysctl选项
以2.6.32内核版本为例,Linux有一个sysctl_tcp_abc选项,它会选择是否使用ABC算法,如果启用ABC,一个ACK包确认的字节数会被保存在bytes_acked字段里,在拥塞避免阶段,TCP是这样使用bytes_acked的:
if (tp->bytes_acked >= tp->snd_cwnd*tp->mss_cache) { tp->bytes_acked -= tp->snd_cwnd*tp->mss_cache; if (tp->snd_cwnd < tp->snd_cwnd_clamp) tp->snd_cwnd++; }
而bytes_acked会在收到ACK的时候被更新:
if (icsk->icsk_ca_state < TCP_CA_CWR) tp->bytes_acked += ack - prior_snd_una; else if (icsk->icsk_ca_state == TCP_CA_Loss) /* we assume just one segment left network */ tp->bytes_acked += min(ack - prior_snd_una, tp->mss_cache);
然而,如果没有使用ABC的话,在每收到ACK的时候会执行下面的逻辑:
if (tp->snd_cwnd_cnt >= tp->snd_cwnd) { if (tp->snd_cwnd < tp->snd_cwnd_clamp) tp->snd_cwnd++; tp->snd_cwnd_cnt = 0; } else { // 可见,不使用ABC是通过ACK的数量来计数的 tp->snd_cwnd_cnt++; }
在慢启动阶段:
if (sysctl_tcp_abc > 1 && tp->bytes_acked >= 2*tp->mss_cache) cnt <<= 1; // 可以增加2倍窗口 // 慢启动阶段完全按照一次ACK的MSS倍数来决定增窗大小,因此需要清除bytes_acked tp->bytes_acked = 0; tp->snd_cwnd_cnt += cnt; // 注意,下面的算法可能会出现ssthresh的穿越问题! while (tp->snd_cwnd_cnt >= tp->snd_cwnd) { tp->snd_cwnd_cnt -= tp->snd_cwnd; if (tp->snd_cwnd < tp->snd_cwnd_clamp) tp->snd_cwnd++; }
阶段二:ABC可选实现
以3.10为例,你会发现没有了tcp_abc选项,在代码中,也再也没有了关于bytes_acked的计数,而且在拥塞避免阶段,也只剩下了tcp_cong_avoid_ai(tp, tp->snd_cwnd)的逻辑,而这个逻辑是按照ACK的数量来计数的。
那么如果想实现ABC怎么办呢?只好在自己的拥塞模块里面自己写了。每个ACK所确认的数据量(即bytes_acked)可以通过pkts_acked回调函数获取(在清理传输队列的数据包后调用)。
阶段三:ABC内置并优化实现
到了Linux 4.4内核,依然还是没有tcp_abc选项,然而如果你看代码,发现基本已经完全实现了ABC算法:
void tcp_reno_cong_avoid(struct sock *sk, u32 ack, u32 acked) { struct tcp_sock *tp = tcp_sk(sk); if (!tcp_is_cwnd_limited(sk)) return; /* In "safe" area, increase. */ if (tcp_in_slow_start(tp)) { // slow_start函数有了返回值,返回慢启动结束后,还剩下多少被确认的数据可以用来增加拥塞避免增窗计数器 acked = tcp_slow_start(tp, acked); // 如果返回0,说明此次增窗还没有穿越ssthresh。可见新版本完美捕捉到了ssthresh穿越问题 if (!acked) return; } /* In dangerous area, increase slowly. */ // acked参数被传入,作为增窗条件的计数被累加。 tcp_cong_avoid_ai(tp, tp->snd_cwnd, acked); }
如果我们看下tcp_slow_start,会发现它异常简洁:
u32 tcp_slow_start(struct tcp_sock *tp, u32 acked) { // 慢启动阶段,窗口最多增加到ssthresh。 // acked累加到窗口,虽然不是ABC的标准算法(没有实现N增窗),但基本就是那个意思 u32 cwnd = min(tp->snd_cwnd + acked, tp->snd_ssthresh); // 剩下的穿越ssthresh的部分交给拥塞避免来处理 acked -= cwnd - tp->snd_cwnd; tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp); return acked; }
函数tcp_cong_avoid_ai的注释也多了一句话:
/* In theory this is tp->snd_cwnd += 1 / tp->snd_cwnd (or alternative w), * 下面这句话是新增的... * for every packet that was ACKed. */ void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked) { /* If credits accumulated at a higher w, apply them gently now. */ if (tp->snd_cwnd_cnt >= w) { tp->snd_cwnd_cnt = 0; tp->snd_cwnd++; } // 累加被确认的数据量而不是每收到ACK简单加1 tp->snd_cwnd_cnt += acked; ... }
TCP是一个复杂无比的协议,Linux的实现也历经着无比大的变化,从2.6.8到4.4,你会发现很多细节连基本思想都改变了,这背后,如果不了解RFC中阐释的思路,将很难理解其所以然,因此RFC才是王道,内功,心法!