vlambda博客
学习文章列表

举一反三:TCP协议是如何实现网络级的流控的?


本篇文章大概5000字,阅读时间大约10分钟


作为一个面试题,总结在这里吧,也是和Netty的流控进行比较,比较中去理解,在我所知,流量控制的实现方式,有水位线机制,以及Netty的流量整形里的定时任务轮询检测机制,还有TCP这种滑动窗口机制,和令牌桶机制等。都需要了解一下。比如:

1、滑动窗口的工作机制,流控控制的是哪一方?

2、零窗口探测攻击原理

3、TCP包头常见的可选项

4、慢启动原理?

5、拥塞控制算法都有哪些?

举一反三:TCP协议是如何实现网络级的流控的?

首先,知道TCP协议进行成块数据有效传输的最重要的方法是TCP的滑动窗口协议,通过这个滑动窗口来进行流控,这对应用的开发,中间件的开发都有借鉴意义,比如很多算法都可以使用滑动窗口来解。


而且该机制也是理解TCP精髓的关键,这里可以结合《TCP/IP协议族》或者《深入理解TCP/IP》这两本书来看。本文大部分总结自这些书,以及网络资料。


如下是Java的Socket程序的一个底层结构:

举一反三:TCP协议是如何实现网络级的流控的?

TCP会把要发送的数据放入发送缓冲区(Send Buffer),接收到的数据放入接收缓冲区(Receive Buffer),应用程序会不停的读取接收缓冲区的内容进行处理。


流量控制做的事情是:如果接收缓冲区已满,发送端应该停止发送数据。那发送端怎么知道接收端缓冲区是否已满?为了控制发端速率,接收端会告知客户端自己接收窗口的size(rwnd),也就是接收缓冲区中空闲的部分:

举一反三:TCP协议是如何实现网络级的流控的?

TCP在收到数据包后,回复的ACK包里会带上自己接收窗口的大小,接收端需要根据这个值调整自己的发送策略。


一个非常容易混淆的概念是「发送窗口」和「接收窗口」,很多人会认为接收窗口就是自己的发送窗口,其实是不对的。比如在wireshark抓包中显示的win=12759B,这指的是向对方声明自己的接收窗口的大小:

举一反三:TCP协议是如何实现网络级的流控的?

对方收到以后,会把自己的「发送窗口」限制在12759B内。如果自己的处理能力有限,导致自己的接收缓冲区满,接收窗口大小为0,发送端此时会停止发数据。

 

一般的,TCP发送缓冲区里的数据的状态如下:

举一反三:TCP协议是如何实现网络级的流控的?

1、粉色部分(Bytes Sent and Acknowledged):表示已发送且已收到ACK确认的数据包。

2、蓝色部分(Bytes Sent but Not Yet Acknowledged):表示已发送但未收到ACK的数据包。发送方不确定这部分数据对端有没有收到,如果在一段时间内没有收到ACK,发送端需要重传这部分数据包。

3、绿色部分(Bytes Not Yet Sent for Which Recipient Is Ready):表示未发送但接收端已经准备就绪可以接收的数据包(有空间可以接收)

4、黄色部分(Bytes Not Yet Sent,Not Ready to Receive):表示还未发送,且这部分接收端没有空间接收


发送窗口表示在某个时刻一端能拥有的最大未确认的数据包大小(最大在途数据),发送窗口是发送端被允许发送的最大数据包大小,其大小等于上图中2区域和3区域加起来的总大小


可用窗口是发送端还能发送的最大数据包大小,它等于发送窗口的大小减去在途数据包大小,是发送端还能发送的最大数据包大小,对应于上图中的3号区域


发送窗口的左边界表示成功发送并已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小等于右边界减去左边界。当上图中的可用区域(绿色)的6个字节(46~51)发送出去,可用窗口区域减小到0,这个时候除非收到接收端的ACK数据,否则发送端将不能发送数据。

 

如果接收端无法处理数据了,此时接收端回复的ack里的win=0,表示不再接收数据,发端也别发了。。。 发端收到该ack,会将自己的滑动窗口大小减为0,即所谓的零窗口。


当发送端的滑动窗口变为0,经过一段时间接收端从高负载中缓过来,可以处理更多的数据包,如果发端不知道这个情况,它就会永远的等待了。于是TCP又设计了零窗口探测机制(Zero window probe),用来向接收端探测,判断对方的接收窗口是否变大了,本质就是发一个ACK包,这个包跟Keep-Alive包很像,是一个长度为0的包,Seq为当前连接的Seq的最大值减1。如果发出的探测包一直没被回应,那么TCP会一直重试。重试的策略和超时重传机制一样,时间间隔遵循指数级退避,最大时间间隔为120s,重试16次,总共花费16分钟。


相应的,该机制也能被黑客利用,即零窗口探测攻击。假设客户端向服务器发起下载大文件的请求,在接收少量几个字节以后把自己的接受window设置为0,回送给发送方(服务器),这会导致服务器的TCP进程不再发送文件,并且开始漫长的十几分钟时间的零窗口探测,如果有大量的客户端对服务端执行这种操作,那么服务端资源很快就被消耗殆尽。


相对的,有零窗口,就有满窗口,即TCP Full Window,假设三次握手中接收端告诉发送方,它的接收窗口为4000,如果这时发送方发送5000字节的数据,假设MSS(TCP允许从对方接受的最大报文段,TCP收发的是报文段)为1000,则每次发包的大小为1000,总共发了4次以后在途数据包字节数为4000,再发数据就会超过接收窗口的大小了,于是发送端暂停改了发送,等待在途数据包的确认。即此时发端是Full Window,不论是full window还是zero window,都是控制发端的发送速率的手段。

 

一句话总结:TCP使用滑动窗口进行流量控制,流量控制实际上是对发送方数据流量的控制。


在TCP包头里,和滑动窗口相关的可选项,常用的有:

举一反三:TCP协议是如何实现网络级的流控的?

MSS:最大段大小,是TCP允许从对方接收的最大报文段

SACK:选择确认选项

Window Scale:窗口缩放选项,即窗口缩放比例因子,会在syn报文中的可选项里指定。


以上,简单的总结了滑动窗口是什么,下面再看TCP滑动窗口的图形展示:

举一反三:TCP协议是如何实现网络级的流控的?

以上,将字节从1至11进行标号。接收方通告的窗口称为接收窗口,它覆盖了从第4字节到第9字节的区域,表明接收方已经确认了包括第3字节在内的数据,且通告窗口大小为6。窗口大小是与确认序号相对应的。此时发送方会计算它的可用窗口,该窗口表明多少数据可以立即被发送。当接收方确认数据后,这个滑动窗口不时地向右移动。窗口两个边沿的相对运动增加或减少了窗口的大小。可以使用三个术语来描述窗口左右边沿的运动:


1、窗口左边沿向右边沿靠近叫做窗口合拢。这种现象发生在数据被发送和确认时


2、窗口右边沿向右移动时将允许发送更多的数据,称之为窗口张开。这种现象发生在另一端的接收进程读取已经确认的数据并释放了TCP的接收缓存时


3、当右边沿向左移动时,称之为窗口收缩。RFC建议不要使用这种方式。但TCP必须能够在某一端产生这种情况时进行处理。


如果左边沿到达右边沿,则称其为一个零窗口,此时发送方不能够发送任何数据。


阶段小结:

1、发送方不必发送一个全窗口大小的数据

2、发送方收到来自接收方的一个报文段确认后,才把窗口向右边滑动。这是因为窗口的大小是相对于确认序号的

3、窗口的大小可以减小,但窗口的右边沿却不能够向左移动,同理,左边缘也不能向左移动,收到重复ACK,就丢弃

4、接收方在发送一个ACK前不必等待自己这边的窗口被填满。

 

下面梳理在网络拥堵的情况下,TCP协议是如何结合其它窗口机制来控制流量的。


假设在外网上搭建一个聊天服务器,让客户端(发送方)一开始便向网络发送多个报文段,直至到达服务器(接收方)通告的接收窗口大小为止,由于位于广域网内的发送方和接收方之间存在多个路由器,且可能存在速率较慢的链路,这就有可能出现一些问题——一些中间路由器必须缓存分组,并有可能耗尽存储器的空间。那么应该怎么解决呢?


为此,TCP设计了一个慢启动算法(slow start)。该算法为发送方的TCP增加了一个新窗口——拥塞窗口(congestion window),记为cwnd。当与另一个网络的主机建立TCP连接时,拥塞窗口被初始化为1个报文段大小(即另一端通告的报文段MSS大小)。发送方每收到对端一个ACK,发送方的拥塞窗口就增加一个报文段(cwnd以字节为单位,但是慢启动以报文段大小为单位进行增加)。发送方取拥塞窗口与通告的接收窗口中的最小值作为发送的上限。


以上,可以得知拥塞窗口是发送方使用的流量控制机制,而通告的接收端的接收窗口则是接收方使用的流量控制。发送方开始时发送一个MSS大小的报文段,然后等待ACK。当收到该ACK时,拥塞窗口从1增加为2MSS(慢启动以报文段大小为单位进行增加),即可以发送两个报文段。当收到这两个报文段的ACK时,拥塞窗口就增加为4MSS。


以上,拥塞窗口是一种指数增加的关系,在某些点上可能达到了当前网络的最大容量,于是中间路由器开始丢弃分组。这就表明通知了发送方,它的拥塞窗口开得太大了。


以上,也能隐隐发现,不论是滑动窗口还是拥塞窗口,这种流量控制机制虽然可以防止发送端向接收端发过量的数据,但它只关注了发端和接收端自身的状况,而没有考虑整个网络的状况。于是TCP协议就设计了拥塞处理算法。主要涉及到下面这几个算法:

1、慢启动(Slow Start)

2、拥塞避免(Congestion Avoidance)

3、快速重传(Fast Retransmit)

4、快速恢复(Fast Recovery)


为了实现上面的算法,TCP的每条连接都有两个核心状态值:除了前面刚刚说的拥塞窗口(Congestion Window,cwnd),还额外加了一个慢启动阈值(Slow Start Threshold,ssthresh)


再谈拥塞窗口(Congestion Window,cwnd):它指的是在收到对端ACK之前自己还能传输的最大MSS段数


拥塞窗口和接收窗口(rwnd)的关系

接收窗口(rwnd)接收端对发端的限制,是接收端还能接收的数据大小。拥塞窗口(cwnd)是发送端自己对自己的限制,是发送端在还未收到对端ACK之前还能发送的数据量大小。在TCP头部看到的window字段其实是接收窗口(rwnd)的大小。而拥塞窗口初始值等于操作系统的一个变量initcwnd,最新的linux系统initcwnd默认值等于10。

拥塞窗口与发送窗口(Send Window)的关系

前面总结了,如下图:

举一反三:TCP协议是如何实现网络级的流控的?

发送窗口大小=「接收端接收窗口大小」与「发送端自己拥塞窗口大小」两者的最小值。如果接收窗口比拥塞窗口小,表示接收端处理能力不够。如果拥塞窗口小于接收窗口,表示接收端处理能力ok,但网络拥塞。这也很好理解,


以上,可以得知,发端能发送多少数据,取决于两个因素

1、对方能接收多少数据(接收窗口)

2、自己为了避免网络拥塞主动控制不要发送过多的数据(拥塞窗口)

发送端和接收端不会交换cwnd这个值,这个值是维护在发送端本地内存中的一个值,发送端和接收端最大的在途字节数(未经确认的)数据包大小只能是rwnd和cwnd的最小值。


下面看拥塞处理算法中的慢启动算法

拥塞控制算法的本质是控制拥塞窗口(cwnd)的变化。在连接建立之初,应该发多少数据给接收端才是合适的呢?如果有足够的带宽,那么可以用最快的速度传输数据,但如果是一个缓慢的移动网络呢?如果发送的数据过多,只是造成更大的网络延迟。而基于整个考虑,每个TCP连接都有一个拥塞窗口的限制,最初这个值很小,随着时间的推移,每次发送的数据量如果在不丢包的情况下,会“慢慢”的递增,这种机制被称为「慢启动」。


故拥塞控制是从整个网络的大局观来思考的,如果没有拥塞控制,那么某一时刻网络的时延增加、丢包频繁,发送端疯狂重传,会造成网络更重的负担,而更重的负担会造成更多的时延和丢包,形成雪崩的网络风暴。这个算法的过程如下:

  • 三次握手后,双方通过ACK告诉了对方自己的接收窗口(rwnd)的大小,之后就可以互相发数据了

  • 双方各自初始化自己的「拥塞窗口」(Congestion Window,cwnd)大小

  • 拥塞窗口cwnd初始值较小时,每收到一个ACK,cwnd+1,每经过一个RTT,cwnd变为之前的两倍(指数级变化)。即发送方开始时发送一个报文段,然后等待ACK。当收到该ACK时,拥塞窗口从1增加为2,即可以发送两个报文段。当收到这两个报文段的ACK时,拥塞窗口就增加为4。在某些点上可能达到了互联网的最大容量,于是中间路由器开始丢弃分组。这就通知发送方它的拥塞窗口开得过大

 

以上,慢启动过程里的拥塞窗口(cwnd)肯定不能无止境的增长下去,它的上限的阈值称为「慢启动阈值」(Slow Start Threshold,ssthresh),这是拥塞控制的第二个核心状态:

  • 当cwnd<ssthresh时,拥塞窗口按指数级增长(慢启动)

  • 当cwnd>ssthresh时,拥塞窗口按线性增长(拥塞避免)


这也就引出了拥塞处理中的拥塞避免算法(Congestion Avoidance)


当cwnd>ssthresh时,拥塞窗口进入「拥塞避免」阶段,在这个阶段,每一个往返RTT,拥塞窗口大约增加1个MSS大小,直到检测到网络拥塞为止。它与慢启动的区别在于:

  • 慢启动的做法是RTT时间内每收到一个ACK,拥塞窗口cwnd就加1,也就是每经过1个RTT,cwnd翻倍

  • 拥塞避免的做法保守——每经过一个RTT将拥塞窗口加1,不管期间收到多少个ACK

 

以初始cwnd=1为例,cwnd变化的过程如下图

举一反三:TCP协议是如何实现网络级的流控的?


以上,慢启动和拥塞避免是1988年提出的拥塞控制方案,在1990年又出现了两种新的拥塞控制方案:「快速重传」和「快速恢复」


先看拥塞处理算法的快速重传算法(Fast Retransmit)

一般的,发送一个报文段,TCP发端会有一个重传定时器,会等几百毫秒,如果收不到对方的ack,那么进行一次重传,而网络协议设计者们想到了一种聪明的方法即「快速重传」。含义是当接收端收到一个不按序到达的数据段时,TCP立刻发送1个重复ACK,而不用等有数据捎带确认,当发送端收到3个或以上重复ACK,就意识到之前发的包可能丢了,于是马上进行重传,不用傻傻的等到重传定时器超时再重传。

 

拥塞处理算法的快速恢复算法

当发端收到三次重复的ACK时,会进入快速恢复阶段。可以理解此时网络轻度拥塞了。此时发端的拥塞阈值ssthresh降低为拥塞窗口值cwnd的一半,而拥塞窗口cwnd设置为ssthresh,然后进入线性增长阶段。


注意,这些算法里的快和慢,指的是拥塞窗口的初始值的选取。慢启动的初始值一般都很小,快速恢复的拥塞窗口的cwnd会设置为ssthresh。


小结

1、慢启动:拥塞窗口一开始是一个很小的值,然后每RTT时间,只要收到一个ack就翻倍(MSS为单位)

2、拥塞避免:当拥塞窗口达到拥塞阈值(ssthresh)时,拥塞窗口从指数增长变为线性增长,即一个RTT时间,才增加一个MSS

3、快速重传:发送端接收到3个重复ACK时立即进行重传,不需要等待自己的重传定时器,节省时间

4、快速恢复:当收到3次重复ACK时,进入快速恢复阶段,此时拥塞阈值降为之前的一半

5、由接收方提供的窗口的大小通常可以由接收进程控制,这将影响TCP的性能。所以应用层的Socket API允许用户设置发送和接收缓存的大小。接收缓存的大小是该连接上所能够通告的最大窗口大小。故可以通过修改Socket接收方缓存大小来提升性能。

6、对于滑动窗口,发送方收到来自接收方的一个报文段确认后,才把窗口向右边滑动。这是因为窗口的大小是相对于确认序号的

7、滑动窗口的大小可以减小,但窗口的右边沿却不能够向左移动,同理左边缘也不能向左移动,收到重复ACK,就丢弃

8、接收方在发送一个ACK前不必等待自己这边的窗口被填满

9、滑动窗口的发送窗口大小=在途窗口大小+可发送窗口大小(未发但是可发),代表发送端被允许发送的最大数据包大小


END


点亮在看,你最好看

~

阅读原文,获得更多精彩内容