Silly Window Syndrome

Silly Window Syndrome翻译成中文就是“糊涂窗口综合症”。正如你上面看到的一样,如果我们的接收方太忙了,来不及取走Receive Windows里的数据,那么,就会导致发送方越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的window,而我们的发送方会义无反顾地发送这几个字节。

要知道,我们的TCP+IP头有40个字节,为了几个字节,要达上这么大的开销,这太不经济了。

另外,你需要知道网络上有个MTU,对于以太网来说,MTU是1500字节,除去TCP+IP头的40个字节,真正的数据传输可以有1460,这就是所谓的MSS(Max Segment Size)注意,TCP的RFC定义这个MSS的默认值是536,这是因为 ​​RFC 791​​里说了任何一个IP设备都得最少接收576尺寸的大小(实际上来说576是拨号的网络的MTU,而576减去IP头的20个字节就是536)。

如果你的网络包可以塞满MTU,那么你可以用满整个带宽,如果不能,那么你就会浪费带宽。(大于MTU的包有两种结局,一种是直接被丢了,另一种是会被重新分块打包发送) 你可以想像成一个MTU就相当于一个飞机的最多可以装的人,如果这飞机里满载的话,带宽最高,如果一个飞机只运一个人的话,无疑成本增加了,也而相当二。

所以,Silly Windows Syndrome这个现像就像是你本来可以坐200人的飞机里只做了一两个人。 要解决这个问题也不难,就是避免对小的window size做出响应,直到有足够大的window size再响应,这个思路可以同时实现在sender和receiver两端。

  • 如果这个问题是由Receiver端引起的,那么就会使用 David D Clark’s 方案。在receiver端,如果收到的数据导致window size小于某个值,可以直接ack(0)回sender,这样就把window给关闭了,也阻止了sender再发数据过来,等到receiver端处理了一些数据后windows size 大于等于了MSS,或者,receiver buffer有一半为空,就可以把window打开让send 发送数据过来。
  • 如果这个问题是由Sender端引起的,那么就会使用著名的 ​​Nagle’s algorithm​​。这个算法的思路也是延时处理,他有两个主要的条件:1)要等到 Window Size>=MSS 或是 Data Size >=MSS,2)收到之前发送数据的ack回包,他才会发数据,否则就是在攒数据。

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

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

另外,网上有些文章说TCP_CORK的socket option是也关闭Nagle算法,这不对。TCP_CORK其实是更新激进的Nagle算法,完全禁止小包发送,而Nagle算法没有禁止小包发送,只是禁止了大量的小包发送。最好不要两个选项都设置。

传递效率

一个显而易见的问题是:单个发送字节单个确认,和窗口有一个空余即通知发送方发送一个字节,无疑增加了网络中的许多不必要的报文(请想想为了一个字节数据而添加的40字节头部吧!),所以我们的原则是:

a.尽可能一次多发送几个字节;

b:或者窗口空余较多的时候通知发送方一次发送多个字节。

​ 对于前者a我们广泛使用​​Nagle算法​​​,即:
​ *1. 若发送应用进程要把发送的数据逐个字节地送到TCP的发送缓存,则发送方就把第一个数据字节先发送出去,把后面的字节先缓存起来;
​ *2. 当发送方收到第一个字节的确认后(也得到了网络情况和对方的接收窗口大小),再把缓冲区的剩余字节组成合适大小的报文发送出去;
​ *3. 当到达的数据已达到发送窗口大小的一半或以达到报文段的最大长度时,就立即发送一个报文段;
​ 对于后者b我们往往的做法是让接收方等待一段时间,或者接收方获得足够的空间容纳一个报文段或者等到接受缓存有一半空闲的时候,再通知发送方发送数据。

拥塞控制

​ 网络中的​​链路容量​​和交换结点中的缓存和处理机都有着工作的极限,当网络的需求超过它们的工作极限时,就出现了拥塞。拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。

所以,TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了。

关于拥塞控制的论文请参看《​​Congestion Avoidance and Control​​》(PDF)

拥塞控制主要是四个算法:1)慢启动2)拥塞避免3)拥塞发生4)快速恢复。这四个算法不是一天都搞出来的,这个四算法的发展经历了很多时间,到今天都还在优化中。 备注:

  • 1988年,TCP-Tahoe 提出了1)慢启动,2)拥塞避免,3)拥塞发生时的快速重传
  • 1990年,TCP Reno 在Tahoe的基础上增加了4)快速恢复

常用的方法就是:

慢开始
  • 慢开始、拥塞控制
  • 快重传、快恢复
    一切的基础还是​​慢开始​​,这种方法的思路是这样的:
    -1. 发送方维持一个叫做“拥塞窗口”的变量,该变量和接收端口共同决定了发送者的发送窗口;
    -2. 当主机开始发送数据时,避免一下子将大量字节注入到网络,造成或者增加拥塞,选择发送一个1字节的试探报文;
    -3. 当收到第一个字节的数据的确认后,就发送2个字节的报文;
    -4. 若再次收到2个字节的确认,则发送4个字节,依次递增2的指数级;
    -5. 最后会达到一个提前预设的“慢开始门限”,比如24,即一次发送了24个分组,此时遵循下面的条件判定:(cwnd = crowde window ssthresh = slow start threshold)
    *a. cwnd < ssthresh, 继续使用慢开始算法;
    *b. cwnd > ssthresh,停止使用慢开始算法,改用拥塞避免算法;
    *c. cwnd = ssthresh,既可以使用慢开始算法,也可以使用拥塞避免算法;
    -6. 所谓拥塞避免算法就是:每经过一个往返时间RTT就把发送方的拥塞窗口+1,即让拥塞窗口缓慢地增大,按照线性规律增长;
    -7. 当出现网络拥塞,比如丢包时,将慢开始门限设为原先的一半,然后将cwnd设为1,执行慢开始算法(较低的起点,指数级增长);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jls00Z2X-1617028415912)(https://i.loli.net/2021/03/05/n1uREYFtrJpOBl9.png)]

上述方法的目的是在拥塞发生时循序减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够的时间把队列中积压的分组处理完毕。​​慢开始和拥塞控制​​​算法常常作为一个整体使用,而​​快重传和快恢复​​则是为了减少因为拥塞导致的数据包丢失带来的重传时间,从而避免传递无用的数据到网络。

快重传

快重传的机制是:
-1. 接收方建立这样的机制,如果一个包丢失,则对后续的包继续发送针对该包的重传请求;
-2. 一旦发送方接收到三个一样的确认,就知道该包之后出现了错误,立刻重传该包;
-3. 此时发送方开始执行“快恢复”算法:
*1. 慢开始门限减半;
*2. cwnd设为慢开始门限减半后的数值;
*3. 执行拥塞避免算法(高起点,线性增长);

TCP引入了一种叫Fast Retransmit 的算法,不以时间驱动,而以数据驱动重传。也就是说,如果,包没有连续到达,就ack最后那个可能被丢了的包,如果发送方连续收到3次相同的ack,就重传。Fast Retransmit的好处是不用等timeout了再重传。

比如:如果发送方发出了1,2,3,4,5份数据,第一份先到送了,于是就ack回2,结果2因为某些原因没收到,3到达了,于是还是ack回2,后面的4和5都到了,但是还是ack回2,因为2还是没有收到,于是发送端收到了三个ack=2的确认,知道了2还没有到,于是就马上重转2。然后,接收端收到了2,此时因为3,4,5都收到了,于是ack回6。示意图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mR17FZEX-1617028415916)(https://i.loli.net/2021/03/06/sDRqigS8jCfd3ZO.png)]

Fast Retransmit只解决了一个问题,就是timeout的问题,它依然面临一个艰难的选择,就是,是重传之前的一个还是重传所有的问题。对于上面的示例来说,是重传#2呢还是重传#2,#3,#4,#5呢?因为发送端并不清楚这连续的3个ack(2)是谁传回来的?也许发送端发了20份数据,是#6,#10,#20传来的呢。这样,发送端很有可能要重传从2到20的这堆数据(这就是某些TCP的实际的实现)。可见,这是一把双刃剑。

快速恢复

TCP Reno

这个算法定义在​​RFC5681​​。快速重传和快速恢复算法一般同时使用。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。 注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:

  • cwnd = cwnd /2
  • sshthresh = cwnd

然后,真正的Fast Recovery算法如下:

  • cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)
  • 重传Duplicated ACKs指定的数据包
  • 如果再收到 duplicated Acks,那么cwnd = cwnd +1
  • 如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。

如果你仔细思考一下上面的这个算法,你就会知道,上面这个算法也有问题,那就是——它依赖于3个重复的Acks。注意,3个重复的Acks并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到RTO超时,于是,进入了恶梦模式——超时一个窗口就减半一下,多个超时会超成TCP的传输速度呈级数下降,而且也不会触发Fast Recovery算法了。

通常来说,正如我们前面所说的,SACK或D-SACK的方法可以让Fast Recovery或Sender在做决定时更聪明一些,但是并不是所有的TCP的实现都支持SACK(SACK需要两端都支持),所以,需要一个没有SACK的解决方案。而通过SACK进行拥塞控制的算法是FACK(后面会讲)

TCP New Reno

于是,1995年,TCP New Reno(参见 ​​RFC 6582​​ )算法提出来,主要就是在没有SACK的支持下改进Fast Recovery算法的——

  • 当sender这边收到了3个Duplicated Acks,进入Fast Retransimit模式,开发重传重复Acks指示的那个包。如果只有这一个包丢了,那么,重传这个包后回来的Ack会把整个已经被sender传输出去的数据ack回来。如果没有的话,说明有多个包丢了。我们叫这个ACK为Partial ACK。
  • 一旦Sender这边发现了Partial ACK出现,那么,sender就可以推理出来有多个包被丢了,于是乎继续重传sliding window里未被ack的第一个包。直到再也收不到了Partial Ack,才真正结束Fast Recovery这个过程

我们可以看到,这个“Fast Recovery的变更”是一个非常激进的玩法,他同时延长了Fast Retransmit和Fast Recovery的过程。

sack方法

另外一种更好的方式叫:Selective Acknowledgment (SACK)(参看​​RFC 2018​​),这种方式需要在TCP头里加一个SACK的东西,ACK还是Fast Retransmit的ACK,SACK则是汇报收到的数据碎版。参看下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XSmDjFaX-1617028415918)(https://i.loli.net/2021/03/06/P2TB4SFYZUnewEI.jpg)]

这样,在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。于是就优化了Fast Retransmit的算法。当然,这个协议需要两边都支持。在 Linux下,可以通过tcp_sack参数打开这个功能(Linux 2.4后默认打开)。

这里还需要注意一个问题——接收方Reneging,所谓Reneging的意思就是接收方有权把已经报给发送端SACK里的数据给丢了。这样干是不被鼓励的,因为这个事会把问题复杂化了,但是,接收方这么做可能会有些极端情况,比如要把内存给别的更重要的东西。所以,发送方也不能完全依赖SACK,还是要依赖ACK,并维护Time-Out,如果后续的ACK没有增长,那么还是要把SACK的东西重传,另外,接收端这边永远不能把SACK的包标记为Ack。

注意:SACK会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆SACK的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源。详细的东西请参看《​​TCP SACK的性能权衡​​》

Duplicate SACK – 重复收到数据的问题

Duplicate SACK又称D-SACK,其主要使用了SACK来告诉发送方有哪些数据被重复接收了。​​RFC-2883 ​​​里有详细描述和示例。下面举几个例子(来源于​​RFC-2883​​)

D-SACK使用了SACK的第一个段来做标志,

  • 如果SACK的第一个段的范围被ACK所覆盖,那么就是D-SACK
  • 如果SACK的第一个段的范围被SACK的第二个段覆盖,那么就是D-SACK

示例一:ACK丢包

下面的示例中,丢了两个ACK,所以,发送端重传了第一个数据包(3000-3499),于是接收端发现重复收到,于是回了一个SACK=3000-3500,因为ACK都到了4000意味着收到了4000之前的所有数据,所以这个SACK就是D-SACK——旨在告诉发送端我收到了重复的数据,而且我们的发送端还知道,数据包没有丢,丢的是ACK包。

Transmitted  Received    ACK Sent
Segment Segment (Including SACK Blocks)

3000-3499 3000-3499 3500 (ACK dropped)
3500-3999 3500-3999 4000 (ACK dropped)
3000-3499 3000-3499 4000, SACK=3000-3500
---------

示例二,网络延误

下面的示例中,网络包(1000-1499)被网络给延误了,导致发送方没有收到ACK,而后面到达的三个包触发了“Fast Retransmit算法”,所以重传,但重传时,被延误的包又到了,所以,回了一个SACK=1000-1500,因为ACK已到了3000,所以,这个SACK是D-SACK——标识收到了重复的包。

这个案例下,发送端知道之前因为“Fast Retransmit算法”触发的重传不是因为发出去的包丢了,也不是因为回应的ACK包丢了,而是因为网络延时了。

Transmitted    Received    ACK Sent
Segment Segment (Including SACK Blocks)

500-999 500-999 1000
1000-1499 (delayed)
1500-1999 1500-1999 1000, SACK=1500-2000
2000-2499 2000-2499 1000, SACK=1500-2500
2500-2999 2500-2999 1000, SACK=1500-3000
1000-1499 1000-1499 3000
1000-1499 3000, SACK=1000-1500
---------

可见,引入了D-SACK,有这么几个好处:

1)可以让发送方知道,是发出去的包丢了,还是回来的ACK包丢了。

2)是不是自己的timeout太小了,导致重传。

3)网络上出现了先发的包后到的情况(又称reordering)

4)网络上是不是把我的数据包给复制了。

参考

​​1.滑动窗口算法基本原理与实践​​(javascript:void(0))

​2.leetcode​

​​3.TCP滑动窗口​​

​4.TCP协议的滑动窗口具体是怎样控制流量的?​

​酷 壳 – CoolShell:​

​5.TCP 的那些事儿(上)​

​6.TCP 的那些事儿(下)​