TCP-4-流量控制

引言

如果发送方一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。

流量控制可以让发送方根据接收方的实际接收能力控制发送 的数据量

如果窗口固定

  • 客户端是接收方,服务端是发送方,
  • 假设接收窗口和发送窗口相同都为 200
  • 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响

image-20201103000537747

根据上图的流量控制,说明下每个过程(本次是把服务端作为发送方,所以没有画出服务端的接收窗口):

  1. 客户端向服务端发送请求数据报文
  2. 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字 节,同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321。
  3. 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节, RCV.NXT 也就指向 321,这意味着 客户端期望的下一个报文的序列号是 321,接着发送确认报文给服务端。
  4. 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法在继续发送数据。
  5. 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节, RCV.NXT 也就指向 441,接着 发送确认报文给服务端。
  6. 服务端收到对 80 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 321,于是可用窗口 Usable 增大到 80。
  7. 服务端收到对 120 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable 增大到 200。
  8. 服务端可以继续发送了,于是发送了 160 字节的数据后, SND.NXT 指向 601,于是可用窗口 Usable 减少到 40。
  9. 客户端收到 160 字节后,接收窗口往右移动了 160 字节, RCV.NXT 也就是指向了 601,接着发送 确认报文给服务端。
  10. 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 SND.UNA 指针 偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。

操作系统缓冲区与滑动窗口的关系

前面的流量控制例子假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中 所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。 当应用进程没法及时读取缓冲区的内容时,也会对缓冲区造成影响。

操作系统的缓冲区,是如何影响发送窗口和接收窗口的呢?

缓冲区与滑动窗口

考虑以下场景: 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360 ; 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。

image-20201104233144297

根据上图的流量控制,说明下每个过程:

  1. 客户端发送 140 字节数据后,可用窗口变为 220 (360 - 140)
  2. 服务端收到 140 字节数据,但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260 (360 - 100),最后发送确认信息时,将窗口大小通过给客户端
  3. 客户端收到确认和窗口通告报文后,发送窗口减少为 260
  4. 客户端发送 180 字节数据,此时可用窗口减少到 80
  5. 服务端收到 180 字节数据,但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区, 于是接收窗口收缩到了 80 (260 - 180),并在发送确认信息时,通过窗口大小给客户端
  6. 客户端收到确认和窗口通告报文后,发送窗口减少为 80
  7. 客户端发送 80 字节数据后,可用窗口耗尽
  8. 服务端收到 80 字节数据,但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,于是 接收窗口收缩到了 0,并在发送确认信息时,通过窗口大小给客户端
  9. 客户端收到确认和窗口通告报文后,发送窗口减少为 0。

可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变

缩小缓冲区

当服务端系统资源非常紧张,操心系统可能会直接减少接收缓冲区大小,应用程序会因为无法及时读取缓存数据,可能会出现丢包

image-20201104234046770

说明下每个过程:

  1. 客户端发送 140 字节的数据,于是可用窗口减少到了 220
  2. 服务端因为现在非常的繁忙,操作系统把接收缓存减少了 100 字节,当收到对端 140 数据确认报文后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大 小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方
  3. 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户 端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40
  4. 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了
  5. 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗 口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值。

所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。所以TCP 规定不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段间再减少缓存

窗口关闭

TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控 制。 如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭

窗口关闭存在潜在危险:接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。 当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个ACK报文丢失呢?

image-20201104234723110

这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,就会出现相互等待的死锁现象

TCP 是如何解决窗口关闭时,潜在的死锁现象呢?

为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。 如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。

image-20201104235633124

窗口探测

  • 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;
  • 如果接收窗口不是 0,那么死锁的局面就可以被打破了。

窗口探查探测的次数一般为 3 此次,每次次大约 30-60 秒(不同的实现可能会不一样,如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接

糊涂窗口综合症

如果接收方太忙了,来不及取走接收窗口里的数据,就会导致发送方的发送窗口越来越小。 到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症

要知道,我们的 TCP + IP 头有 40 个字节,如果只是为了传几个字节不太划算

考虑以下场景: 接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:

  1. 接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据;

  2. 在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节;

    image-20201106214438696

每个过程的窗口大小不断减少了,并且发送的数据都是比较小的了

糊涂窗口综合症的现象:

  • 接收方可以通告一个小的窗口
  • 而发送方可以发送小数据

解决糊涂窗口综合症的办法是:

  • 让接收方不通告小窗口给发送方:当 窗口大小 小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会 向发送方通告窗口为 0 ,也就阻止了发送方再发数据过来。 等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。

  • 让发送方避免发送小数据:使用 Nagle 算法,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:

    • 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
    • 收到之前发送数据的 ack 回包

    只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件

    Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。 可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每 个应用自己的特点来关闭)

    1
    setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int))