TCP的重传机制、滑动窗口、流量控制、拥塞控制
date
Nov 22, 2022
slug
tcp
status
Published
tags
计算机网络
summary
针对不稳定的网络环境提出的补偿机制
type
Post
简介
本文是阅读4.2 TCP 重传、滑动窗口、流量控制、拥塞控制 | 小林coding (xiaolincoding.com)的笔记,想要了解更多细节请看原文
先说结论
- 流量控制:针对发送方和接收方速度不匹配的问题,限制发送方的速度,是端对端的场景
- 拥塞控制:根据整个网络环境来调整发送速度,避免引起网络拥塞导致传输质量下降,是针对整个网络环境而言的
为了更详细的介绍这两种控制机制,我们先引入重传和滑动窗口这两个概念。
重传机制
针对网络环境不稳定,可能会出现丢包情况设计的补偿机制。一共有四种:超时重传,快速重传,SACK方法,D-SACK方法
首先说我们为什么需要重传?理想状态下的网络环境不会丢失,发送方发送一个,接收方就返回一个ACK确认消息。但是实际情况下,发送方的消息和接收方的ACK都有可能会丢失,如果不做重传补偿的话,丢失的数据是无法正常交付的。
针对这种情况,TCP设计了重传机制,一共有四种重传机制:
- 超时重传:设计一个时间间隔
- 快速重传:重复3次ACK就重传
- SACK 方法:通过额外信息告知发送方如何重传
- Duplicate SACK:告知发送方丢失包的原因
超时重传
核心思想就是设置一个定时器,如果一定时间内发送方没有接收到ACK,那么就视为数据包丢失,需要进行重传。
那么现在问题的难点在于怎么定义这个超时时间:
- 如果RT0过小的时候,可能会频繁传输已经接受到的数据
- 如果RT0过大,接收方会迟迟收不到丢失的数据包
为此TCP引入了
RTT( Round-Trip Time 往返时延)
的概念,RTT指的是数据发送时刻到收到确认时刻的差值,也就是接收ACK时间-发送时间。超时重传时间是
RT0(Retransmission Timeout)
,我们应该把RT0设置为略大于RTT。网络环境是不断变化的,RTT也是动态的,RTT的计算方法有很多,可以看这篇文章的介绍:4.2 TCP 重传、滑动窗口、流量控制、拥塞控制 | 小林coding (xiaolincoding.com)
超时重传的缺陷就是要设定这些超参数,快速重传设计思想不依赖于这些超参数。不过我们应该了解到重传机制并不是互斥的,我们可以在超时重传的基础上搭配快速重传。
快速重传
快速重传的核心思想很简单:收到三次重复的ACK就进行重传
比如上面的例子,发送方在发送了Seq3、4、5的时候累计收到了3次ACK2的信号,那么就会重发Seq2。
但其实这里还有一个问题,就是发送方应该重传所有包,还是单个包?
- 重传单个包:重传效率很低,如果ACK3也发生丢失,那么接下来收到3个ACK3,才能重传
- 重传所有包:如果没有发生丢失,会发送大量的重复数据,导致资源浪费
我们可以看出来这里问题的关键点在于,发送方不知道哪些包发生了丢失,因此无法很好的选择重传的范围。因此设计了SACK选项,让接收方告诉发送方应该重传哪些包。
SACK方法
SACK是TCP的一个选项,也就是头部字段里面的一个值,用来告知发送方真正接受的包。要使用SACK,两个设备都必须同时支持SACK。
上面这张图,在200~299的时候发送了丢失,接收方重传了3次ACK 200,此时接收方知道了200数据包发送丢失。而SACK返回了 300~500,这表示接收方已经接受了300~500的数据包。那么发送方此时根据ACK和SACK的值,重传200~299的数据包即可
DSACK方法
D-SACK即Duplicate SACK,它可以告知发送方哪些数据被重复接受了。
下面用两个例子来说明D-SACK的作用
例子1 ACK丢包
如果ACK号>SACK号,那么就表示这是一个D-SACK包。上面是ACK丢失的情况,此时出发超时重传,重发3000~3499的数据包,后续收到了ACK=4000,表示此时4000之前的数据都被收到了,同时SACK=3000~3500,那么表示3000~3500的数据包被重复接受了,发送方就会得知:是ACK丢失了
例子2 网络延时
这个例子中,ACK没有丢失,同时重发了三次,而1000~1499因为网络延迟没有到达,于是接收方重发了1000~1499。
后续接收方收到延时的数据后会重发一个ACK=3000,SACK=1000~1500,那么接收方就知道了重传的原因是:网络延时
我们可以通过上面两个例子得知D-SACK的好处:
- 可以让发送方知道重传的原因
- 可以知道哪些数据是被重复接收的。
而D-SACK和SACK的判断依据在于:
如果ACK>SACK,那么SACK就是一个D-SACK包,其中的数据是重复接收的
如果ACK<SACK,那么SACK就是表示已经接收的数据。
滑动窗口
提高通信效率
上面介绍的通信模式都是发送方发送一个包,接收方返回一个ACK。放到现实生活中就是A说一句话,B要等A说完再说话,就这样轮流交替。这种通信模式效率太低下了
为了解决这个问题,TCP引入了窗口的概念,窗口可以支持批量发送数据,窗口大小指的就是可以继续发送数据的最大值。
窗口的底层实际上是操作系统为网络通信开辟的一个缓存空间,发送主机在等到ACK之前,必须在缓冲区中保存发送过的数据,如果收到了ACK,那么就可以清除这些旧数据。
假设窗口大小为3,那么发送方可以连续发送3个包,如果中途有ACK丢失,可以根据最新的ACK来选择重发的数据。
比如说上图中,ACK 600丢失了,但是最新返回的是ACK 700,这表示700之前的数据都被接收了,600不需要重发就可以继续进行发送。
在HTTP1.1中,开启管道后也是类似的通信方式,每次都可以发送一批数据
窗口大小由哪一方决定?
发送速度和消费速度可能会不一致,我们应该考虑消费速度,避免接收方处理不过来。
TCP头里面有一个字段叫做
Window
,也就是窗口大小,一般是以接收方的窗口大小决定的。发送方的滑动窗口
发送方的滑动窗口可以分为四个部分:
- 已经发送且收到ACK的数据
- 发送窗口:已经发送但未收到ACK的数据
- 可用窗口:未发送但是可以发送的数据
- 未发送且超过接收方处理范围的数据
当发送方全部发送数据后,可用窗口就为0,发送窗口就是整个窗口大小了
如果此时收到37的ACK,那么就可以往后移动窗口了
注意发送方的滑动窗口移动依据是ACK号
发送方的窗口表达
SND.WND
:表示发送窗口的大小(大小是由接收方指定的);
SND.UNA
(Send Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
SND.NXT
:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要
SND.UNA
指针加上SND.WND
大小的偏移量,就可以指向 #4 的第一个字节了。
那么可用窗口大小的计算就可以是:
可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方的滑动窗口
RCV.WND
:表示接收窗口的大小,它会通告给发送方。
RCV.NXT
:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要
RCV.NXT
指针加上RCV.WND
大小的偏移量,就可以指向 #4 的第一个字节了。
流量控制
流量控制指的是端对端通信时,协调发送方的发送速度和接收方的处理速度,避免接收方处理不过来。TCP使用流量控制让发送方根据接收方的实际接受能力控制发送数据量。
假设接收方的可用窗口大小是400,那么这个值会在建立连接的时候告诉发送方,同时在通信过程中不断告知当前接收方的可用窗口大小
rwnd
,发送方会根据这个大小发送数据。简述一下就是返回的信息里面带有接收方的可用窗口大小,让发送方避免发出处理范围之外的数据。
另外这里有一个额外的知识点,那就是滑动窗口的大小依赖于缓冲区大小,如果操作系统调小了缓冲区大小,那么就会引起滑动窗口的收缩。而这个事件的发生顺序是要先收缩滑动窗口,再调小缓冲区
否则的话可能还没来得及读取滑动窗口内的数据,就已经被限制读取范围了,导致部分数据永远读取不到了。
窗口关闭
窗口关闭指的是接收方告知发送方可用窗口大小为0,此时发送方只有等待接收方告诉他可用窗口增加后才能发送数据。那么这里可能就有一个问题:非0窗口通知丢失。
如果非0窗口丢失了,那么就会出现循环等待的现象。
解决这个问题其实很简单,发送方收到0窗口通知后,就会开启一个计时器,一段时间后会发送一个探测报文,要求对方给出当前的可用窗口大小,那么这样就能避免死锁的现象了。
糊涂窗口综合症
就是接收方处理不过来,导致每次可用窗口大小都是几个字节,而发送方收到大小后就发送几个字节数据,这样的开销其实是很大的。
这个问题现象产生于:
- 接收方告诉小窗口给发送方
- 发送方可以发送小数据
解决这个问题只需要把这两个现象处理掉就好了
- 接收方不告知小窗口
- 发送方等待一批数据后再发送
怎么让接收方不通告小窗口呢?
接收方通常的策略如下:
当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为
0
,也就阻止了发送方再发数据过来。等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
怎么让发送方避免发送小数据呢?
发送方通常的策略如下:
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:
- 条件一:要等到窗口大小 >=
MSS
并且 数据大小 >=MSS
;
- 条件二:收到之前发送数据的
ack
回包;
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。
Nagle 伪代码如下:
注意,如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话(达到 Nagle 算法的条件二),Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。
所以,接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。
另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置
TCP_NODELAY
选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
拥塞控制
前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大....
所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
什么是拥塞窗口
TCP中一共有三种窗口:
- rwnd:接收窗口
- swnd:发送窗口
- cwnd:拥塞窗口
拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口
swnd
和接收窗口 rwnd
是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。拥塞窗口
cwnd
变化的规则:- 只要网络中没有出现拥塞,
cwnd
就会增大;
- 但网络中出现了拥塞,
cwnd
就减少;
那么怎么知道当前网络是否出现了拥塞呢?
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
拥塞控制有哪些控制算法?
拥塞控制主要是四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
慢启动
慢启动的核心思想是控制一开始发送方的发送速度,不要一上来速度就拉满。
规则:当发送方每接受一个ACK,拥塞窗口cwnd的大小就会加1
每个ACK都使得cwnd大小加1,而cwnd变大的同时还能发送多个数据包,因此它的增长速度就是指数性的增长。
但是cwnd并不是无限增长的,有一个叫做慢启动门限
ssthresh (slow start threshold
) 状态变量- 当cwnd < ssthresh,使用慢启动算法
- 当cwnd ≥ ssthresh,使用拥塞避免算法
拥塞避免
前面说道,当拥塞窗口
cwnd
「超过」慢启动门限 ssthresh
就会进入拥塞避免算法。一般来说
ssthresh
的大小是 65535
字节。那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的栗子,现假定
ssthresh
为 8
:- 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个
MSS
大小的数据,变成了线性增长。
拥塞避免算法的变化过程如下图:
也就是说一旦cwnd越过了阈值,那么就会变成线性增长
所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。
就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
当触发了重传机制,也就进入了「拥塞发生算法」。
拥塞发生算法
我们之前介绍了两种重传机制,分别是:
- 超时重传
- 快速重传
根据重传机制不同,拥塞发生算法也会不同。
发生超时重传的拥塞算法
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
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
;
- 进入快速恢复算法
快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像
RTO
超时那么强烈。正如前面所说,进入快速恢复之前,
cwnd
和 ssthresh
已被更新了:cwnd = cwnd/2
,也就是设置为原来的一半;
ssthresh = cwnd
;
然后,进入快速恢复算法如下:
- 拥塞窗口
cwnd = ssthresh + 3
( 3 的意思是确认有 3 个数据包被收到了);
- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。
很多人问题,快速恢复算法过程中,为什么收到新的数据后,cwnd 设置回了 ssthresh ?
- 在快速恢复的过程中,首先 ssthresh = cwnd/2,然后 cwnd = ssthresh + 3,表示网络可能出现了阻塞,所以需要减小 cwnd 以避免,加 3 代表快速重传时已经确认接收到了 3 个重复的数据包;
- 随后继续重传丢失的数据包,如果再收到重复的 ACK,那么 cwnd 增加 1。加 1 代表每个收到的重复的 ACK 包,都已经离开了网络。这个过程的目的是尽快将丢失的数据包发给目标。
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,恢复过程结束。
首先,快速恢复是拥塞发生后慢启动的优化,其首要目的仍然是降低 cwnd 来减缓拥塞,所以必然会出现 cwnd 从大到小的改变。
其次,过程2(cwnd逐渐加1)的存在是为了尽快将丢失的数据包发给目标,从而解决拥塞的根本问题(三次相同的 ACK 导致的快速重传),所以这一过程中 cwnd 反而是逐渐增大的。
总结:
- 首先减半cwnd,然后将阈值设置为减半后的cwnd
- 进入恢复阶段,此时的目标是将数据包尽可能的重传给对方,cwnd += 3表示之前接收了3个相同的ACK,然后进入线性增长
- 如果收到新的ACK,表示恢复阶段结束,将cwnd设置回阈值,避免拥塞