一、TCP 重传、滑动窗口、流量控制、拥塞控制
1.1、重传机制
在 TCP 中,当发送端的数据达到接受主机时,接收端主机会返回一个确认应答消息,表示已收到消息。但是在复杂的网络中,并一定能顺利正常的进行数据传输,,万一数据在传输过程中丢失了呢?
所以 TCP 针对数据包丢失的情况,会用重传机制解决:
- 超时重传
- 快速重传
- SACK
- D-SACK
1.1.1、超时重传
重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。
TCP 会在以下两种情况下发生超时重传:
- 数据包丢失
- 确认应答丢失
超时时间应该设置为多少呢?
我们先了解什么是 RTT(Round-Trip Time 往返时延),从下图中就可以知道:
RTT 指的是数据发送时刻到接收到确认的时刻的差值,也就是包的往返时间。超时重传时间是以 RTO(Retransmission Timeout 超时重传时间)表示。假设在重传的情况下,超时时间 RTO【较长或较短】时,会发生什么呢?
- 当超时时间 RTO 较大时,重发就很慢,丢了很久才重传,没有效率,性能差
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发
综合上面的,可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值
可能大家觉得超时重传时间 RTO 的计算并不复杂,就是在发送端发包时记下 t0,然后接收端再把这个 ack 回来时记一个 t1,于是 RTT = t1 - t0。但是没那么简单,这只是一个采样,不能代表普遍情况。实际上【报文往返 RTT 的值】是经常变化的,因为我们的网络也是时常变化的。就因为【报文往返 RTT 的值】是经常波动变化的,所以【超时重传时间 RTO 的值】应该是一个动态变化的值。
Linux 是如何计算 RTO 的呢?
- 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑的 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化
- 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况
1.1.2、快速重传
TCP 还有一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传。
如上图,发送出了 1,2,3,4,5 份数据:
- 第一份 Seq1 先送到了,于是就 ACK 回 2
- 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 ACK 回 2
- 后面的 Seq4 和 Seq5 都到了,但还是 ACK 回 2,因为 Seq 还是没有收到
- 发送端收到了三个 ACK = 2 的确认,知道了 Seq2 还没有到达,就会在定时器过期之前,重传丢失的 Seq2
- 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 ACK 回 6
由于 TCP 采用的是累计确认机制,即当接收端收到比期望序号大的报文段时,便会重复发送最近一次确认的报文段的确认信号,称之为冗余 ACK(duplicate ACK)
所以,快速重传机制的工作机制是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题,就是重传的时候,是重传一个,还是重传所有的问题?
举个🌰,假设发送端发了 6 个数据,编号的顺序是 Seq1 ~ Seq6,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4,Seq4,Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的,那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢?
- 如果只选择重传 Seq2 一个报文,那么重传的效率很低,因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
- 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4,Seq5,Seq6 的报文是已经被接受过了,对于重传 Seq4 ~ Seq6 这部分数据相当于做了一次无用功,浪费资源
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。所以就有了 SACK 方法。
1.1.3、SACK 方法
SACK 方法(Selective Acknowledgment),选择性确认
这种方式需要在 TCP 头部【选项】字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给【发送方】,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重传机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择这个 TCP 段进行重传。如果要支持 SACK ,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能。
1.1.4、Duplicate SACK
Duplicate SACK 又称为 D-SACK ,其主要使用了 SACK 来告诉【发送方】有哪些数据被重复接收了。下面举个🌰:
🌰一号:ACK 丢包:
- 【接收方】发给【发送方】的两个 ACK 确认应答报文都丢失了,所以发送方超时后,重传第一个数据包(3000~3499)
- 于是【接收方】发现数据是重复收到的,于是回了一个 SACK = 3000~3500,告诉【发送方】 3000~3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的数据都已被收到,所以这个 SACK 就代表着 D-SACK
- 这样【发送方】就知道了,数据没有丢,是【接收方】的 ACK 确认应答报文丢了
🌰二号:网络延时:
- 数据包(1000~1499)被网络延迟了,导致【发送方】没有收到 ACK 1500 的确认报文
- 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传之,被延迟的数据包(1000~1499)又到了【接收方】
- 所以【接收方】回了一个 SACK = 1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包
- 这样发送方就知道快速重传出发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了
可见,D_SACK 有这么几个好处:
- 可以让【发送方】知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了
- 可以知道是不是【发送方】的数据包被网络延迟了
- 可以知道网络中是不是把【发送方】的数据包给复制了
1.2、滑动窗口
TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包受到了应答,再发送下一个。这个模式就有点像面对面聊天,你一句我一句,这种方式的缺点就是效率极低。在 TCP 中就是数据包的往返时间长,通信效率低
为了解决这个问题, TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率,那么有了窗口,就可以指定窗口的大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
窗口的实现实际上是操作系统开辟的一个缓存空间,发送主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清楚。
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据【接收方】都收到了。这个模式就叫确认应答或者累计应答。
此时,窗口大小由哪一方决定呢?
TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以通常窗口的大小是由接收方的窗口来决定的。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。
发送方的滑动窗口:
根据情况分为四个部分
- #1 是确认并收到 ACK 确认的数据
- #2 是已发送但未收到 ACK 确认的数据
- #3 是未发送但总大小在接收方处理范围内(接收方还有空间)
- #4 是未发送但总大小超过接收方处理范围(接收方没有空间)
当发送方把数据【全部】都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了
当收到之前发送的数据的 ACK 确认应答报文后,如果发送窗口的大小没有变化,则滑动窗口往右边移动相应的大小,因为这个大小的数据被应答确认下来。接下来相应大小的字节数又变成了可用窗口,那么后续也就可以发送空闲大小的剩余数据了。
程序是如何表示发送方的四个部分呢?
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
- SND.WND:表示发送窗口的大小(大小是由接收方指定的)
- SND.UNA(Send Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节
- SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了
可用窗口大小 = SND.WND - (SND.NXT - SND.UNA)
接收方的滑动窗口:
- #1 + #2 是已成功接收并确认的数据(等待应用进程读取)
- #3 是未收到数据但可以接收的数据
- #4 未收到数据并不可以接收的数据
其中三个接收部分,使用两个指针进行划分:
- RCV.WND:表示接收窗口的大小,它会通告给发送方
- RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节
- 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了
接收窗口和发送窗口的大小是相等的吗?
并不是,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口就可以很快的空缺出来。那么新的接收窗口的大小,是通过 TCP 报文中的 Window 字段来告诉发送端,那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
1.3、流量控制
发送方不能无脑的发数据给接收方,靠考虑接收方的处理能力
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重传机制,从而导致网络流量的无端的浪费。
为了解决这一问题,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.UMA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大到了 200
1.3.1、操作系统缓冲区与滑动窗口的关系
上述的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内核缓冲区中的,而操作系统的缓冲区,会被操作系统调整。
当应用进程无法即使读取缓冲区的内容时,也会对我们的缓冲区造成影响
那么操作系统的缓冲区是如何影响发送窗口和接收窗口的呢?
直接来个🌰:
当应用程序没有及时读取缓存时,发送窗口和接收窗口的变化
考虑一下场景:
- 客户端做为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360
- 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据
过程说明:
- 客户端发送 140 字节数据后,可用窗口变为 220(360-140)
- 服务端收到 140 字节数据,但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260(360-100),最后发送确认信息时,将窗口大小通告给客户端
- 客户端收到确认和窗口通告报文后,发送窗口减少为 260
- 客户端发送 180 字节数据,此时可用窗口减少到 80
- 服务端收到 180 字节数据, 但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80(260-180),并在发送确认信息时,通过窗口大小给客户端
- 客户端收到确认和窗口通告报文后,发送窗口减少为 80
- 客户端发送 80 字节数据后,可用窗口耗尽
- 服务端收到 80 字节数据,但是应用进程依然没有读取任何数据,这 80 字节留在了缓冲区,于是接收窗口收缩到了 0,并在发送确认信息时,通过窗口大小给客户端
- 客户端收到确认和窗口通告后,发送窗口减少为 0
可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变。
🌰二号:
当服务端系统资源非常紧张的时候,操作系统可能会直接减少了接收缓冲区大小,这时应用程序又无法即使读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的情况。
具体过程:
- 客户端发送 140 字节的数据,于是可用窗口减少到了 220
- 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 120 字节,当收到 140 字节数据后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方
- 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看到自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40
- 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了
- 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值
所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象
为了防止这种情况的发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免这种情况
1.3.2、窗口关闭
如上述,TCP 通过让接收方指明希望从发送接收的数据大小(窗口大小)来进行流量控制。
如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭
窗口关闭潜在的危险:
接收方向发送通告窗口大小时,是通过 ACK 报文来通告的。
那么当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。
这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不采取措施,这种相互等待的过程,会造成了死锁的现象。
TCP 是如何解决窗口关闭时,潜在的死锁现象呢?
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
如果接收窗口依然为 0 ,那么收到这个报文的一方就会重新启动持续计时器
如果接收窗口不是 0 ,那么死锁的局面就可以被打破了
窗口探测的次数一般是 3 次,每次大约 30-60s(不同的实现方式可能会不一样)。如果三次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接
1.3.3、糊涂窗口综合症
如果接受方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
要知道,我们的 TCP + IP 头部有 40 个字节,为了传输那几个字节的数据,要搭上这么大的开销,这太不经济了。就好比一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴车司机,才敢这么玩,不然迟早破产。解决这个问题也不难,大巴车司机等乘客数量达到 25 个,才认定发车。
直接举个🌰:
接收方的窗口大小为 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层数据读取的能力如下:
- 接收方每接收 3 个字节,应用程序就只能缓存从缓冲区中读取 1 个字节的数据
- 在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节
由上图可知,窗口大小不断减少了,并且发送的数据都是比较小的:
所以,糊涂窗口综合症的现象是可以发生在发送方和接收方:
- 接收方可以通告一个小的窗口
- 而发送方可以发送小数据
于是要解决糊涂窗口综合症,就要同时解决上面两个问题就行:
- 让接收方不通告小窗口给发送方
- 让发送方避免发送小数据
怎么让接收方不通告小窗口呢?
接收方通常的策略如下:
当【窗口大小】小于 min(MSS,缓存空间 / 2),也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0 ,也就阻止了发送方再发数据过来
等到接收方处理了一些数据后,窗口大小 >= MSS ,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
怎么让发送方避免发送小数据呢?
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:
- 要等到窗口大小 >= MSS,并且数据大小 >= MSS
- 收到之前发送数据的 ACK 回包
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件
注意:如果接受方不能满足【不通告小窗口给发送方】,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话,Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体利用率依然很低。
所以接收方得满足【不通告小窗口给发送方】+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症
另外,Nagle 算法默认是开启的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或者 ssh 这样交互性比较强的程序,则需要关闭 Nagle 算法
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char* )&value, sizeof(int));
1.4、拥塞控制
有的小伙伴可能会问,为什么要有拥塞控制啊?不是有流量控制了吗?
前面的流量控制是避免【发送方】的数据填满【接收方】的缓存,但是并不知道网络中发生了什么。一般来说,计算机网络都处在同一个共享环境,因此也有可能会因为其他主机之间的通信使得网络拥堵。
在网络发送拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断放大。
所以,TCP 不能忽略网络上发生的事,它被设置成一个无私的协议,当网络发送拥堵时,TCP 会自我牺牲降低发送的数据量。于是就有了拥塞控制,控制的目的就是避免【发送方】的数据填满整个网络。为了在【发送方】调节所要发送的数据的量,定义了一个叫做【拥塞窗口】的概念。
什么是拥塞窗口?和发送窗口有什么关系呢?
拥塞窗口 cwnd 是发送方维护的一个状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过,发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送的窗口的值是 swnd = min(cwnd,rwnd),也就是拥塞窗口和接收窗口中的最小值
拥塞窗口 cwnd 变化的规则:
- 只要网络中没有出现拥塞, cwnd 就会增大
- 但网络中出现了拥塞,cwnd 就减少
那么怎么知道当前网络中是否出现了拥塞呢?
其实只要【发送方】没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
拥塞控制有哪些控制算法呢?
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
1.4.1、慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?
慢启动算法的规则:当发送方每收到一个 ACK ,拥塞窗口 cwnd 的大小就会加 1。
这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等,下面举个🌰:
连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据
当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
当收到 2 个的 ACK 确认应答后,cwnd 增加 2,于是就可以比之前多发 2 个,所以这一次能够发送 4 个
当这 4 个的 ACK 确认到来时,每个确认 cwnd 增加 1,4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个
可以看到慢启动算法,发包的个数是指数性的增长。那么什么时候慢启动涨到个头呢?
有一个叫慢启动门限 ssthresh(slow start threshold)状态变量:
- 当 cwnd < ssthresh 时,使用慢启动算法
- 当 cwnd >= ssthresh 时,就会使用【拥塞避免算法】
1.4.2、拥塞避免算法
前面说到,当拥塞窗口 cwnd 【超过】慢启动门限 ssthreshold 就会进入拥塞避免算法。
一般来说 ssthresh 的大小是 65535 字节。那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd 。接上前面的慢启动的🌰,现规定 ssthresh 为 8:
当 8 个 ACK 应答确认到来时,每个确认增加 1/8 ,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。
过程如下:
所以我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一点。
就这么一直增长着后,网络就会慢慢进入了拥塞的状态,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。当触发了重传机制,也就进入了【拥塞避免算法】
1.4.3、拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
- 快速重传
这两种使用的拥塞算法是不同的,接下来分别说说:
发生【超时重传】,则会使用【拥塞发生算法】:
- ssthresh 设为 cwnd / 2,
- cwnd 重置为 1 (是恢复为 cwnd 初始化值,这里假定为 cwnd 的初始化值为 1)
如何查看 系统的 cwnd 初始化值?
Linux 针对每一个 TCP 连接的 cwnd 初始化值是 10,也就是 10 个 MSS,我们可以用 ss-nli 命令查看每一个 TCP 连接的 cwnd 初始化值,如图:
拥塞发生算法的变化如图:
接着,就重新开始慢启动,慢性启动是会突然减少数据流的,这真是一旦【超时重传】,马上回到解放前。但是这种方式太激进,反应也很强烈,会造成网络卡顿。
发生【快速重传】,则也会使用【拥塞发生算法】不过有些许不同:
还有个更好的办法,前面我们说过【快速重传算法】。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:
- cwnd = cwnd / 2,也就是设置为原来的一半
- ssthresh = cwnd
- 进入快速恢复算法
1.4.4、快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没必要像 RTO 超时那么强烈
正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新:
- cwnd = cwnd / 2,也就是设置为原来的一半
- ssthresh = cwnd
然后进入,快速恢复算法如下:
- 拥塞窗口 cwnd = ssthresh + 3(3 的意思是确认有 3 个数据包被收到了)
- 重传丢失的数据包
- 如果再收到重复的 ACK ,那么 cwnd 增加 1
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 dulicated ACK 时的数据都已经收到,改恢复过程已经结束了,可以回到恢复之前的状态了,也即可以再次进入拥塞避免算法
快速回复算法变化过程如图:
Tip:在快速恢复算法过程中,为什么收到新的数据后,cwnd 设置回了 ssthresh?
- 在快速恢复的过程中,首先 ssthresh = cwnd / 2,然后 cwnd = ssthresh + 3 ,表示网络可能出现了阻塞,所以需要减小 cwnd 来避免,加 3 代表快速重传时已经确认收到了 3 个重复的数据包
- 随后继续重传丢失的数据包,如果再收到重复的 ACK ,那么 cwnd 增加 1 。加 1 代表每个收到的重复的 ACK 包,都已经离开了网络。这个过程的目的是尽快将丢失的数据包发送给目标。
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一部中的 sshtresh 的值,恢复过程结束
首先,快速恢复是拥塞发生后慢启动的优化,其首要目的仍然是降低 cwnd 来减缓拥塞,所以必然会出现 cwnd 从大到小的改变。
其次,过程 2(cwnd 逐渐加 1)的存在是为了尽快将丢失的数据包发给目标,从而解决拥塞的根本问题(三次相同的 ACK 导致的快速重传),所以这一过程中 cwnd 反而是逐渐增大的