Skip to content

25 有风度的 TCP - 拥塞控制

前面的文章介绍了 TCP 利用滑动窗口来做流量控制,但流量控制这种机制确实可以防止发送端向接收端过多的发送数据,但是它只关注了发送端和接收端自身的状况,而没有考虑整个网络的通信状况。于是出现了我们今天要讲的拥塞处理。

拥塞处理主要涉及到下面这几个算法

  • 慢启动(Slow Start)
  • 拥塞避免(Congestion Avoidance)
  • 快速重传(Fast Retransmit)和快速恢复(Fast Recovery)

为了实现上面的算法,TCP 的每条连接都有两个核心状态值:

  • 拥塞窗口(Congestion Window,cwnd)
  • 慢启动阈值(Slow Start Threshold,ssthresh)

01 拥塞窗口(Congestion Window,cwnd)

拥塞窗口指的是在收到对端 ACK 之前自己还能传输的最大 MSS 段数。

它与前面介绍的接收窗口(rwnd)有什么区别呢?

  • 接收窗口(rwnd)是接收端的限制,是接收端还能接收的数据量大小
  • 拥塞窗口(cwnd)是发送端的限制,是发送端在还未收到对端 ACK 之前还能发送的数据量大小

我们在 TCP 头部看到的 window 字段其实讲的接收窗口(rwnd)大小。

拥塞窗口初始值等于操作系统的一个变量 initcwnd,最新的 linux 系统 initcwnd 默认值等于 10。

拥塞窗口与前面介绍的发送窗口(Send Window)又有什么关系呢?

真正的发送窗口大小 = 「接收端接收窗口大小」与「发送端自己拥塞窗口大小」两者的最小值

如果接收窗口比拥塞窗口小,表示接收端处理能力不够。如果拥塞窗口小于接收窗口,表示接收端处理能力 ok,但网络拥塞。

这也很好理解,发送端能发送多少数据,取决于两个因素

  • 对方能接收多少数据(接收窗口)
  • 自己为了避免网络拥塞主动控制不要发送过多的数据(拥塞窗口)

发送端和接收端不会交换 cwnd 这个值,这个值是维护在发送端本地内存中的一个值,发送端和接收端最大的在途字节数(未经确认的)数据包大小只能是 rwnd 和 cwnd 的最小值。

拥塞控制的算法的本质是控制拥塞窗口(cwnd)的变化。

02 拥塞处理算法一:慢启动

在连接建立之初,应该发多少数据给接收端才是合适的呢?

你不知道对端有多快,如果有足够的带宽,你可以选择用最快的速度传输数据,但是如果是一个缓慢的移动网络呢?如果发送的数据过多,只是造成更大的网络延迟。这是基于整个考虑,每个 TCP 连接都有一个拥塞窗口的限制,最初这个值很小,随着时间的推移,每次发送的数据量如果在不丢包的情况下," 慢慢 " 的递增,这种机制被称为「慢启动」

拥塞控制是从整个网络的大局观来思考的,如果没有拥塞控制,某一时刻网络的时延增加、丢包频繁,发送端疯狂重传,会造成网络更重的负担,而更重的负担会造成更多的时延和丢包,形成雪崩的网络风暴。

这个算法的过程如下:

  • 第一步,三次握手以后,双方通过 ACK 告诉了对方自己的接收窗口(rwnd)的大小,之后就可以互相发数据了

  • 第二步,通信双方各自初始化自己的「拥塞窗口」(Congestion Window,cwnd)大小。

  • 第三步,cwnd 初始值较小时,每收到一个 ACK,cwnd + 1,每经过一个 RTT,cwnd 变为之前的两倍。过程如下图

    img

在初始拥塞窗口为 10 的情况下,拥塞窗口随时间的变化关系如下图

img

因此可以得到拥塞窗口达到 N 所花费的时间公式为:

img

假设 RTT 为 50ms,客户端和服务端的接收窗口为 65535 字节(64KB),初始拥塞窗口为:10 段,那么要达到 64KB 的吞吐量,拥塞窗口的段数 = 65535 / 1460 = 45 段,需要的 RTT 次数 = log2(45 / 10)= 2.12 次,需要的时间 = 50 * 2.12 = 106ms 。也就是客户端和服务器之间的 64KB 的吞吐量,需要 2.12 次 RTT,100ms 左右的延迟。

早期的 Linux 的初始 cwnd 为 4,在这种情况下,需要 3.35 次 RTT,花费的实际就更长了。如果客户端和服务器之间的 RTT 很小,则这个时间基本可以忽略不计

03 使用 packetdrill 来演示慢启动的过程

我们用 packetdrill 脚本的方式来看慢启动的过程。模拟服务端 8080 端口往客户端传送 100000 字节的数据,客户端的 MSS 大小为 1000。

bash
+0  write(4, ..., 100000) = 100000

packetdrill 脚本内容如下

bash
--tolerance_usecs=1000000
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0 setsockopt(3, SOL_TCP, TCP_NODELAY, [1], 4) = 0
+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0 bind(3, ..., ...) = 0
+0 listen(3, 1) = 0

+0  < S 0:0(0) win 65535  <mss 100>
+0  > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 65535

+.1  accept(3, ..., ...) = 4

// 往客户端写 20000 字节数据
+.3  write(4, ..., 20000)  = 20000
// 预期内核会发出 10 MSS 数据,下面是 10 次断言
+0 > . 1:101(100) ack 1 <...>
+0 > . 101:201(100) ack 1 <...>
+0 > . 201:301(100) ack 1 <...>
+0 > . 301:401(100) ack 1 <...>
+0 > . 401:501(100) ack 1 <...>
+0 > . 501:601(100) ack 1 <...>
+0 > . 601:701(100) ack 1 <...>
+0 > . 701:801(100) ack 1 <...>
+0 > . 801:901(100) ack 1 <...>
+0 > . 901:1001(100) ack 1 <...>

+0 `sleep 1000000`

第 1 步:首先通过抓包确定,是不是符合我们的预期,拥塞窗口 cwnd 为 10,第一次会发 10 段 MSS 的数据包,抓包结果如下。

img

可以看到服务器一口气发了 10 段数据,然后等待客户端回复 ACK,因为我们没有写回复 ACK 的代码,所以过了 300ms 以后开始重传了。

第 2 步:确认这 10 段数据 在 write 调用后面增加确认 10 个段数据的脚本。理论上拥塞窗口 cwnd 会从 10 变为 20,预期内核会发出 20 段数据

bash
+.1 < . 1:1(0) ack 1001 win 65535
// 预期会发出 20 MSS,下面是 20 次断言
+0 > . 1001:1101(100) ack 1 <...>
+0 > . 1101:1201(100) ack 1 <...>
+0 > . 1201:1301(100) ack 1 <...>
+0 > . 1301:1401(100) ack 1 <...>
+0 > . 1401:1501(100) ack 1 <...>
+0 > . 1501:1601(100) ack 1 <...>
+0 > . 1601:1701(100) ack 1 <...>
+0 > . 1701:1801(100) ack 1 <...>
+0 > . 1801:1901(100) ack 1 <...>
+0 > . 1901:2001(100) ack 1 <...>
+0 > . 2001:2101(100) ack 1 <...>
+0 > . 2101:2201(100) ack 1 <...>
+0 > . 2201:2301(100) ack 1 <...>
+0 > . 2301:2401(100) ack 1 <...>
+0 > . 2401:2501(100) ack 1 <...>
+0 > . 2501:2601(100) ack 1 <...>
+0 > . 2601:2701(100) ack 1 <...>
+0 > . 2701:2801(100) ack 1 <...>
+0 > . 2801:2901(100) ack 1 <...>
+0 > . 2901:3001(100) ack 1 <...>

重新执行抓包,可以看到这次服务端发送了 20 段长度为 MSS 的数据

img

第 3 步:确认发送的 20 段数据 再确认发送的 20 段数据,看看内核会发送出多少数据

bash
// 确认这 20 段数据
+.2 < . 1:1(0) ack 3001 win 65535

// 预期会发出 40 MSS 数据,下面是 40 次断言
+0 > . 3001:3101(100) ack 1 <...>
+0 > . 3101:3201(100) ack 1 <...>
// 中间省略若干行
+0 > . 6701:6801(100) ack 1 <...>
+0 > . 6801:6901(100) ack 1 <...>
+0 > . 6901:7001(100) ack 1 <...>

抓包结果如下,可以看到这下服务器发送了 40 段数据

img

第 4 步,确认发送的 40 段数据,理论上应该会发送 80 段数据,包序号区间:7001 ~ 15001

bash
+.2 < . 1:1(0) ack 7001 win 65535

抓包结果如下

img

上面的过程通过抓包的方式来验证了慢启动指数级增大拥塞窗口 cwnd 的过程。

04 慢启动阈值(Slow Start Threshold,ssthresh)

慢启动拥塞窗口(cwnd)肯定不能无止境的指数级增长下去,否则拥塞控制就变成了「拥塞失控」了,它的阈值称为「慢启动阈值」(Slow Start Threshold,ssthresh),这是文章开头介绍的拥塞控制的第二个核心状态值。ssthresh 就是一道刹车,让拥塞窗口别涨那么快。

  • 当 cwnd < ssthresh 时,拥塞窗口按指数级增长(慢启动)
  • 当 cwnd > ssthresh 时,拥塞窗口按线性增长(拥塞避免)

05 拥塞避免(Congestion Avoidance)

当 cwnd > ssthresh 时,拥塞窗口进入「拥塞避免」阶段,在这个阶段,每一个往返 RTT,拥塞窗口大约增加 1 个 MSS 大小,直到检测到拥塞为止。

img

与慢启动的区别在于

  • 慢启动的做法是 RTT 时间内每收到一个 ACK,拥塞窗口 cwnd 就加 1,也就是每经过 1 个 RTT,cwnd 翻倍
  • 拥塞避免的做法保守的多,每经过一个 RTT 才将拥塞窗口加 1,不管期间收到多少个 ACK

img

实际的算法是如下:,

  • 每收到一个 ACK,将拥塞窗口增加一点点(1 / cwnd):cwnd += 1 / cwnd

以初始 cwnd = 1 为例,cwnd 变化的过程如下图

img

所以是每经过 1 个 RTT,拥塞窗口「大约」增加 1

前面介绍的慢启动和拥塞避免是 1988 年提出的拥塞控制方案,在 1990 年又出现了两种新的拥塞控制方案:「快速重传」和「快速恢复」

06 算法三:快速重传(Fast Retransmit)

之前重传的文章中我们介绍重传的时间间隔,要等几百毫秒才会进行第一次重传。聪明的网络协议设计者们想到了一种方法:「快速重传」

快速重传的含义是:当接收端收到一个不按序到达的数据段时,TCP 立刻发送 1 个重复 ACK,而不用等有数据捎带确认,当发送端收到 3 个或以上重复 ACK,就意识到之前发的包可能丢了,于是马上进行重传,不用傻傻的等到重传定时器超时再重传。

img

07 选择确认(Selective Acknowledgment,SACK)

这个有一个问题,发送 3、4、5 包收到的全部是 ACK=1001,快速重传解决了一个问题:需要重传。因为除了 2 号包,3、4、5 包也有可能丢失,那到底是只重传数据包 2 还是重传 2、3、4、5 所有包呢?

聪明的网络协议设计者,想到了一个好办法

  • 收到 3 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:3001] 区间的包我也收到了
  • 收到 4 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:4001] 区间的包我也收到了
  • 收到 5 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:5001] 区间的包我也收到了

这样发送端就清楚知道只用重传 2 号数据包就可以了,数据包 3、4、5 已经确认无误被对端收到。这种方式被称为 SACK(Selective Acknowledgment)。

如下图所示:

img

08 使用 packetdrill 演示快速重传

bash
 1 --tolerance_usecs=100000
 // 常规操作:初始化
 2 0  socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
 3 +0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
 4 +0 bind(3, ..., ...) = 0
 5 +0 listen(3, 1) = 0
 6
 7 +0  < S 0:0(0) win 32792 <mss 1000,sackOK,nop,nop,nop,wscale 7>
 8 +0  > S. 0:0(0) ack 1 <...>
 9 +.1 < . 1:1(0) ack 1 win 257
10
11 +0 accept(3, ... , ...) = 4
12 // 往客户端写 5000 字节数据
13 +0.1 write(4, ..., 5000) = 5000
14
15 +.1 < . 1:1(0) ack 1001 win 257 <sack 1:1001,nop,nop>
// 三次重复 ack
16 +0  < . 1:1(0) ack 1001 win 257 <sack 1:1001 2001:3001,nop,nop>
17 +0  < . 1:1(0) ack 1001 win 257 <sack 1:1001 2001:4001,nop,nop>
18 +0  < . 1:1(0) ack 1001 win 257 <sack 1:1001 2001:5001,nop,nop>
19 // 回复确认包,让服务端不再重试
20 +.1 < . 1:1(0) ack 5001 win 257
21
22 +0 `sleep 1000000`

用 tcpdump 抓包以供 wireshark 分析 sudo tcpdump -i any port 8080 -nn -A -w fast_retran.pcap,使用 packetdrill 执行上面的脚本。可以看到,完全符合我们的预期,3 次重复 ACK 以后,过了 15 微妙,立刻进行了重传

img

打开单个包的详情,在 ACK 包的 option 选项里,包含了 SACK 的信息,如下图:

img

09 算法四:快速恢复

当收到三次重复 ACK 时,进入快速恢复阶段。解释为网络轻度拥塞。

  • 拥塞阈值 ssthresh 降低为 cwnd 的一半:ssthresh = cwnd / 2
  • 拥塞窗口 cwnd 设置为 ssthresh
  • 拥塞窗口线性增加

10 慢启动、快速恢复中的快慢是什么意思

刚开始学习这部内容的时候,有一个疑惑,明明慢启动拥塞窗口是成指数级增长,那还叫慢?快速恢复拥塞窗口增长的这么慢,还叫快速恢复?

我的理解是慢和快不是指的拥塞窗口增长的速度,而是指它们的初始值。慢启动初始值一般都很小,快速恢复的 cwnd 设置为 ssthresh

11 演示丢包

下面我们来演示出现丢包重传时候,拥塞窗口变化情况

bash
// 回复这 10 段数据
+.2 < . 1:1(0) ack 1001 win 65535

// 预期会发出 20 MSS
+0 > . 1001:1101(100) ack 1 <...>
// ... 省略若干行
+0 > . 2901:3001(100) ack 1 <...>



// 3 秒再回复这 20 段数据,模拟网络延迟,发送端会在这期间重传
+3 < . 1:1(0) ack 3001 win 65535

这种情况下,我们来抓包看一下

img

本来应该发送 40 段数据的,实际上只发送了 20 段,因为 TCP 这个时候已经知道网络可能已经出现拥塞,如果发送更大量的数据,会加重拥塞。

拥塞避免把丢包当做网络拥塞的标志,如果出现了丢包的情况,必须调整窗口的大小,避免更多的包丢失。

拥塞避免是一个很复杂的话题,有很多种算法:TCP Reno、TCP new Reno、TCP Vegas、TCP CUBIC 等,这里不做太多的展开。

12 为什么初始化拥塞窗口 initcwnd 是 10

最初的 TCP 初始拥塞窗口值为 3 或者 4,大于 4KB 左右,如今常见的 web 服务数据流都较短,比如一个页面只有 4k ~ 6k,在慢启动阶段,还没达到传输峰值,整个数据流就可能已经结束了。对于大文件传输,慢启动没有什么问题,慢启动造成的时延会被均摊到漫长的传输过程中。

根据 Google 的研究,90% 的 HTTP 请求数据都在 16KB 以内,约为 10 个 TCP 段。再大比如 16,在某些地区会出现明显的丢包,因此 10 是一个比较合理的值。

13 小结

这篇文章主要以实际的案例讲解了拥塞控制的几种算法:

  • 慢启动:拥塞窗口一开始是一个很小的值,然后每 RTT 时间翻倍
  • 拥塞避免:当拥塞窗口达到拥塞阈值(ssthresh)时,拥塞窗口从指数增长变为线性增长
  • 快速重传:发送端接收到 3 个重复 ACK 时立即进行重传
  • 快速恢复:当收到三次重复 ACK 时,进入快速恢复阶段,此时拥塞阈值降为之前的一半,然后进入线性增长阶段

14 做一道练习题

设 TCP 的 ssthresh(慢开始门限)的初始值为 8(单位为报文段)。当拥塞窗口上升到 12 时网络发生了超时,TCP 使用慢开始和拥塞避免。试分别求出第 1 次到第 15 次传输的各拥塞窗口大小,备注:拥塞算法使用 tahoe,初始窗口为 1。