前言

在介绍TCP的拥塞控制算法之前,有必要先了解下TCP的滑动窗口,它是TCP流控的有效且重要方式之一。TCP连接的双端都包括了发送和接收窗口(滑动窗口)。

接收方收到数据后,会ACK告知发送方自己的接收窗口大小对应TCP首部的Window字段,这个窗口即通知窗口(Advertised-Window)或滑动窗口(Usable-Window)。

窗口

一些变量

TCP缓冲区使用一些指针变量计算滑动窗口,这些变量存储于TCB(Transmission Control Block)中,变量有些指向具体的Sequence Number值,有些计算所得。

作为发送方涉及变量:

  • SND.UNA(Send Unacknowledged):指向第一个已发送未收到ACK的Sequence Number。
  • SND.NXT(Send Next):指向下一个将要发送的字节数据对应的Sequence Number。
  • SND.WND(Send Window):发送窗口的大小,受接收方影响;注意不同于发送端的可用窗口大小。

前两个指向真实的缓冲区位置,第三个窗口大小是计算而来。更多变量见RFC 9293

如下图所示:
image-1703509640850

图片来源地址

由图还可以看到发送缓冲区被分成了4部分,对应Category #1、#2、#3、#4。

  • #1:表示已经发送并收到了ACK的字节数据。
  • #2:表示已经发送但是未收到ACK的字节数据。
  • #3:表示未发送,接收方准备好接收的数据区间。
  • #4:表示未发送,接收方未准备好接收的数据区间。

发送端的可用窗口大小(Usable Window Size)对应着Category #3的区域:
Usable Window Size = SND.UNA + SND.WND - SND.NXT

如果发送窗口内的数据均未ACK,那么可用窗口大小为0,此时不会继续发送数据。
如果发送窗口内数据均被ACK,那么SND.UNA = SND.NXT,可用窗口大小等于SND.WND。

如下是滑动后的示意图(32到36被ACK,并发出了46-51字节数据):
image-1703511954364

作为接收方涉及变量:

  • RCV.NXT(Receive Next):表示希望收到的下一个Sequence Number,也是指向接收窗口的第一个字节数据
  • RCV.WND(Receive Window):接收窗口的大小,对应ACK的Window字段。

如下图所示:
image-1703515404083

由图还可以看到接收缓冲区被分成了3部分,对应Category #1+2、#3、#4。

  • #1+2:1和2之所以没分开,是因为接收方不区分是否ACK了,该区域表示收到并回复ACK的数据。
  • #3:接收窗口,准备接收的数据的区间。
  • #4:未收到且不会进行接收数据的区域。

TCP首部Window字段

TCP首部对应的Window字段,占16位,单位字节,该字段最大表示65535个字节。

需要注意的是该字段表示的是当作为接收方(不管哪一端,属于全双工)的可接收窗口大小,用于TCP流控

滑动窗口

作为发送方或接收方都有一个滑动窗口,如果是发送数据方则对应着发送窗口(对应发送缓冲区图的 #2 + #3),如果是接收数据方则对应着接收窗口(对应接收缓冲区图的 #3)。

如下图所示,TCP窗口大小是如何进行调整的,看Client的发送窗口,Server的接收窗口:

示例1:理论的情况
image-1703517960949

图片来源地址

如图所示,步骤如下:
0、初始化Client和Server的滑动窗口大小都是360字节。

1、Client发送140字节后,SND.NXT=141,窗口大小仍为360,可用窗口减少到220

2、Server接收到数据后,进行了ACK,并发送了数据,此时的Rev.NXT变为141,滑动窗口大小仍为360。

3、Client收到Server的ACK后,SND.UNA变成和SND.NXT相等为141;发送窗口整体向右滑动140字节,可用窗口变成和滑动窗口大小相等为360。

这个过程中,两端的滑动窗口大小一直未变。

示例2:真实的情况
然而事实上,Server端收到数据后,可能并不是立马被应用程序消费。如果数据没有被及时消费,就需要减少接收方的滑动窗口大小。

为什么要减少滑动窗口大小呢?一方面表示Server端消费速度跟不上,需要控制下发送速度;另一方面防止Server的缓冲区溢出,导致数据丢失。

image-1703558410895

图片来源地址

如图所示,应用程序并没有读取全部收到的数据,仍留有100字节在buffer中:
0、初始化Client与Server的滑动窗口均为360

1、Client发送140字节数据到Server,此时,SND.NXT变成141,可用窗口大小从360变成220

2、Server收到140字节数据后,被应用程序消费40字节,仍有100字节在buffer中,此时 RCV.WND从360变成260,RCV.NXT为141(希望下次接收的Seq)

3、Client收到ACK后,被告知接收端的Window变成了260,那么 发送窗口SND.WND也要跟着变成260,另外SND.UNA=SND.NXT。

4、Client继续发送180字节数据,此时可用窗口变成80

5、Server收到数据后,由于应用繁忙,buffer中的数据仍没消费,此时的窗口大小从260变成80。回复ACK窗口大小为80

6、Client收到ACK后,被告知接收端的Window变成了80,那么 发送窗口SND.WND也要跟着变成80 ,另外SND.UNA=SND.NXT。

7、Client继续发送80字节数据,此时可用窗口变成0,发送窗口仍为80

8、Server收到数据后,由于应用繁忙,buffer中的数据仍没消费,此时的窗口大小从80变成0。回复ACK窗口大小为0

9、Client收到ACK后,被告知接收端的Window变成了0,那么 发送窗口SND.WND也要跟着变成0(移动左边界)

从两个示例可以看出来,当发送方收到了ACK后,SND.NXT = RCV.NXT

示例2有个问题,如果在Server接收数据后,buffer中数据被少部分消费,窗口如何变动,这个涉及到“糊涂窗口综合症”避免算法,往下看窗口管理模块。

窗口管理

内容参考RFC 9293

管理窗口说明

1、窗口的发送方会告诉对方自己可接收数据窗口的大小,使用Window字段。

2、在单向数据传输的场景中,即Client发送数据到Server端,Server端仅接收并ACK。在此场景中,那么ack的Segment中,序列号都是相同的,如果回复途中乱序,将没有办法根据Seq进行重排序,这不是一个严重的问题,但会导致发送端可能会根据旧的数据来调整窗口大小。一个解决办法是使用最大ACK的Segment中的Window字段,如果当前的ACK值比之前收到的小,则表示是乱序了。

3、窗口的大小至关重要,太大,会导致传输的数据超过可接收的数据,以致数据丢弃引发重传;太小,会使得网络传输效率低下。

4、接收方的窗口不应该缩小,如往左移动右边界;但是发送方需要能够应对窗口缩小,这可能导致"可用窗口"为变成负值

5、如果出现发送方的“可用窗口”变成负值的情况,发送方不应该发送新的数据;但是可以重传未ACK的数据(处于SND.UNA ~ SND.UNA + SND.WND之间),发送方也可能重传在SND.UNA + SND.WND后面的数据(超出了缩小后的滑动窗口),而不是直接超时关闭连接。如果窗口缩小到0,TCP实现必须使用探针来检测窗口防止“死锁”。

零窗口 - Zero Window

在真实场景中,如果接收端处理速度很慢,就会导致滑动窗口缩到0,此时,发送端不会再发送数据,但是何时可以继续发送呢?

TCP使用Zero-Window Probing技术(零窗口探测),TCP在实现时必须要实现零窗口探测来应对窗口变成0的情况,以免产生“死锁”。

一般探测3次,每次30-60s,如果还是0的话,便可能发RST断开连接,不同的操作系统实现可能有所不同。

糊涂窗口综合症 - Silly Window Syndrome

Silly Window Syndrome(SWS,糊涂窗口综合症)是一种小增量窗口移动模式(所谓小增量窗口,是指窗口的右边缘慢慢向右移动而产生的小段)。譬如,对于发送端,收到新的ACK时;对于接收端,buffer数据被慢慢消费。

我们知道几字节的数据发送对TCP/IP协议模式来说太不划算了,毕竟TCP和IP头加起来至少40字节起。对于以太网来说,网络上MSS(Maximum Segment Size)默认是1460字节(1500-40首部)。在RFC791中提到IP设备实现上至少接收576字节数据(MTU),对应MSS就是536字节。

所以,如果每次只发送几个字节,会大大影响TCP的效率。

解决SWS的方式就是避免响应小增量窗口,包括从发送端和接收端进行规避:

发送端算法
TCP实现上,发送端必须包括SWS规避算法。发送端的SWS规避算法决定着什么时候可以发送数据。

先了解下著名的Nagle算法,算法思路就是合并小包延迟发送,满足两个主要的条件之一:1)、等到Window Size >= MSS 或者Data Size >= MSS;2)、收到之前发送数据的ACK之后,才会发数据,否则先积攒数据。

对于Nagle算法,当接收方的ACK很快的时候,条件2)会很容易满足,所以小包也是可以发送的,它只是限制大量的小包发送。
Nagle算法默认是打开的,对于像telnet、ssh这种交互性强的发送小包的程序,考虑关闭Nagel算法。

值得一提的是,Nagle算法是合并小包进行延迟发送,是SWS规避算法的一种补充,不是严格意义上的SWS规避算法。
对于SWS规避算法更推荐的实现,可以发送数据的时机包括如下:

  • 发送队列数据和可用窗口大小 大于等于 MSS大小。
  • 所有发送数据都ACK了,且发送队列数据可以一次性全发送。
  • 所有发送数据都ACK了,且发送队列数据和可用窗口大小 大于等于 Fs * Max(SND.WND) 。
  • 超时时间,以免一直不发送。

其中Fs是一个计算因子,推荐是1/2;Max(SND.WND)连接以来最大的窗口大小。
超时时间一般为0.1~1秒,可以和零窗口探针时间一起用。
具体可以参考RFC9293

接收端算法
TCP实现上,接收端也必须包括SWS规避算法。接收端的SWS规避算法决定着什么时候可以将窗口右边缘向右移动。

假设总的接收缓冲区的空间是RCV.BUFF,数据在缓存区未被应用消费的区间是RCV.USER,没有被通知给发送方的将来可用区间是Reduction。
RCV.BUFF被分成3部分:RCV.USER、RCV.WND、Reduction。
image-1703578860324

连接静止状态下,RCV.WND = RCV.BUFF,RCV.USER = 0。

对于SWS规避算法,主要就是避免右边界以小增量向右移动。算法的前提是保持右边界的值固定(接收到数据后RCV.NXT增大,RCV.WND减少,RCV.NXT+RCV.WND和不变),然后满足如下条件时再更新窗口大小:

RCV.BUFF - RCV.USER - RCV.WND  >=
                    min( Fr * RCV.BUFF, Eff.snd.MSS )

Fr是一个因子,建议值1/2。
Eff.snd.MSS接收方自己的MSS,假设和发送方相同。

满足条件后,设置窗口大小为:RCV.WND = RCV.BUFF - RCV.USER

通常的效果是接收方的窗口大小增加Eff.snd.MSS或者1/2的缓冲区大小。

总结

窗口是TCP进行流控的有效手段,窗口包括了发送方的滑动窗口(发送窗口)和接收方的滑动窗口(接收窗口);发送方的滑动窗口同时包括了已发送未ACK的数据和可用窗口

不管窗口怎么滑动,发送窗口的第一个字节对应着SND.UNA指针,表示已发送未ACK的Sequence Number;接收窗口的第一个字节对应着RCV.NXT,表示下一个要收到的Sequence Number;RCV.USER并不在窗口内。