之前在知乎上分享网络编程知识文章的时候,有个网友私信给我留言了一条“能不能写一篇关于 TCP 滑动窗口原理的文章”。
当时没有立即回复,经过查询多方资料,发现这个 TCP 真的非常非常的复杂,就像一个清澈的小沟,你以为很浅,结果一脚踩下去,感觉深不可测。
虽然之前也总结过一些关于网络编程相关的技术知识,对于 TCP 协议栈也做过一些介绍,但是大体上都描述的比较简单,没有深入去了解,本篇在很大程度上弥补了我对计算机网络知识的空白。
话不多说,直接上干货!
在之前的文章中我们了解到,TCP 协议能保证网络上的计算机之间可靠无差错的数据传输,比如上传文件、下载文件、浏览网页等都得益于它,实际的应用场景非常广泛。
与 TCP 协议一并称霸天下的还有 UDP 协议,不过 UDP 协议虽然传输效率更高,但是并不保证数据传输正确性,相比 TCP 要稍逊一些。
事实上,TCP 协议经过多年的发展,已经成为实现数据可靠传输的标准协议,所谓可靠,就是确保数据准确的、不重复、无延迟的到达目的地,那 TCP 协议是如何实现这些特点的呢?
其实要实现数据可靠传输,并不简单,因为要考虑异常的情况比较多,例如数据丢失、数据顺序混乱、网络拥堵等,如果不能解决这些问题,也就无从谈起可靠传输。
总的来说,TCP 协议是通过序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现数据稳定可靠性的传输。
以下是 TCP 协议的报文格式。
TCP 报文段包括协议首部和数据两部分,协议首部的固定部分是 20 个字节,头部是固定部分,后面是选项部分。
下面是报文段首部各个字段的含义:
计算机之间使用 TCP 协议进行传输数据时,每次连接都需要经过 3 个阶段:创建连接、数据传送和释放连接,即传输数据之前,在发送端和接收端建立逻辑连接、然后传输数据、最后断开连接,它保证两台计算机之间比较可靠的数据传输。
当两个设备之间准备传输数据之前,TCP 会建立连接,创建连接的阶段需要三次握手,过程如下:
详细过程如下:
完成以上 3 次握手之后,可靠性连接建立完成,就可以进行数据传输了。
当数据传输完毕之后,TCP 会释放连接,连接的释放需要四次挥手,过程如下:
完成以上 4 次挥手之后,连接释放完成。
通过以上的介绍,我们可以描绘出一个简易版的 TCP 数据传输过程,如下图所示。
通过序列号与确认应答机制,是 TCP 实现数据可靠传输的方式之一,也是最为重要的基石。
但是在复杂的网络环境下,并不一定能如上图所描述的那样顺利的进行数据传输,例如数据包丢失,针对这种问题,TCP 使用了重传机制来解决。
当网络不稳定的时候,很容易出现数据包丢失,TCP 采用了哪些重传手段来解决数据包丢失问题呢?
常见的重传方式有以下几种:
超时重传,顾名思义,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发数据。
TCP 会在以下两种情况发生超时重传:
其中比较关键的就是超时重传时间如何来设定的问题。
我们先来看看正常的数据传输过程。
其中 RTT 指的是数据从网络一端传送到另一端所需的时间,也就是数据包发送出去的往返时间。
超时重传时间,我们以 RTO (Retransmission Timeout 超时重传时间)来表示。
当超时重传时间设定过大,会出现什么情况呢?如下图所示
当超时重传时间设定过小,又会出现什么情况呢?如下图所示
一路分析下来,可以得出如下结论:
因此可以得出一个结论,超时重发时间既不能设置过大,也不能设置过小,必须精准的计算。
以 Linux 操作系统为例,RTO 的计算过程如下!
其中SRTT
是计算平滑的RTT
,DevRTR
是计算平滑的RTT
与最新RTT
的差距,在 Linux 下,通常α = 0.125
,β = 0.25
,μ = 1
,∂ = 4
,至于这个数值是怎么算出的,答案就是大量的数据采集统计出来的。
实际算出来的超时重传时间RTO
的值应该略大于报文往返RTT
的值,符合预期值。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。
也就是说,每当遇到一次超时重传的时候,会将下一次超时时间间隔设为先前值的两倍,多次超时说明网络环境差,不宜频繁反复重发。
超时重传虽然能解决数据丢包的问题,但是超时重发时间有时候可能会较长,有没有一种更快的重传方式呢?
快速重传就是来补充超时重传机制中时间过长的问题。
简单的说,快速重传不像超时重传那样通过时间来驱动重发,而是通过次数来驱动重发。
当收到报文重复的 ACK 数量,到达一定的阀值(一般为3),TCP 会在定时器过期之前,检查丢失的报文段并重传丢失的报文段。
大致的工作方式,可以用如下图来描述!
在上图,发送方向接受方发出了 1、2、3、4、5 份数据,大致执行的过程如下:
因此,快速重传的工作方式是当收到相同的 ACK 报文数量到达一个阀值,默认是 3,会在定时器过期之前,重传丢失的报文段。
快速重传机制弥补了超时重传机制中时间过长的问题,但是它依然面临着另外一个问题,那就是重传的时候,是重传之前的一个还是重传所有的包?
例如上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?
根据 TCP 不同的实现,以上两种情况都有可能。
另外按照上文的分析,如果每发送一个数据,当收到确认应答再发送下一个数据包的传输模式下,基本上不会出现快速重传的可能。
关于快速重传的实际应用,我们会在滑动窗口中再次进行详解,目前只需要知道有这个方式就行。
为了解决不知道该重传哪些 TCP 报文,天才师们想出来了SACK
方法,英文全称:Selective Acknowledgment,也被称为选择性确认。
具体实现就是需要在 TCP 头部选项字段里加一个 SACK 的东西,接受方可以将缓存的数据地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
如下图,当发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有200~299
这段数据丢失,会将丢失的片段进行重发,以便提升数据传输可靠性效率。
需要主要的是,如果要支持 SACK 机制,必须发送方和接受方都要支持。在 Linux 操作系统中,开发者可以通过net.ipv4.tcp_sack
参数打开这个功能(Linux 2.4 后默认打开)。
最后再来讲讲 Duplicate SACK 方法,又称D-SACK
,这个方法实现主要是使用 SACK
和ACK
来告诉发送方有哪些数据被重复接收了,以防止 TCP 反复的重发。
我们用个案例来介绍D-SACK
的作用,例如 ACK 丢包的场景,如下图!
过程分析:
ACK 300
和 SACK 100~199
,告诉发送方100~299
的数据早已被接收了,因为 ACK 都到300
了,因此这个 SACK 可以称为D-SACK
。使用D-SACK
方法的好处,可以让发送方知道,是发出去的包丢了还是接收方回应的 ACK 包丢了,然后来决定是否需要继续重发包。
在 Linux 操作系统下,可以通过net.ipv4.tcp_dsack
参数来开启/关闭这个功能(Linux 2.4 后默认打开)。
在上文中,我们有介绍到 TCP 协议的数据传输机制,当两台计算机之间建立连接之后,就可以进行传输数据了,TCP 每发送一个数据,都要进行一次确认应答,当上一个数据包收到了确认应答了, 再发送下一个,从而保证数据的可靠传输。
这种传输方式,虽然可靠但是缺点也比较明显,传输数据的效率非常的低下,好比你现在跟某个人打电话,你说了一句话,只有等到对方回复了你,你才能说下一句,这显然不现实。
为解决这个问题,TCP 引入了滑动窗口,可以一次性向窗口中发送多个数据包并不需要依次等待接受方的确认应答,即使在往返时间较长的情况下,它也不会降低数据传输效率。
那什么是滑动窗口呢?我们以高速路的收费站为例,做一个类比介绍。
上过高速的同学应该都知道,在高速路上有一个入口收费站和一个出口收费站。TCP 也是一样的,除了入口有发送方滑动窗口,出口处也设立有接收方滑动窗口。
对于发送方滑动窗口,我们可以把数据包看成车辆,分类它们的状态:
Not Sent,Recipient Not Ready to Receive
部分,这些属于发送端未发送,同时接收端也未准备接收的数据Not Sent,Recipient Ready to Receive
部分,这些属于发送端未发送,但已经告知接收方的数据,其实已经在窗口中(发送端缓存)了,等待发送。Send But Not Yet Acknowledged
部分,这些属于发送端已发送出去,等到接收方接受的数据,属于窗口内的数据。Sent and Acknowledged
部分,这些属于已经发送成功并已经被接受的数据,这些数据已经离开窗口了。同样,对于接受方滑动窗口,我们也可以把数据包看成车辆,分类它们的状态:
Not Received
,表示还没有被接收的数据。Received Not ACK
,表示已经被接受但是还没有回复 ACKReceived and ACK
,表示已经被接受并回复了 ACK通过以上的描述,相信大家对滑动窗口已经有了初步的认识,在整个数据传输过程中,光线传输类似于高速公路,TCP 滑动窗口类似于收费站,通过收费站可以做到对车辆进行适当的流量控制,以防止高速公路出现拥堵,滑动窗口也有同样的作用。
下图就是发送方的滑动窗口样例图,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口。
含义解释:
#1
表示已发送并收到 ACK 确认的数据:1~31 字节#2
表示已发送但未收到 ACK 确认的数据:32~45 字节#3
表示未发送但总大小在接收方处理范围内:46~51字节#4
表示未发送但总大小超过接收方处理范围:52 字节以后当发送方把数据全部都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到接受方 ACK 确认之前是无法继续发送数据的。
当收到之前发送的数据32~36
字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来52~56
字节又变成了可用窗口,那么后续也就可以发送52~56
这 5 个字节的数据了。
问题来了,程序是如何精准的控制发送方的窗口数据呢?
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
含义解释:
SND.WND
:表示发送窗口的大小(大小是由接收方指定的)SND.UNA
:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是#2
的第一个字节SND.NXT
:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是#3
的第一个字节可用窗口大小
:是一个相对指针,通过SND.WND - (SND.NXT - SND.UNA)
公式计算得来接下来我们看看接收方的滑动窗口,接收窗口相对简单一些,根据处理的情况划分成三个部分。
含义解释:
#1
和#2
表示已成功接收并确认的数据,等待应用进程读取#3
表示未收到数据但可以接收的数据#4
表示未收到数据并不可以接收的数据其中三个接收部分,使用两个指针进行划分:
RCV.WND
:表示接收窗口的大小,它会通告给发送方RCV.NXT
:是一个绝对指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是#3
的第一个字节RCV.NXT + RCV.WND
计算得出,也就是#4
的第一个字节相比传统的发送一个包,然后等待确认应答再发送包的数据传输模型,滑动窗口这种一次性批量发包然后等待确认应答的传输方式,可以显著的提升数据传输效率,整个传输过程可以用如下图来描述。
上图中的 ACK 600 确认应答报文丢失,也不会影响数据传输,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就表示 700 之前的所有数据接收方都收到了,这种确认应答模式叫累计确认或者累计应答。
在上文中,我们提到滑动窗口有一个很关键字的参数,就是窗口大小。
通常,窗口的大小是由接收方来决定的,接收端告诉发送端自己还有多少缓冲区可以接收数据,防止发送的数据量过大导致接受方处理不过来,会触发发送方重发机制,从而导致网络流量的无端的浪费。
通过控制窗口大小,可以避免发送方的数据超过接收方的可用窗口,也就是大家常说的流量控制。
除此之外,计算机网络都处在一个共享的环境,难免会出现网络拥堵的现象。当网络出现拥堵时,流量控制的手段非常有限。
如果网络出现拥堵时,发送方继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,重传就会导致网络的更拥堵,于是会导致更大的延迟以及更多的丢包,此时可能就会进入恶性循环….
因此,当网络发生拥堵时,TCP 需要主动降低发送的数据量,避免发送方的数据填满整个网络,我们把这一行为称为拥塞控制。
下面我们再来细致的谈谈 TCP 是如何进行流量控制和拥塞控制的。
在上文中我们提到,TCP 通过接受方实际能接收的数据量来控制发送方的窗口大小,从而实现所谓的流量控制。
理想的情况下,假如不受外界影响,两台计算机在整个传输过程中,可以保持基本相同的窗口大小值。
而实际的情况,很难做到这一点,因为外部环境比较复杂,可能会出现一些意想不到的问题。
下面我们一起来看看有哪些因素可能会影响窗口大小值。
在实际的数据传输过程中,发送方的窗口大小主要依赖于接受方的可用窗口大小来计算。
因此如果接受方的可用窗口发生变化,发送方的窗口大小也必然会发生变化。
比较常见能影响接受方的可用窗口发生变化的因素,有如下几个:
我们先看看第一个案例!
根据上图描述,大致的过程说明如下:
当可用窗口都收缩为 0,会发生了窗口关闭,这个对数据传输会造成非常严重的影响,具体等会在说。
我们继续看看第二个案例!
当服务端系统资源非常紧张的时候,操作系统可能会直接减少了接收方的缓冲区大小,此时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。
根据上图描述,大致的过程说明如下:
因此,从上面的分析中可以发现,如果发生了先减少缓存,再收缩窗口,就会出现数据丢包的现象。
为了防止这种情况发生,TCP 规定是不允许先减少缓存又收缩窗口的,而是采用先收缩窗口,然后告知发送方,过段时间再减少缓存,这样就可以避免了丢包情况。
上文中我们提到当可用窗口都收缩为 0,会发生了窗口关闭,此时发送方会停止向接受方传递数据,直到接受方可用窗口变成非 0,再次向发送方通告可用窗口的大小,最后发送方再次重新发起数据传输。
如果这个接受方向发送方通告窗口的 ACK 报文在网络中丢失了,可能会造成死锁!
根据上图描述,大致的过程说明如下:
因此,当发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如果不采取干预措施,这种相互等待的过程,会造成了死锁的现象。
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的 0 窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
根据上图描述,大致的过程说明如下:
窗口探查探测的次数一般为 3 次,每次大约 30-60 秒(不同的操作系统实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。
在上文中,我们提到可能因为接受方阻塞,来不及取走接收窗口里的数据,导致发送方的窗口越变越小。
到最后,可能接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车,这样玩下去,司机迟早要破产不可。
要解决这个问题其实也不难,等乘客数量超过一半,也就是 25 个,就进行发车。
解决糊涂窗口综合症,主要从以下两个方向入手:
接收方通常的解决策略如下:
当窗口大小小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
发送方通常的解决策略如下:
使用 Nagle 算法来处理,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:
如果没满足上面条件中的一条,发送方就一直在囤积数据,直到满足上面的发送条件。
在上文中我们也提到,面对复杂的网络环境,TCP 的流量控制能解决的问题比较有限,尤其是当网络出现拥堵的时候,这个时候 TCP 会采用拥塞控制来解决。
拥塞控制,其目的就是避免发送方的数据填满整个网络!
为了在发送方调节所要发送的数据量,我们需要定义了一个叫做拥塞窗口的概念,使用cwnd
来表示。
在前面我们有提到过发送窗口swnd
大小,取自于接收窗口rwnd
的大小,假设没有外部因素的影响,两者基本上是约等于的关系。
由于入了拥塞窗口的概念后,此时发送窗口的值是 swnd = min(cwnd, rwnd)
,也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口cwnd
值的变化情况,取自于网络环境
cwnd
就会变小cwnd
就会变大那 TCP 是如何知道当前网络是否出现拥堵呢?
一般来说,只要发送方没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
当网络出现拥塞时,TCP 主要有以下四种主要的算法来控制发送量。
下面我们依次来看看具体的实现逻辑。
TCP 在刚建立完连接后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,防止一下子发送大量的数据,填充整个网络。
慢启动的算法,就一个规则:当发送方每收到一个 ACK,拥塞窗口cwnd
的大小就会加 1。
整个慢启动的过程,可以用下图来描述。
其中cwnd=1
表示可以传一个 MSS 大小的数据。
可以看出慢启动算法,发包的个数是成指数性的增长。
那是不是可以无限的增长呢?
答案肯定不是,慢启动有个门限ssthresh
变量值。
cwnd < ssthresh
时,使用慢启动算法cwnd >= ssthresh
时,就会使用拥塞避免算法下面我们再来看看拥塞避免算法!
当拥塞窗口cwnd
超过慢启动门限ssthresh
就会进入拥塞避免算法。
进入拥塞避免算法后,它的规则也比较简单:每当收到一个 ACK 时,cwnd
增加1/cwnd
。
假定ssthresh
为 8,可以用下图来描述增长过程。
当 8 个 ACK 应答确认到来时,每个确认增加1/8
,8 个 ACK 确认,cwnd
一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。
所以,可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。
就这么一直增长着,网络也可能会出现拥塞,一旦出现拥塞,就可能会出现丢包现象,这时就需要对丢失的数据包进行重传。
当触发了重传机制,也就进入了拥塞发生算法阶段。
当网络出现拥塞,也就是会发生数据包重传,此时会使用拥塞发生算法,重传机制主要上文介绍的两种:
当发生超时重传时,sshresh 和 cwnd 的值会发生如下变化:
ssthresh
设为cwnd/2
cwnd
重置为1
整个过程可以用如下图来描述!
当进入超时重传的时候,基本上就重新进入了慢启动过程,慢启动的过程就是会突然减少数据流的,然后重新开始。
除此之外,还有另一种快速重传的机制,当接收方发现丢了一个中间包的时候,发送方收到 3 次重复的 ACK,会进入快速重传阶段,不必等待超时重传。
当发生快速重传时,sshresh 和 cwnd 的值会发生如下变化:
cwnd
设为cwnd/2
ssthresh
设置为cwnd
然后进入快速恢复算法阶段。
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像超时重传那样重新开始。
进入快速恢复算法之后,主要的调整如下:
cwnd
设置为ssthresh + 3
( 3 的意思是确认有 3 个数据包被收到了)cwnd
增加 1ssthresh
设置为cwnd
,接着就进入了拥塞避免算法整个过程可以用如下图来描述!
采用快速恢复算法,依然可以保持比较高的数据传输,不至于向超时重传那样,重新开始!
本文整理了一些优秀网友分享的知识,结合自己的理解比较全面的探讨了 TCP 滑动窗口的原理,同时抽取了一些比较干活的内容进行介绍,希望对大家有所帮助。
实际上 TCP 涉及到的知识点比我们介绍的要多得多,如果有描述不对的地方,欢迎网友留言指出!
总的来说,TCP 滑动窗口主要有以下作用:
不会有人刷到这里还想白嫖吧?点赞对我真的非常重要!在线求赞。加个关注我会非常感激!
最近也无意间获得一份阿里大佬写的技术笔记,内容涵盖 Spring、Spring Boot/Cloud、Dubbo、JVM、集合、多线程、JPA、MyBatis、MySQL 等技术知识。需要的小伙伴可以点击如下链接获取,资源地址:技术资料笔记。