如果发送一个新的包,需要等待上一个包的 ACK 回来之后才能发送,这样效率显然是很低的。也就是每经过一个 RTT(RTT指的是一个数据包从发送到接收到ACK确认包的花费时间,RTT是会随着网络的变化而变化) 的时间,我们只能发送一个包,假设一个 RTT 是 100ms,那在一秒中我们甚至只能发送 10 个包,这完全是不可接受的。
其实我们在等待 ACK 的时候没有必要停止后续包的发送,因为网络传输虽然不稳定,但大部分包往往还是可达的,这样我们就可以获得数倍的传输效率提升。如果真的不幸遇到了丢包,接收端 ACK 姗姗来迟的时候,也就告诉了我们某个序列号之前的所有包全部收到,我们再根据一定的策略,尝试重新发送对应丢失的包就可以了。
所以自然而然的,发送方需要缓存已发出但尚未收到 ACK 的包,接收方收到包但没有被用户进程消费之前也得把收到的包留着。但是,缓存是有大小限制的,程序消费数据和链路传输数据的能力也是有限的,发送端和接受端都需要一种机制来限制可发送或者可接收数据的最大范围。
于是滑动窗口和拥塞窗口应运而生。
这两个算法核心都是为了防止网络中发送的包太多。但两者的目的不同,滑动窗口机制,可以用来控制流量,防止接收方处理不过来消息;同样基于窗口机制的拥塞控制算法,则用来处理网络上数据包太多的情况,以避免网络中出现拥塞。
滑动窗口实现了TCP流控制。首先明确滑动窗口的范畴:TCP是双工的协议,会话的双方都可以同时接收和发送数据。TCP会话的双方都各自维护一个发送窗口和一个接收窗口。各自的接收窗口大小取决于应用、系统、硬件的限制(TCP传输速率不能大于应用的数据处理速率)。各自的发送窗口则要求取决于对端通告的接收窗口。
滑动窗口解决的是流量控制的的问题,就是如果接收端和发送端对数据包的处理速度不同,如何让双方达成一致。接收端的缓存传输数据给应用层,但这个过程不一定是即时的,如果发送速度太快,会出现接收端数据overflow,流量控制解决的是这个问题。
接收端会建立一个滑动窗口,由接收方向发送方通告,TCP 首部里的 window 字段就是用来表示窗口大小的,窗口表示的就是接收方目前能接收的缓冲区的剩余大小。
但是发送方也会根据这个通告窗口的大小建立自己的滑动窗口。为了兼顾效率和可靠性,在发送方,所有未收到 ACK 的消息虽然可以发送,但是在收到 ACK 之前是一定要在缓冲区中保存的。
发送方的发送缓存内的数据都可以被分为4类:
其中类型2和3都属于发送窗口。
零窗口
但如果发送方一直没有收到 ACK,随着数据不断被发送,很快可用窗口就会被耗尽。在这种情况下,发送方也就不会继续发送数据了,这种发送端可用窗口为零的情况称为零窗口。
正常来说,等接收端处理了一部分数据,又有了新的可用窗口之后,就会再次发送 ACK 报文通告发送端自己有新的可用窗口(因为发送端的可用窗口是受接收端控制的)。
但是,万一要是 ACK 消息在网络传输中正好丢包了,那发送端还能感知到接收端窗口的变化吗?其实是不会的,在这个情况下,接收端就会一直等着发送端发送数据,而发送端也还会以为接收端仍然处于零窗口的状态,这样一直互相等待,就好像进入了死锁状态。
解决办法也很简单,我们可以再引入一个零窗口定时器,如果发送端陷入零窗口的状态,就会启动这个定时器,去定时地询问接收端窗口是否可用了,这也是在分布式系统中常见的处理丢包的方式之一。
接收方的缓存数据分为3类:
其中类型2属于接收窗口。
如果进程读取缓冲区速度有所变化,接收端可能也会改变接收窗口的大小,每次通告给发送端,就可以控制发送端的发送速度了。这就是所谓的滑动窗口,也就是流量控制机制。
在实际网络中,因为大量的包传输,可能导致中间某些节点的缓冲区满载,从而多余的包被丢弃,需要重新发送,情况越发恶化,最差的时候,网络上的包都是重传的包并且反复地丢弃;整个网络传输能力甚至可以降低为 0。
网络中每个节点不会有全局的网络通信情况,唯一能发现的就是自己的部分包丢了,这种时候它就有理由怀疑网络环境劣化,可能产生了拥塞。
TCP 是一个比较无私的协议,在这种情况下,会选择减少自己发送的包。当网络上大部分通信协议传输层都采用的是 TCP 协议时,在出现拥塞的情况下,大部分节点都会不约而同地减少自己传输的包,这样网络拥塞情况就会得到极大的缓解,一直处于比较好的网络状态。
所以我们就需要在发送端定义一个窗口CWND(congestion window),也就是拥塞窗口;
引入拥塞控制机制的 TCP 协议,发送端最大的发送范围是拥塞窗口和滑动窗口中较小的一个,即 Min [ rwnd, cwnd ]。拥塞窗口会动态地随着网络情况的变化而进行调整,大体上的策略是如果没有出现拥塞,我们扩大窗口大小,否则就减少窗口大小。
具体是如何实现的呢?经典拥塞控制算法主要包括四个部分:
在不确定拥塞是否会发生的时候,我们不会一上来就发送大量的包,而是会采用指数倍增窗口的大小,窗口大小从 1 开始尝试,然后尝试 2、4、8、16 等越来越大的窗口。
这样,倍增的方式窗口就会很快扩大;我们会在窗口大到一定程度后(达到慢启动门限ssthresh),减慢增加的速度,转成线性扩大窗口的方式,也就是每次收到新的 ACK 没有丢包的话只比上次窗口增大 1。整个过程看起来就像这样:
随着窗口进一步缓慢增加,终于有一天,网络还是遇到了丢包的情况,我们就会假定这是拥塞造成的。这个时候会进行超时重传或者快速重传。
总体而言,TCP 就是通过滑动窗口、拥塞窗口这两个简单的窗口实现了流量控制和拥塞控制。
滑动窗口由接收端控制,向发送端通告,这样就可以保证发送端发出的包数量上限是明确的,也就不会存在淹没接收端导致来不及处理的情况。
拥塞窗口由发送端控制,它会根据网络中的情况动态的调整,通过慢启动、拥塞避免、拥塞发生、快速恢复四个算法,就可以很好地调整窗口的大小。和滑动窗口一起限制了发送端最大的发送范围,从而保证了拥塞在网络上不会发生。