Search K
Appearance
Appearance
这篇文章我们来开始介绍 TCP 的滑动窗口。滑动窗口的一个非常重要的概念,是理解 TCP 精髓的关键,下面来开始这部分的内容吧。
如果从 socket 的角度来看 TCP,是下面这样的

TCP 会把要发送的数据放入发送缓冲区(Send Buffer),接收到的数据放入接收缓冲区(Receive Buffer),应用程序会不停的读取接收缓冲区的内容进行处理。
流量控制做的事情就是,如果接收缓冲区已满,发送端应该停止发送数据。那发送端怎么知道接收端缓冲区是否已满呢?
为了控制发送端的速率,接收端会告知客户端自己接收窗口(rwnd),也就是接收缓冲区中空闲的部分。

TCP 在收到数据包回复的 ACK 包里会带上自己接收窗口的大小,接收端需要根据这个值调整自己的发送策略。
一个非常容易混淆的概念是「发送窗口」和「接收窗口」,很多人会认为接收窗口就是发送窗口。
先来问一个问题,wireshark 抓包中显示的 win=29312 指的是「发送窗口」的大小吗?

当然不是的,其实这里的 win 表示向对方声明自己的接收窗口的大小,对方收到以后,会把自己的「发送窗口」限制在 29312 大小之内。如果自己的处理能力有限,导致自己的接收缓冲区满,接收窗口大小为 0,发送端应该停止发送数据。
从 TCP 角度而言,数据包的状态可以分为如下图的四种

发送窗口是 TCP 滑动窗口的核心概念,它表示了在某个时刻一端能拥有的最大未确认的数据包大小(最大在途数据),发送窗口是发送端被允许发送的最大数据包大小,其大小等于上图中 #2 区域和 #3 区域加起来的总大小
可用窗口是发送端还能发送的最大数据包大小,它等于发送窗口的大小减去在途数据包大小,是发送端还能发送的最大数据包大小,对应于上图中的 #3 号区域
窗口的左边界表示成功发送并已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小等于右边界减去左边界。
如下图所示

当上图中的可用区域的 6 个字节(46~51)发送出去,可用窗口区域减小到 0,这个时候除非收到接收端的 ACK 数据,否则发送端将不能发送数据。

我们用 packetdrill 复现上面的现象
--tolerance_usecs=100000
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
// 禁用 nagle 算法
+0 setsockopt(3, SOL_TCP, TCP_NODELAY, [1], 4) = 0
+0 bind(3, ..., ...) = 0
+0 listen(3, 1) = 0
// 三次握手
+0 < S 0:0(0) win 20 <mss 1000>
+0 > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 20
+0 accept(3, ..., ...) = 4
// 演示已经发送并 ACK 前 31 字节数据
+.1 write(4, ..., 15) = 15
+0 < . 1:1(0) ack 16 win 20
+.1 write(4, ..., 16) = 16
+0 < . 1:1(0) ack 32 win 20
+0 write(4, ..., 14) = 14
+0 write(4, ..., 6) = 6
+.1 < . 1:1(0) ack 52 win 20
+0 `sleep 1000000`解析如下:
滑动窗口变化过程如下:

这个过程抓包的结果如下图:

抓包显示的 TCP Window Full不是一个 TCP 的标记,而是 wireshark 智能帮忙分析出来的,表示包的发送方已经把对方所声明的接收窗口耗尽了,三次握手中客户端声明自己的接收窗口大小为 20,这意味着发送端最多只能给它发送 20 个字节的数据而无需确认,在途字节数最多只能为 20 个字节。
我们用 packetdrill 再来模拟这种情况:三次握手中接收端告诉自己它的接收窗口为 4000,如果这个时候发送端发送 5000 个字节的数据,会发生什么呢?
是会发送 5000 个字节出去,还是 4000 字节?
脚本内容如下:
--tolerance_usecs=100000
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0 bind(3, ..., ...) = 0
+0 listen(3, 1) = 0
// 三次握手告诉客户端告诉服务器自己的接收窗口大小为 4000
+0 < S 0:0(0) win 4000 <mss 1000>
+0 > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 4000
+0 accept(3, ..., ...) = 4
// 写客户端写 5000 字节数据
+0 write(4, ..., 5000) = 5000
+0 `sleep 1000000`抓包结果如下

可以看到,因为 MSS 为 1000,每次发包的大小为 1000,总共发了 4 次以后在途数据包字节数为 4000,再发数据就会超过接收窗口的大小了,于是发送端暂停改了发送,等待在途数据包的确认。
过程如下

TCP 包中 win= 表示接收窗口的大小,表示接收端还有多少缓冲区可以接收数据,当窗口变成 0 时,表示接收端不能暂时不能再接收数据了。我们来看一个实际的例子,如下图所示

逐个解释一下
一开始三次握手确定接收窗口大小为 360 字节。
第一步:发送端发送 140 字节给接收端,此时因为 140 字节在途未确认,所以它的可用滑动窗口大小为:360 - 140 = 220
第二步:接收端收到 140 字节以后,将这 140 字节放入 TCP 接收区缓冲队列。
正常情况下,接收端处理的速度非常快,这 140 字节会马上被应用层取走并释放这部分缓冲区,同时发送确认包给发送端,这样接收端的窗口大小(RCV.WND) 马上可以恢复到 360 字节,发送端收到确认包以后也马上将可用发送滑动窗口恢复到 360 字节。
但是如果因为高负载等原因,导致 TCP 没有立马处理接收到的数据包,收到的 140 字节没能全部被取走,这个时候 TCP 会在返回的 ACK 里携带它建议的接收窗口大小,因为自己的处理能力有限,那就告诉对方下次发少一点数据嘛。假设如上图的场景,收到了 140 字节数据,现在只能从缓冲区队列取走 40 字节,还剩下 100 字节留在缓冲队列中,接收端将接收窗口从原来的 360 减小 100 变为 260。
第三步:发送端接收到 ACK 以后,根据接收端的指示,将自己的发送滑动窗口减小到 260。所有的数据都已经被确认,这时候可用窗口大小也等于 260
第四步:发送端继续发送 180 字节的数据给接收端,可用窗口= 260 - 180 = 80。
第五步:接收端收到 180 字节的数据,因为负载高等原因,没有能取走数据,将接收窗口再降低 180,变为 80,在回复给对端的 ACK 里携带回去。
第六步:发送端收到 ACK 以后,将自己的发送窗口减小到 80,同时可用窗口也变为 80
第七步:发送端继续发送 80 字节数据给接收端,在未确认之前在途字节数为 80,发送端可用窗口变为 0
第八步:接收端收到 80 字节的数据,放入接收区缓冲队列,但是入之前原因,没能取走,滑动窗口进一步减小到 0,在回复的 ACK 里捎带回去
第九步:发送端收到 ACK,根据发送端的指示,将自己的滑动窗口总大小减小为 0
思考一个问题:现在发送端的滑动窗口变为 0 了,经过一段时间接收端从高负载中缓过来,可以处理更多的数据包,如果发送端不知道这个情况,它就会永远傻傻的等待了。于是乎,TCP 又设计了零窗口探测的机制(Zero window probe),用来向接收端探测,你的接收窗口变大了吗?我可以发数据了吗?
零窗口探测包其实就是一个 ACK 包,下面根据抓包进行详细介绍
我们用 packetdrill 来完美模拟上述的过程
--tolerance_usecs=100000
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+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 4000 <mss 1000>
+0 > S. 0:0(0) ack 1 <...>
// 三次握手确定客户端接收窗口大小为 360
+.1 < . 1:1(0) ack 1 win 360
+0 accept(3, ..., ...) = 4
// 第一步:往客户端(接收端)写 140 字节数据
+0 write(4, ..., 140) = 140
// 第二步:模拟客户端回复 ACK,接收端滑动窗口减小为 260
+.01 < . 1:1(0) ack 141 win 260
// 第四步:服务端(发送端)接续发送 180 字节数据给客户端(接收端)
+0 write(4, ..., 180) = 180
// 第五步:模拟客户端回复 ACK,接收端滑动窗口减小到 80
+.01 < . 1:1(0) ack 321 win 80
// 第七步:服务端(发送端)继续发送 80 字节给客户端(接收端)
+0 write(4, ..., 80) = 80
// 第八步:模拟客户端回复 ACK,接收端滑动窗口减小到 0
+.01 < . 1:1(0) ack 401 win 0
// 这一步很重要,写多少数据没关系,一定要有待发送的数据。如果没有待发的数据,不会进行零窗口探测
// 这 100 字节数据实际上不会发出去
+0 write(4, ..., 100) = 100
+0 `sleep 1000000`抓包结果如下:

可以看到
与之前介绍的 Syn Flood 攻击类似,上面的零窗口探测也会成为攻击的对象。试想一下,一个客户端利用服务器上现有的大文件,向服务器发起下载文件的请求,在接收少量几个字节以后把自己的 window 设置为 0,不再接收文件,服务端就会开始漫长的十几分钟时间的零窗口探测,如果有大量的客户端对服务端执行这种攻击操作,那么服务端资源很快就被消耗殆尽。
这两者都是发送速率控制的手段,
关于 TCP 的滑动窗口,下面哪些描述是错误的?
TCP 使用滑动窗口进行流量控制,流量控制实际上是对( )的控制。