Search K
Appearance
Appearance
前面的文章介绍了 TCP 利用滑动窗口来做流量控制,但流量控制这种机制确实可以防止发送端向接收端过多的发送数据,但是它只关注了发送端和接收端自身的状况,而没有考虑整个网络的通信状况。于是出现了我们今天要讲的拥塞处理。
拥塞处理主要涉及到下面这几个算法
为了实现上面的算法,TCP 的每条连接都有两个核心状态值:
拥塞窗口指的是在收到对端 ACK 之前自己还能传输的最大 MSS 段数。
它与前面介绍的接收窗口(rwnd)有什么区别呢?
我们在 TCP 头部看到的 window 字段其实讲的接收窗口(rwnd)大小。
拥塞窗口初始值等于操作系统的一个变量 initcwnd,最新的 linux 系统 initcwnd 默认值等于 10。
拥塞窗口与前面介绍的发送窗口(Send Window)又有什么关系呢?
真正的发送窗口大小 = 「接收端接收窗口大小」与「发送端自己拥塞窗口大小」两者的最小值
如果接收窗口比拥塞窗口小,表示接收端处理能力不够。如果拥塞窗口小于接收窗口,表示接收端处理能力 ok,但网络拥塞。
这也很好理解,发送端能发送多少数据,取决于两个因素
发送端和接收端不会交换 cwnd 这个值,这个值是维护在发送端本地内存中的一个值,发送端和接收端最大的在途字节数(未经确认的)数据包大小只能是 rwnd 和 cwnd 的最小值。
拥塞控制的算法的本质是控制拥塞窗口(cwnd)的变化。
在连接建立之初,应该发多少数据给接收端才是合适的呢?
你不知道对端有多快,如果有足够的带宽,你可以选择用最快的速度传输数据,但是如果是一个缓慢的移动网络呢?如果发送的数据过多,只是造成更大的网络延迟。这是基于整个考虑,每个 TCP 连接都有一个拥塞窗口的限制,最初这个值很小,随着时间的推移,每次发送的数据量如果在不丢包的情况下," 慢慢 " 的递增,这种机制被称为「慢启动」
拥塞控制是从整个网络的大局观来思考的,如果没有拥塞控制,某一时刻网络的时延增加、丢包频繁,发送端疯狂重传,会造成网络更重的负担,而更重的负担会造成更多的时延和丢包,形成雪崩的网络风暴。
这个算法的过程如下:
第一步,三次握手以后,双方通过 ACK 告诉了对方自己的接收窗口(rwnd)的大小,之后就可以互相发数据了
第二步,通信双方各自初始化自己的「拥塞窗口」(Congestion Window,cwnd)大小。
第三步,cwnd 初始值较小时,每收到一个 ACK,cwnd + 1,每经过一个 RTT,cwnd 变为之前的两倍。过程如下图

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

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

假设 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 很小,则这个时间基本可以忽略不计
我们用 packetdrill 脚本的方式来看慢启动的过程。模拟服务端 8080 端口往客户端传送 100000 字节的数据,客户端的 MSS 大小为 1000。
+0 write(4, ..., 100000) = 100000packetdrill 脚本内容如下
--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 的数据包,抓包结果如下。

可以看到服务器一口气发了 10 段数据,然后等待客户端回复 ACK,因为我们没有写回复 ACK 的代码,所以过了 300ms 以后开始重传了。
第 2 步:确认这 10 段数据 在 write 调用后面增加确认 10 个段数据的脚本。理论上拥塞窗口 cwnd 会从 10 变为 20,预期内核会发出 20 段数据
+.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 的数据

第 3 步:确认发送的 20 段数据 再确认发送的 20 段数据,看看内核会发送出多少数据
// 确认这 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 段数据

第 4 步,确认发送的 40 段数据,理论上应该会发送 80 段数据,包序号区间:7001 ~ 15001
+.2 < . 1:1(0) ack 7001 win 65535抓包结果如下

上面的过程通过抓包的方式来验证了慢启动指数级增大拥塞窗口 cwnd 的过程。
慢启动拥塞窗口(cwnd)肯定不能无止境的指数级增长下去,否则拥塞控制就变成了「拥塞失控」了,它的阈值称为「慢启动阈值」(Slow Start Threshold,ssthresh),这是文章开头介绍的拥塞控制的第二个核心状态值。ssthresh 就是一道刹车,让拥塞窗口别涨那么快。
当 cwnd > ssthresh 时,拥塞窗口进入「拥塞避免」阶段,在这个阶段,每一个往返 RTT,拥塞窗口大约增加 1 个 MSS 大小,直到检测到拥塞为止。

与慢启动的区别在于

实际的算法是如下:,
以初始 cwnd = 1 为例,cwnd 变化的过程如下图

所以是每经过 1 个 RTT,拥塞窗口「大约」增加 1
前面介绍的慢启动和拥塞避免是 1988 年提出的拥塞控制方案,在 1990 年又出现了两种新的拥塞控制方案:「快速重传」和「快速恢复」
之前重传的文章中我们介绍重传的时间间隔,要等几百毫秒才会进行第一次重传。聪明的网络协议设计者们想到了一种方法:「快速重传」
快速重传的含义是:当接收端收到一个不按序到达的数据段时,TCP 立刻发送 1 个重复 ACK,而不用等有数据捎带确认,当发送端收到 3 个或以上重复 ACK,就意识到之前发的包可能丢了,于是马上进行重传,不用傻傻的等到重传定时器超时再重传。

这个有一个问题,发送 3、4、5 包收到的全部是 ACK=1001,快速重传解决了一个问题:需要重传。因为除了 2 号包,3、4、5 包也有可能丢失,那到底是只重传数据包 2 还是重传 2、3、4、5 所有包呢?
聪明的网络协议设计者,想到了一个好办法
这样发送端就清楚知道只用重传 2 号数据包就可以了,数据包 3、4、5 已经确认无误被对端收到。这种方式被称为 SACK(Selective Acknowledgment)。
如下图所示:

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 微妙,立刻进行了重传

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

当收到三次重复 ACK 时,进入快速恢复阶段。解释为网络轻度拥塞。
刚开始学习这部内容的时候,有一个疑惑,明明慢启动拥塞窗口是成指数级增长,那还叫慢?快速恢复拥塞窗口增长的这么慢,还叫快速恢复?
我的理解是慢和快不是指的拥塞窗口增长的速度,而是指它们的初始值。慢启动初始值一般都很小,快速恢复的 cwnd 设置为 ssthresh
下面我们来演示出现丢包重传时候,拥塞窗口变化情况
// 回复这 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这种情况下,我们来抓包看一下

本来应该发送 40 段数据的,实际上只发送了 20 段,因为 TCP 这个时候已经知道网络可能已经出现拥塞,如果发送更大量的数据,会加重拥塞。
拥塞避免把丢包当做网络拥塞的标志,如果出现了丢包的情况,必须调整窗口的大小,避免更多的包丢失。
拥塞避免是一个很复杂的话题,有很多种算法:TCP Reno、TCP new Reno、TCP Vegas、TCP CUBIC 等,这里不做太多的展开。
最初的 TCP 初始拥塞窗口值为 3 或者 4,大于 4KB 左右,如今常见的 web 服务数据流都较短,比如一个页面只有 4k ~ 6k,在慢启动阶段,还没达到传输峰值,整个数据流就可能已经结束了。对于大文件传输,慢启动没有什么问题,慢启动造成的时延会被均摊到漫长的传输过程中。
根据 Google 的研究,90% 的 HTTP 请求数据都在 16KB 以内,约为 10 个 TCP 段。再大比如 16,在某些地区会出现明显的丢包,因此 10 是一个比较合理的值。
这篇文章主要以实际的案例讲解了拥塞控制的几种算法:
设 TCP 的 ssthresh(慢开始门限)的初始值为 8(单位为报文段)。当拥塞窗口上升到 12 时网络发生了超时,TCP 使用慢开始和拥塞避免。试分别求出第 1 次到第 15 次传输的各拥塞窗口大小,备注:拥塞算法使用 tahoe,初始窗口为 1。