5.3.9 拥塞控制
在以前章节所介绍的特性中,比如延迟确认、扩大滑动窗口(Window Scale Option)等,其目的都是为了提高 TCP 的发送效率。这些特性都有一个潜台词:网络是不拥塞的。毕竟如果网络是拥塞的话,提升发送效率没有任何意义。(当然,延迟确认也有避免网络拥塞的功效,这是另一个话题)
网络拥塞的定义,我们就不啰嗦了,那句著名的英语成语已经能说明一切:People Mountain People Sea!
本节所介绍的特性——拥塞控制(TCP Congestion Control)——讲述的则是从如何避免网络拥塞的视角或者网络已经拥塞的情形下,TCP 对应的算法和处理机制。
TCP 拥塞控制(对应 RFC 5681)包括4个算法(机制):慢速启动、拥塞避免、快速重传、快速恢复(slow start, congestion avoidance, fast retransmit, and fast recovery)。
下面我们分别介绍这4个算法。
5.3.9.1 慢速启动和拥塞避免
慢速启动和拥塞避免,是成对出现的两个算法。在正是介绍它们之前,我们先介绍一个名词:cwnd(CONGESTION WINDOW,拥塞窗口)。
我们知道,TCP 发送方可以连续发送多个 Segment,而不必等待对方的 ACK。理想情况下,TCP 甚至可以连续发送完最大窗口(230)所包含的字节。但是在拥塞控制这个场景下,TCP 不能闭着眼连续发送多个报文,因为这很可能会造成网络拥塞。
我们把TCP 报文(Segment)所包含的数据比喻成子弹,1个字节对应1颗子弹,把1个弹夹所包含的子弹数比喻成 cwnd,那么将这个弹夹的子弹打光以后,必须换个弹夹才能继续发射——我们把这个换弹夹的动作比喻成收到对方的 ACK 报文,如图5-140所示:
图5-140 换弹夹
图5-140中,A 向 B 连续发送了 cwnd 个字节以后,必须要换一次弹夹(收到对方对应的 ACK 报文)才能继续发送。
从理论上讲,即使没有拥塞控制一说,TCP 也不能永远发送下去,中间也必须要换弹夹,因为弹夹的容量是有限的(发送窗口最大是 230,一次最多能连续发送231个字节)。如果我们把这个最大窗口看成巨无霸弹夹的话,cwnd 存在的意义就是为了避免 TCP 一上来就闭着眼把巨无霸弹夹里的子弹一扫而光,这就是所谓的拥塞控制场景(之一)。
当刚刚创建完连接或者长时间空闲,TCP 对于网络情形几乎是一无所知,此时它如果要发送数据,就有必要考虑网络是否拥塞:如果网络是拥塞的,TCP 将巨无霸弹夹一扫而光只会带来“丢包/重传/丢包/重传......”的恶性循环,它必须得悠着点,换1个较小的弹夹;但是如果网络是不拥塞的,TCP 却一直采用较小的弹夹,这又会影响发送效率。
慢速启动和拥塞避免,就是边发送数据边“探测”网络拥塞状况边动态调整 cwnd(弹夹)的过程,如图5-141所示:
图5-141 慢速启动和拥塞避免的算法示意
图5-141中,坐标亨轴是时间 t,纵轴是拥塞窗口 cwnd,它体现的是 cwnd 随着时间变化的情形。
图5-141中,ssthresh(slow start threshold)指的是慢速启动门限值。当 cwnd < ssthresh 时,cwnd 随时间变化的函数称为慢速启动;当 cwnd > ssthresh 时,cwnd 随时间变化的函数称为拥塞避免。至于 cwnd = ssthresh 时,cwnd 的变化可以选择两个函数(算法)的任意1个。
图5-141 中,cwnd 随着时间变化的曲线,被简化示意成了2段直线,而且可以看到,慢速启动的直线,其斜率(cwnd 的变化率)更大一些,而拥塞避免的直线,其斜率(cwnd 的变化率)更小一些。由此可以看到,慢速启动指的并不是 cwnd 的变化率,而是 cwnd 的初始值。
1)初始拥塞窗口
初始拥塞窗口(IW,Initial Window)指的是图5-141中的 IW 点,从直观来看它的值就比较小——实际上也确实比较小(下文会讲述)。
IW 比较小的含义是:对网络的一种试探(探测)。毕竟,如果网络是拥塞的,一下子发送那么多,与己与人(网络)都没有好处。与己:只会产生丢包、重传;与人(网络):只会增大网络的拥塞程度。
RFC 5681 关于 IM 的定义是这样的:
(1)If SMSS > 2190 bytes:
IW = 2 * SMSS bytes and MUST NOT be more than 2 segments
即 IW 最多等于2倍的 SMSS,同时 IW 也不能超过2个报文。极端一点,如果1个报文只包含1个字节的数据,那么 IW 只能等于2。
另外,SMSS(SENDER MAXIMUM SEGMENT SIZE)这个概念与 MSS(Maximum Segment Size)没有本质区别,只不过前面加了一个限定词 SENDER,强调是发送方所能发送的最大 Segment 的 Size。
(2)If (SMSS > 1095 bytes) and (SMSS <= 2190 bytes):
IW = 3 * SMSS bytes and MUST NOT be more than 3 segments
(3)if SMSS <= 1095 bytes:
IW = 4 * SMSS bytes and MUST NOT be more than 4 segments
虽然 RFC(5681)非常权威,不过 Google 的论文“An Argument for Increasing TCP’s Initial Congestion Window”也非常重要。Linux 3.0 就是按照这篇论文的建议,将 IW 设置为 10 * SMSS。
是 RFC 5681也好,或者是 Google 的论文也罢,我们可以粗略的把 IW 理解为 4K(或者10K)。前文说过,如果 TCP 的 Window 不放大(没有 Window Scale Option),则 Window 最大可以为64K,如果考虑放大系数,则 Window 最大可以为1G。而 IW 仅仅相当于 4K(或者10K),可以看到它确实比较小。
但是 cwnd 并不是一成不变的,仅仅是它的初始值(IW)比较小,在随后的数据发送过程中,如果网络比较通畅,cwnd 还是会比较快速地增大。
2)慢速启动算法
再强调一遍,慢速启动算法中的“慢速”二字,更多地指的是 cwnd 的初始值(IW)比较小,并不是指 cwnd 的增速比较慢。不过既然 RFC 如此称呼,我们也就采用这种叫法吧。
慢速启动算法,首先设置1个门限值 ssthresh,当 cwnd 小于(等于) ssthresh 时,cwnd 的增大算法就是慢速启动算法。
慢速启动算法也比较简单,就是:每当发送方收到1个相应的 ACK 报文时,cwnd 增加 SMSS 字节,即 cwnd = cwnd + SMSS,如图5-142所示:
图5-142 慢速启动算法
慢速启动算法虽然简单,但是其背后有几层含义:
(1)在当前 cwnd 的情形下,网络还不算阻塞,因为它收到了 ACK 报文
(2)既然没有阻塞,那就可以适当加大 cwnd
(3)但是 cwnd 也不要增加的太多,1次只增加 SMSS 即可
1个 RTT 时间,增加 1个 SMSS ,实际上并不慢。我们假设 SMSS = 1K,IW = 1K,从发送 cwnd 个字节的数据到收到 ACK 报文为0.01秒,那么经过1万秒以后,CWND 将达到 1G(对应的理论带宽是 1Tbps(传输距离是1500km)),或者说10秒以后,理论带宽将达到 1Gbps(传输距离是1500km)——这已经是非常快的增长速度。
当然,TCP 不会这么一直让 cwnd 增长下去,当 cwnd 达到 ssthresh 时,cwnd 的增长速度就会变慢(即采用拥塞避免算法)。ssthresh 应该等于多少?拥塞避免算法又是什么?这两个问题我们放到后面再说,这里先看看 RFC 5681 对慢速启动算法的修正:
cwnd = cwnd + min(N, SMSS)
其中,N 为图5-142中 ACK 报文中所确认的数据字节数。考虑这样一种情形,如图5-143所示:
图5-143 RFC 5681 的慢速启动算法
图5-143中,假设 SMSS = 1000,A 给 B 发送了1个报文 SEG1,包含了1000个字节的数据,但是 B 对于这1000个字节的数据是分批确认,比如图5-143中的 ACK 报文,只确认了500个字节。于是 TCP 就只将 cwnd 增大500。
RFC 5681之所以这么做(cwnd = cwnd + min(N, SMSS)),一个是为了适应这种分批确认的情形,另一个也是处于相对保守的目的,不让 cwnd 增加得太快。
但是,有的资料所描述的慢速启动算法,不仅不保守,而且还非常“激进”,它规定:每经过1个 RTT 时间,
cwnd = 2 * cwnd
表面上看这仅仅是乘以2,实际上它是“每次都加倍”,也就是说 cwnd 其实是一个指数级的增长。假设不考虑 ssthresh,那么 cwnd 从1K增加到1G,也只需要20次 RTT——如果1次 RTT 是0.01秒,也就需要0.2秒。
这个算法是由 Van Jacobson 于 1988年所发表的一篇论文提出(Congestion Avoidance and Control. ACM SIGCOMM 1988)。笔者之所以将“激进”二字打了引号,不是因为这个算法中的 cwnd 增长速度不快,而是 cwnd 无论增长多快,待到它增长到 ssthresh 后,增速就会慢下来。
RFC 5681 乃是在 Van Jacobson 这篇论文(和另一篇论文“Modified TCP Congestion Avoidance Algorithm”)的基础上发展出来的(The core algorithms we describe were developed by Van Jacobson [Jac88, Jac90]),不过 RFC 5681 的慢速启动算法要保守的多(cwnd = cwnd + min(N, SMSS))。
3)拥塞避免算法:cwnd 的增加
拥塞避免算法,可以分为两部分:
(1)从 cwnd 大于(等于) ssthresh 开始,到网络未拥塞的这段时间内,cwnd 的增长算法。
(2)当拥塞开始后,cwnd 和 ssthresh 的调整算法。
那么什么叫拥塞呢?对于 TCP 来说,就是它“感觉”到第1个丢包。也就是说,发送了一个(批)报文以后,时间已经超过了 RTO,但是 TCP 发送方还是没有收到对方的相应的 ACK 报文。
在第1阶段(cwnd 大于(等于) ssthresh,而且网络还未拥塞),RFC 5681 关于 cwnd 的增长算法,并没有严格规定哪个公式,而是有几个建议:
(1)在1个 RTT 内,cwnd = cwnd + SMSS
(2)或者在1个 RTT 内,cwnd = cwnd + min(N, SMSS)
这里的 N 指的是在 RTT 时间内,TCP 发送方收到的对方 ACK 报文所确认的数据字节数。
(3)有一点必须严格规定:在1个 RTT 内,cwnd 的增加不能超过1个 SMSS。
实话实说,RFC 5681 这几个建议有点让人崩溃,因为这与其建议的慢速启动算法并无本质区别,让人实在难以理解这两者有什么不同。
幸好,RFC 5681 还有1个建议:在1个 RTT 内,cwnd = cwnd + min(SMSS * SMSS / cwnd, 1)。因为“SMSS * SMSS / cwnd”有可能小于1,所以用“min(SMSS * SMSS / cwnd, 1)”进行修正,表示最少增加1个字节。
至此,关于 cwnd 的增加,我们介绍了慢速启动和拥塞避免两个算法,对比总结如表5-29所示:
表5-29 慢速启动与拥塞避免的对比
慢速启动 | 拥塞避免 | |
方案1 | cwnd = cwnd * 2 | cwnd = cwnd + SMSS |
方案2 | cwnd = cwnd + SMSS | cwnd = cwnd + SMSS * SMSS / cwnd |
表5-29中,为了看起来更加直观,将相关公式做了一定的简化(省略掉了 min、max)。另外,表5-29所描述的“方案1”和“方案2”并不是真的存在,而是为了对比的“冲击感”,而虚拟出的两个方案。换句话说,非常有可能慢速启动和拥塞避免两个算法,都是采用:cwnd = cwnd + SMSS。
4)拥塞避免算法:ssthresh 和 cwnd 的调整
当 TCP 遇到拥塞时,cwnd 肯定不能再继续增加了——那样可能会越来越阻塞,不仅不能继续增加 cwnd,而且 ssthresh 也需要调整,如图5-144所示:
图5-144 ssthresh 和 cwnd 的调整
图5-144中,t1时刻,TCP 发送端感知到了阻塞,这时候它需要做3件事情:
第1件事,调整 ssthresh。图5-114中,ssthresh0 是初始化的 ssthresh,ssthresh1 是调整后的 ssthresh。调整算法是:
ssthresh = max(FlightSize/2, 2 * SMSS)
其中,FlightSize 指的是 TCP 已经发送的但是还未得到 ACK 的数据的 size(字节数)。On the flight,飞机在飞行中,可以引申为数据还在“飞行中”(当然,也可能被网络丢弃了),所以 FlightSize 这个词还是表形象。
ssthresh 这个调整算法的含义是“调整为已发送未确认数据长度的一半(当然最小值不能小于 2 * SMSS)”,用大白话说就是:拥塞了也不知如何是好,那就减半;用高雅的话说,那就是指数回退。
前文我们讲,ssthresh 的初始值(图5-144中的 ssthresh0)可以取1个较大的值,但是并没有讲清楚它到底该等于多少。现在我们看到,ssthresh0 取多少并不是最重要的,因为在实际的数据传输中,它可以动态调整,最终总能调整到一个比较合适的值。当然,根据经验,设置一个相对比较合理的 ssthresh 初始值,也是有一定必要的。
除了调整 ssthresh,TCP 还需要做第2件事,那就是将 cwnd 重新初始化(图5-144中的 IW1)。IW1 具体等于多少,仍然是一个经验数据,RFC 5681 并没有明确规定,只是它强调,IW1 不能大于 SMSS。这么重新设置 IW 背后的含义仍然是:既然阻塞了,那就低到尘埃,尽量将 cwnd 调到最低。
将 cwnd 调低以后,如果网络又变好了呢,所以 cwnd 不能调低之后再也不管了。TCP 做的第3件事情,就是根据网络状况增大 cwnd。如何增大呢?就是继续重复慢速启动算法、拥塞避免算法......直到下一次的网络拥塞再次来到。
可以看到,网络拥塞后,TCP 所做的事情其实是:(1)调整 ssthresh 和 cwnd 的初始值;(2)然后继续重复慢速启动算法、拥塞避免算法......
那么问题来了,TCP 这种循环往复的作法,是不是有点神经病?如图5-145所示:
图5-145 慢速启动和拥塞避免的循环
图5-145做了一些简化:(1)将每次拥塞发生时的 cwnd 简化为相同;(2)将每次拥塞发生时的 ssthresh 简化为相同;(3)将每次循环减缓为相同的周期(实际上每次循环的时间间隔都基本不可能相同)。之所以做这些简化,是为了能更加直观地体现慢速启动和拥塞避免的循环。
通过图5-145,第一眼感觉 TCP 是不是神经病?每次都是“艰难”地爬坡,将 cwnd 增加大 cwnd-max,达到拥塞,然后垂直降落,然后再“艰难”地爬坡......TCP 以为自己是在雪场滑雪呢?爬上去再滑下来、爬上去再滑下来......
这么调侃 TCP 确实有点不尊重,^_^,因为 TCP 确实是被冤枉了,那个对图5-145的第一眼的感觉,对 TCP 有误会。
首先,网络拥塞不一定是由某一个 TCP 连接造成的。图5-145反应的是1个 TCP 连接的慢速启动和拥塞避免算法,但是图中的网络拥塞不一定是由这个连接造成的。如果不是由它造成的,那么图5-145中的“爬上去、滑下来”的过程,不仅不是神经病,而且还是一个非常智能的行为:TCP 非常及时、正确地根据网络拥塞情况调整了自己的参数。
其次,就算网络拥塞是由这个连接造成的,它自己费了半天劲把 cwnd 爬上去,把网络爬拥塞了,然后又不得不垂直降落......这个过程没有错,但是不是 TCP 神经病,而是图5-145给人以误解。
图5-145为了体现慢速启动和拥塞避免的循环往复的过程,将多个循环“挤”在一张图里,给人感觉这个循环周期非常短。实际上这个周期(图中的Δt)还是比较长的。另外,如果是由于这个连接将网络弄成拥塞了,那最大的可能是什么——最大的可能是 TCP 发送方一直发送了 cwnd 个字节的数据,TCP 接收方才会回以1个 ACK 报文,这个在实际情况中也不太可能发生。也就是说,实际的场景中,由于 TCP 发送方将 cwnd 爬高而将网络搞拥塞的概率也是比较低的。
总上所述,笔者应该向 TCP 道歉,它不仅不是神经病,而且是智慧的化身。