为什么要有流量控制
发送方不能无脑的将数据发送给接收方,需要考虑接收方的处理能力。
如果一直无脑的把数据发送给接收方,但是对方就处理不过来,就会导致超时重传机制,从而导致网络流量的无端浪费。
为了解决在这种现象的发生,TCP提供一种机制可以让发送方根据接收方的实际接收能力控制发送的数据量,这就是所谓的流量控制。
流量控制的方法
现在举个例子:
- 客户端是接收方,服务端是发送方。
- 假设接收窗口和发送窗口相同都是200.
- 假设在发送过程两个设备都保持相同的窗口不受外界影响。
根据上图的流量控制,说明下每个过程:
- 客户端向服务端发送请求数据报⽂。这⾥要说明下,本次例⼦是把服务端作为发送⽅,所以没有画出服务端的接收窗⼝。
- 服务端收到请求报⽂后,发送确认报⽂和 80 字节的数据,于是可⽤窗⼝ Usable 减少为 120 字节,同时SND.NXT 指针也向右偏移 80 字节后,指向 321, 这意味着下次发送数据的时候,序列号是 321。
- 客户端收到 80 字节数据后,于是接收窗⼝往右移动 80 字节, RCV.NXT 也就指向 321, 这意味着客户端期望的下⼀个报⽂的序列号是 321,接着发送确认报⽂给服务端。
- 服务端再次发送了 120 字节数据,于是可⽤窗⼝耗尽为 0,服务端⽆法再继续发送数据。
- 客户端收到 120 字节的数据后,于是接收窗⼝往右移动 120 字节, RCV.NXT 也就指向 441,接着发送确认报⽂给服务端。
- 服务端收到对 80 字节数据的确认报⽂后, SND.UNA 指针往右偏移后指向 321,于是可⽤窗⼝ Usable 增⼤到 80。
- 服务端收到对 120 字节数据的确认报⽂后, SND.UNA 指针往右偏移后指向 441,于是可⽤窗⼝ Usable 增⼤到 200。
- 服务端可以继续发送了,于是发送了 160 字节的数据后, SND.NXT 指向 601,于是可⽤窗⼝ Usable 减少到40。
- 客户端收到 160 字节后,接收窗⼝往右移动了 160 字节, RCV.NXT 也就是指向了 601,接着发送确认报⽂给服务端。
- 服务端收到对 160 字节数据的确认报⽂后,发送窗⼝往右移动了 160 字节,于是 SND.UNA 指针偏移了 160后指向 601,可⽤窗⼝ Usable 也就增⼤⾄了 200。
操作系统缓冲区与滑动窗⼝的关系
前面流量控制的例子中,我们假定了接收方和发送方的窗口大小 不会变,但实际上滑动窗口的字节数是放在操作系统内存缓冲区中的,而操作系统的缓冲区的大小是会由操作系统进行动态调节的。
应用进程没有及时读取缓冲区的内容时,会对操作系统的缓冲区造成影响。
操作系统缓冲区是如何影响发送和接收窗口大小的?
第一个例子:
当接收窗口没有及时读取数据时,对发送窗口和接收窗口的影响。
场景:
- 客户端作为发送方,服务端作为接收方,发送和接收窗口大小始终是360;
- 服务器非常繁忙时,当收到客户端数据时不能及时读取数据。
现象:
客户端持续给服务端发送数据,服务端每次回复应答时会带上窗口大小。客户端发送数据直到服务端窗口大小为0时,停止发送数据。也就是发⽣了窗⼝关闭。
实际上,当发送⽅可⽤窗⼝变为 0 时,发送⽅实际上会定时发送窗⼝探测报⽂,以便知道接收⽅的窗⼝是否发⽣了改变。
第二个例子:
当服务器系统资源非常紧张的时候,会减小接收缓冲区的大小,这是应用程序又无法及时读取数据就会出现丢包的现象。如下图:
为了防⽌这种情况发⽣, TCP 规定是不允许同时减少缓存⼜收缩窗⼝的,⽽是采⽤先收缩窗⼝,过段时间再减少缓存,这样就可以避免了丢包情况。
窗口关闭
从前面的例子我们看出,TCP通过想发送方指明期望收到数据的大小(窗口大小),进行流量控制。
如果窗口大小为0时,会阻止发送方向接收方发送数据,直到窗口为非0为止,这就是窗口关闭。
窗口关闭的潜在危险
接收方向发送方通知的窗口大小时,是通过ACK报文通告的。
当发生窗口关闭时,接收方处理完数据会发送一个窗口非0的ACK报文,如果这个报文在网络中丢失了,那就麻烦大了。
这会导致发送方一直等待收到窗口非0的报文,接收方也一直在等待发送方发送数据,如果不采取措施这种互相等待的方式就会造成死锁。
TCP如何解决这种潜在的死锁现象?
为了解决这个问题, TCP 为每个连接设有⼀个持续定时器, 只要 TCP 连接⼀⽅收到对⽅的零窗⼝通知,就启动持续计时器。
如果持续计时器超时,就会发送窗⼝探测 ( Windowprobe ) 报⽂,⽽对⽅在确认这个探测报⽂时,给出⾃⼰现在的接收窗⼝⼤⼩。
- 如果接收窗⼝仍然为 0,那么收到这个报⽂的⼀⽅就会重新启动持续计时器;
- 如果接收窗⼝不是 0,那么死锁的局⾯就可以被打破了。
窗⼝探测的次数⼀般为 3 次,每次⼤约 30-60 秒(不同的实现可能会不⼀样)。如果 3 次过后接收窗⼝还是 0 的话,有的 TCP 实现就会发 RST 报⽂来中断连接。
糊涂窗口综合症
如果接收⽅太忙了,来不及取⾛接收窗⼝⾥的数据,那么就会导致发送⽅的发送窗⼝越来越⼩。
到最后, 如果接收⽅腾出⼏个字节并告诉发送⽅现在有⼏个字节的窗⼝,⽽发送⽅会义⽆反顾地发送这⼏个字节。这就是糊涂窗⼝综合症。
举一个糊涂窗口综合症的例子:
接收⽅的窗⼝⼤⼩是 360 字节,但接收⽅由于某些原因陷⼊困境,假设接收⽅的应⽤层读取的能⼒如下:
- 接收⽅每接收 3 个字节,应⽤程序就只能从缓冲区中读取 1 个字节的数据;
- 在下⼀个发送⽅的 TCP 段到达之前,应⽤程序还从缓冲区中读取了 40 个额外的字节;
每个过程的窗⼝⼤⼩的变化,在图中都描述的很清楚了,可以发现窗⼝不断减少了,并且发送的数据都是⽐较⼩的了。
要知道,我们的 TCP + IP 头有 40 个字节,为了传输那⼏个字节的数据,要达上这么⼤的开销,这太不经济了。
所以,糊涂窗⼝综合症的现象是可以发⽣在发送⽅和接收⽅:
- 接收⽅可以通告⼀个⼩的窗⼝
- ⽽发送⽅可以发送⼩数据
因此解决糊涂窗口综合症需要解决以上2个问题就够了。
- 如何让接收方不通告小窗口?
接收⽅通常的策略如下:
当「窗⼝⼤⼩」⼩于 min( MSS,缓存空间/2 ) ,也就是⼩于 MSS 与 1/2 缓存⼤⼩中的最⼩值时,就会向发送⽅通告窗⼝为 0 ,也就阻⽌了发送⽅再发数据过来。
等到接收⽅处理了⼀些数据后,窗⼝⼤⼩ >= MSS,或者接收⽅缓存空间有⼀半可以使⽤,就可以把窗⼝打开让发送⽅发送数据过来。 - 如何让发送窗口避免发送小数据呢?
发送方通常的策略是:
采用Nagle算法,该算法是延时处理的思路,它满足以下条件之一时才可以发送数据。
- 要等到窗⼝⼤⼩ >= MSS 或是 数据⼤⼩ >= MSS
- 收到之前发送数据的 ack 回包
只要没满⾜上⾯条件中的⼀条,发送⽅⼀直在囤积数据,直到满⾜上⾯的发送条件。
另外Nagle算法是默认打开的,如果对于⼀些需要⼩数据包交互的场景的程序,⽐如, telnet 或 ssh 这样的交互性⽐较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应⽤⾃⼰的特点来关闭)
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
参考:《图解网络-小林coding》