参考:
- 小林 coding : https://xiaolincoding.com/network/3_tcp/tcp_feature.html
- Carson :TCP Send Window, Receive Window, and How it Works
- Brunda K :TCP Series — TCP Receive Buffer and Receive Window
- Marek Majkowski :窗口尚未完全开放时,TCP 堆栈能实现超预期的功能
- 周二鸭 : 【TCP/IP】Nagle 算法以及所谓 TCP 粘包
重传机制
常见的重传机制:
- 超时重传:发送方在 RTO 内发送方没有收到接收方的 ack 确认应答报文。问题:超时周期可能相对较长
- 快速重传:接收方接收ack的顺序不对快速重传,发送方收到三个相同的 ack 时重传。问题:不知道重传 快速重传阶段 的哪些报文
- 只重传【三次重复 ack 的那一个报文】,效率低,在这之后丢失的还要继续三次快速重传过程;
- 重传【三次重复 ack 后的所有报文】,则可能重复发送已成功接收的。
- SACK:通过 sack 字段将【快速重传阶段】【已成功接收的数据段】告诉发送方,这样发送方只重传【快速重传阶段】【ack 三次那个以及之后没接收的】数据段。
- D-SACK:使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
超时重传
重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK
确认应答报文,就会重发该数据,也就是我们常说的超时重传。
而以下两种情况会收不到对方的 ACK 确认应答。即 TCP 会在以下两种情况发生超时重传:
- 数据包丢失
- 确认应答丢失
超时重传时间 RTO 的选择
RTT
指的是数据发送时刻到接收到确认的时刻的差值,也就是包的往返时间。- 超时重传时间是以
RTO
(Retransmission Timeout 超时重传时间)表示。
精确的测量超时时间 RTO
的值是非常重要的,这可让我们的重传机制更高效:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。
估计往返时间,通常需要采样以下两个:
- 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
- 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。
RFC6289 建议使用以下的公式计算 RTO:
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。
快速重传
- Seq2 因为某些原因没收到,Seq3 先到达了,于是还是 Ack 回 2;
- 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传一个,还是重传所有的问题。
举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的。 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?
-
如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
-
如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK
方法。
SACK 方法( Selective Acknowledgment, 选择性确认)
如果要支持 SACK
,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack
参数打开这个功能(Linux 2.4 后默认打开)。
这种方式需要在 TCP 头部「选项」字段里加一个 SACK
的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
Duplicate SACK (D SACK)
Duplicate SACK 又称 D-SACK
,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
在 Linux 下可以通过 net.ipv4.tcp_dsack
参数开启/关闭这个功能(Linux 2.4 后默认打开)。
详情见 小林 coding : https://xiaolincoding.com/network/3_tcp/tcp_feature.html
滑动窗口
引入原因
我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。
这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。
为解决这个问题,TCP 引入了窗口这个概念。那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待ACK确认应答,而可以继续发送数据的最大值。
假设窗口大小为 3
个 TCP 段,那么发送方就可以「连续发送」 3
个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:
- 图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答。
窗口大小由哪一方决定?
TCP 头里有一个字段叫 Window
,也就是窗口大小。
这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
所以,通常窗口的大小是由接收方的窗口大小来决定的。
发送方的【滑动】窗口
已发送但是还未收到ACK确认的数据会占据滑动窗口的大小
在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
在下图,当收到之前发送的数据 32~36
字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则 滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52~56
字节 又变成了可用窗口,那么后续也就可以发送 52~56
这 5 个字节的数据了。
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
(1) SND.UNA 滑动窗口起始(绿蓝交界)(2)SNA.NEXT 未发送部分的起始(蓝黄交界)(3)SND.WND 发送窗口大小(蓝色+黄色 的宽度)
可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收缓冲区和接收窗口
两个指针:(1)RCV.WND 接收窗口大小(2)RCV.NXT 接收窗口起始
TCP 接收缓冲区用于保存应用程序尚未处理(通过 read/recv 系统调用消耗)的 TCP 数据。
- Recv-q:等待
read()
的实际应用程序字节所占用的缓冲区预算部分。 - metadata:驻留在接收缓冲区中的元数据,包括 struct sk_buff 的成本等。
- 这两个部分一起由
ss
报告为 skmem_r,而内核名称是 sk_rmem_alloc。 - 剩余部分是“空闲”的,即尚未被主动使用。
- 但是,这个“空闲”区域的一部分是 公告窗口,可能很快就会被应用程序数据占用。
- 空闲区域剩余部分将用于未来的元数据处理,或者可以在未来进一步划分到公告窗口中。
- 接收缓冲区总长度(由 ss 显示为 skmem_rb)
接收缓冲区总长度 和 窗口占可用空间上限 的调整:
- 接收缓冲区总长度(由 ss 显示为 skmem_rb)由 Linux 的自动调整 确定,该自动调整考虑了各种参数(其中之一是应用程序读取数据的速率)。 skmem_rb 可以达到的最大值由 net.ipv4.tcp_rmem 控制。
- 窗口上限通过
tcp_adv_win_scale
设置进行配置。默认情况下,窗口设置为“空闲”空间的最多 50%。该值可以进一步通过 TCP_WINDOW_CLAMP 选项或内部rcv_ssthresh
变量进行限制。
Linux 内核 skmem_rb 同时为套接字中的数据和元数据设置了内存预算。在悲观的情况下,每个数据包可能产生一个 struct sk_buff + struct skb_shared_info 的开销。所以 Linux 不能直接将 100% 的内存预算作为公告窗口进行公告。一些预算必须保留用于元数据等。
接收窗口和发送窗口的大小是相等的吗?
并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。
在滑动窗口协议方法中,当我们在发送方和接收方之间建立连接时,会创建两个缓冲区。这两个缓冲区中的每一个都分配给发送方(称为发送窗口)和接收方(称为接收窗口)。
发送系统发送的字节数不能超过接收系统接收缓冲区中可用空间的字节数。
如果一直无脑的发数据给接收方,但对方处理不过来,都在那么就会导致触发重发机制(接收方不能及时回复 ACK),从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
如何进行流量控制?
缓冲区的大小由操作系统
在接收方的缓冲区,应用程序 没有及时读取数据,发送的 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80 (260 - 180)。并在【发送确认信息时(ACK)】,通过 Window 字段告知发送方。发送端收到确认和窗口通告报文后,发送窗口减少为 80。
上面的 Win 标识 就是接收端告诉发送端自己还有多少缓冲区可以接收数据。
接收方应当先收缩窗口,过段时间(发送方接收到 win 字段)再减少缓存,这样可以避免丢包情况。
窗口关闭
如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
接收方向发送方通告窗口大小时,是通过 ACK
报文(意味着没有对这个ACK的确认和重传,丢失不会被发现)来通告的。
那么,当接收方处理完数据,窗口关闭结束后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口变为非 0 的 ACK 报文在网络中丢失了,那就会造成双方死锁等待。
TCP 是如何解决窗口关闭时,潜在的死锁现象呢?
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
糊涂窗口综合征
如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。
到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
要知道,我们的 TCP + IP
头有 40
个字节,为了传输那几个字节的数据,要搭上这么大的开销,这太不经济了。
就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。
要解决糊涂窗口综合症,要同时解决:
- 让接收方不通告小窗口给发送方
- 让发送方避免发送小数据:Nagle 算法
怎么让接收方不通告小窗口呢?
当「窗口大小」小于 MIN(MSS,空闲缓存空间/2 ) ,就会向发送方通告窗口为 0
,也就阻止了发送方再发数据过来。
等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
怎么让发送方避免发送小数据呢?
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:
- 条件一:要等到窗口大小 >=
MSS
并且 数据大小 >=MSS
; - 条件二:收到之前发送所有的数据的
ack
回包;
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。
该算法的精妙之处在于它实现了自时钟(self-clocking)控制:ACK 返回得快,数据传输也越快。在相对高延迟的广域网中,更需要减少微型报的数目,该算法使得单位时间内发送的报文段数据更少。也就是说,RTT 控制着发包速率。
Nagle 注意事项
注意,如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话(达到 Nagle 算法的条件二),这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。
所以,接收方「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。
另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
拥塞控制
前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大....
所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满【整个网络】。
为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
什么是拥塞窗口?和发送窗口有什么关系呢?
拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口 swnd
和接收窗口 rwnd
是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是 swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd
变化的规则:
- 只要网络中没有出现拥塞,
cwnd
就会增大; - 但网络中出现了拥塞,
cwnd
就减少;
那么怎么知道当前网络是否出现了拥塞呢?
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
拥塞控制有哪些控制算法?
拥塞控制主要是四个算法:
- 慢启动:小于慢启动门限 ssthresh 时。每收到一个ACK,cnwd增长1,指数增长。
- 拥塞避免:大于慢启动门限 ssthresh 后。每收到一个ACK,cnwd增长1/cnwd,线性增长。
拥塞发生时:
- ssthresh 都变成了之前 【cwnd 的一半】
- 超时重传 - cwnd 变为初始值,开始 指数 慢开始
- 快速恢复:快速重传 - cwnd 变为之前的一半,开始 线性 拥塞避免
拥塞窗口 cwnd 初始值
Linux 针对每一个 TCP 连接的 cwnd 初始化值是 10,也就是 10 个 MSS,我们可以用 ss -nli | grep swnd 命令查看每一个 TCP 连接的 cwnd 初始化值。
慢启动
它的规则是:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
如下图,可以看出慢启动算法,发包的个数是指数性的增长。
那慢启动涨到什么时候是个头呢?
有一个叫慢启动门限 ssthresh
(slow start threshold)状态变量。
- 当
cwnd
<ssthresh
时,使用慢启动算法。 - 当
cwnd
>=ssthresh
时,就会使用「拥塞避免算法」。
拥塞避免算法
它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
- 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个
MSS
大小的数据,变成了线性增长。
拥塞发生
当发生重传(超时重传/快速重传)时,发送方就判断网络发生了拥塞,进入拥塞发生算法:
超时重传时,ssthresh 和 cwnd 的值会发生变化:
ssthresh
设为cwnd/2
,cwnd
重置为初始化值(一般是10,这里假定 cwnd 初始化值 1)- 进入慢启动(指数)算法
快速重传时:TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh
和 cwnd
变化如下:
cwnd = cwnd/2
,也就是设置为原来的一半;ssthresh = cwnd
;- 进入拥塞避免(线性)算法
超时重传时的拥塞发生
快速重传/快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO
超时那么强烈
拥塞发生时,则 ssthresh
和 cwnd
变化如下:
cwnd = cwnd/2
,也就是设置为原来的一半;ssthresh = cwnd
;- 进入拥塞避免(线性)算法
快速恢复算法的变化过程如下图:
超时重传/快速重传 比较简图
- ssthresh 都变成了之前【cwnd 的一半】
- 超时重传 - cwnd 变为初始值,开始 指数 慢开始
- 快速重传 - cwnd 变为一半,开始 线性 拥塞避免