Skip to content

24 TCP 流量控制 - 滑动窗口

这篇文章我们来开始介绍 TCP 的滑动窗口。滑动窗口的一个非常重要的概念,是理解 TCP 精髓的关键,下面来开始这部分的内容吧。

如果从 socket 的角度来看 TCP,是下面这样的

img

TCP 会把要发送的数据放入发送缓冲区(Send Buffer),接收到的数据放入接收缓冲区(Receive Buffer),应用程序会不停的读取接收缓冲区的内容进行处理。

流量控制做的事情就是,如果接收缓冲区已满,发送端应该停止发送数据。那发送端怎么知道接收端缓冲区是否已满呢?

为了控制发送端的速率,接收端会告知客户端自己接收窗口(rwnd),也就是接收缓冲区中空闲的部分。

img

TCP 在收到数据包回复的 ACK 包里会带上自己接收窗口的大小,接收端需要根据这个值调整自己的发送策略。

01 发送窗口与接收窗口

一个非常容易混淆的概念是「发送窗口」和「接收窗口」,很多人会认为接收窗口就是发送窗口。

先来问一个问题,wireshark 抓包中显示的 win=29312 指的是「发送窗口」的大小吗?

img

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

02 TCP 包状态分类

从 TCP 角度而言,数据包的状态可以分为如下图的四种

img

  • 粉色部分#1 (Bytes Sent and Acknowledged):表示已发送且已收到 ACK 确认的数据包。
  • 蓝色部分#2 (Bytes Sent but Not Yet Acknowledged):表示已发送但未收到 ACK 的数据包。发送方不确定这部分数据对端有没有收到,如果在一段时间内没有收到 ACK,发送端需要重传这部分数据包。
  • 绿色部分#3 (Bytes Not Yet Sent for Which Recipient Is Ready):表示未发送但接收端已经准备就绪可以接收的数据包(有空间可以接收)
  • 黄色部分#4 (Bytes Not Yet Sent,Not Ready to Receive):表示还未发送,且这部分接收端没有空间接收

03 发送窗口(send window)与可用窗口(usable window)

发送窗口是 TCP 滑动窗口的核心概念,它表示了在某个时刻一端能拥有的最大未确认的数据包大小(最大在途数据),发送窗口是发送端被允许发送的最大数据包大小,其大小等于上图中 #2 区域和 #3 区域加起来的总大小

可用窗口是发送端还能发送的最大数据包大小,它等于发送窗口的大小减去在途数据包大小,是发送端还能发送的最大数据包大小,对应于上图中的 #3 号区域

窗口的左边界表示成功发送并已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小等于右边界减去左边界。

如下图所示

img

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

img

我们用 packetdrill 复现上面的现象

shell
    --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`

解析如下:

  • 一开始我们禁用了 Nagle 算法以便后面可以连续发送包。
  • 三次握手以后,客户端声明自己的窗口大小为 20 字节
  • 通过两次发包和确认前 31 字节的数据
  • 发送端发送 (32,46) 部分的 14 字节数据,滑动窗口的可用窗口变为 6
  • 发送端发送 (46,52) 部分的 6 字节数据,滑动窗口的可用窗口变为 0,此时发送端不能往接收端发送任何数据了,除非有新的 ACK 到来
  • 接收端确认 (32,52) 部分 20 字节的数据,可用窗口重现变为 20

滑动窗口变化过程如下:

img

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

img

抓包显示的 TCP Window Full不是一个 TCP 的标记,而是 wireshark 智能帮忙分析出来的,表示包的发送方已经把对方所声明的接收窗口耗尽了,三次握手中客户端声明自己的接收窗口大小为 20,这意味着发送端最多只能给它发送 20 个字节的数据而无需确认,在途字节数最多只能为 20 个字节。

04 TCP window full

我们用 packetdrill 再来模拟这种情况:三次握手中接收端告诉自己它的接收窗口为 4000,如果这个时候发送端发送 5000 个字节的数据,会发生什么呢?

是会发送 5000 个字节出去,还是 4000 字节?

脚本内容如下:

bash
--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`

抓包结果如下

img

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

过程如下

img

05 TCP Zero Window

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

img

逐个解释一下

一开始三次握手确定接收窗口大小为 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 来完美模拟上述的过程

bash
    --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`

抓包结果如下:

img

可以看到

  • No = 8 的包,发送端发送 80 以后,自己已经把接收端声明的接收窗口大小耗尽了,wireshark 帮我们把这种行为识别为了 TCP Window Full。
  • No = 9 的包,是接收端回复的 ACK,携带了 win=0,wireshark 帮忙把这个包标记为了 TCP Zero window
  • No = 10 ~ 25 的包就是我们前面提到的 TCP Zero Window Probe,但是 wireshark 这里识别这个包为了 Keep-Alive,之所以被识别为 Keep-Alive 是因为这个包跟 Keep-Alive 包很像。这个包的特点是:一个长度为 0 的 ACK 包,Seq 为当前连接 Seq 最大值减一。因为发出的探测包一直没有得到回应,所以会一直发送端会一直重试。重试的策略跟前面介绍的超时重传的机制一样,时间间隔遵循指数级退避,最大时间间隔为 120s,重试了 16,总共花费了 16 分钟

06 有等待重试的地方就有攻击的可能

与之前介绍的 Syn Flood 攻击类似,上面的零窗口探测也会成为攻击的对象。试想一下,一个客户端利用服务器上现有的大文件,向服务器发起下载文件的请求,在接收少量几个字节以后把自己的 window 设置为 0,不再接收文件,服务端就会开始漫长的十几分钟时间的零窗口探测,如果有大量的客户端对服务端执行这种攻击操作,那么服务端资源很快就被消耗殆尽。

07 TCP window full 与 TCP zero window

这两者都是发送速率控制的手段,

  • TCP Window Full 是站在发送端角度说的,表示在途字节数等于对方接收窗口的情况,此时发送端不能再发数据给对方直到发送的数据包得到 ACK。
  • TCP zero window 是站在接收端角度来说的,是接收端接收窗口满,告知对方不能再发送数据给自己。

08 作业题

  1. 关于 TCP 的滑动窗口,下面哪些描述是错误的?

    • A. 发送端不需要传输完整的窗口大小的报文
    • B. TCP 滑动窗口允许在收到确认之前发送多个数据包
    • C. 重传计时器超时后,发送端还没有收到确认,会重传未被确认的数据
    • D. 发送端不宣告初始窗口大小
  2. TCP 使用滑动窗口进行流量控制,流量控制实际上是对( )的控制。

    • A. 发送方数据流量
    • B. 接收方数据流量
    • C. 发送、接收方数据流量
    • D、链路上任意两节点间的数据流量