从上面的 OSI七层模型及TCP/IP四层模型详细分析 和 深入理解TCP三次握手四次挥手中,我们都知道TCP是可靠的传输协议,那么TCP协议是怎么样保证可靠性呢? 其实要实现可靠性就是要解决数据的破坏、丢包、重复以及分片顺序混乱等问题?要解决这些基本问题主要是通过TCP协议的序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现。如下图:
序列号与确认应答是实现TCP可靠传输之一,如果这中间数据包丢失了,那么就会触发重传机制来解决。那么常见的重传机制有:
超时重传
快速重传
SACK
D-SACK
超时重传就是在发送数据时,会启动一个定时器,如果到指定时间没有说到对方的ACK报文,而触发重传该数据的机制。会有这两种情况:
发送的数据包丢失
确认应答丢失
在说这个超时时间怎么设置时,先说两个相关的概念:RTT和RTO。
RTT就是数据从网络一端传送到另外一端的时间,即一个包的往返时间。如下图:
RTO就是超时重传的时间。
那么RTO怎么设置呢?
如果RTO设置过长或者过短,那么都会发生不太理想的事情。如下图:
两种超时时间不同的情况:
当设置超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差
当设置超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发
所以精确的设置RTO十分重要,能提高重传效率。一般我们设置超时时间 RTO稍微比RTT的值大一些。
那么RTO怎么计算出来呢?
我们都知道在网络中都会有网络波动的情况,所以RTT是一个波动变化的值,那么RTO也应该是一个动态变化的值。
估计往返时间的因素:
需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化
除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况
RFC6289 建议使用以下的公式计算 RTO:
其中SRTT
是计算平滑的RTT,DevRTR
是计算平滑的RTT 与 最新 RTT 的差距,α = 0.125,β = 0.25, μ = 1,∂ = 4。
到这里我们知道了RTO的计算方法,但是还有一种情况我们也应该考虑的,那就是如果我们发生多次重传,那么每次重传时间是否一样呢?答案:否。TCP采取的策略是超时间隔加倍,即每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
超时重发是取决于时间,那么超时周期可能很长。针对这个问题我们可以使用快速重传
快速重传是一个不以时间为启动的,而且数据为驱动的重传机制。如下图:
图的解释:
第一份 Seq1 先送到了,于是就 Ack 回 2
结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2
后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到
发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2
最后,接收到收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6
所以在超时触发定时器之前,如果说到三个相同的ACK报文,就可以认为该报文已经丢失,可以进行重传了。但是重传的时候,我们重传之前的一个,还是重传所有呢?比如上面的例子中,我们是只重传Seq2,还是 Seq2、Seq3、Seq4、Seq5呢?因为我们不知道这三个Ack2报文是那个传回来的。对于该问题,TCP就有了SACK方法。
SACK叫做选择性确认,它就是通过在TCP头部中的“选项”字段中加入一个SACK的数据,它主要是告诉发送方哪些报文段丢失,哪些报文段重传了,哪些报文段已经提前收到等信息。如下图:
在说到三次相同的ACK报文时,触发了快速重传机制,而且根据SACK知道丢失的是那些报文,如上图丢失的是200-299,所以我们只需要重传200-299段报文就可以了。
要支持SACK,必须双方都支持。在linux中可以通过配置项net.ipv4.tcp_sack设置,默认是打开的。
D-SACK是告诉发送方那些数据给重复接收了。它的作用有:
让发送方知道,是发送的包丢了,还是返回的ACK包丢了
网络上是否出现了包失序
数据包是否被网络上的路由器复制并转发了
是不是自己的timeout太小了,导致重传
在linux中可以通过net.ipv4.tcp_dsack
参数开启/关闭该功能,默认是打开的。
例子一: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 包丢了,而是因为网络延迟了。
我们知道TCP每发一个包都会得到一个ACK应答报文,如果发一个包得到应答之后再发下一个包,那么数据包的往返时间越长,通信的效率就越低。
为了解决这个问题,TCP协议引入了窗口的概念。有了窗口,那么就可以指定窗口的大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。实在就是在系统开辟一块缓存空间,在得到数据应答之前,把发送数据保存下来,得到应答之后才删除该数据。
窗口大小由那方决定?
在TCP中有一个window字段,它就是窗口大小。它是由接收方决定的,如果发送数据超过接收方的窗口大小,那么就会导致接收方无法接受数据。所以窗口大小应该由接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来
发送窗口
发送窗口就是一个循环利用的缓冲区,应用层发送数据,就是往缓冲区中写入数据。该缓存区由三个指针来维护。收到ACK后,就相当于从缓冲区中移除数据,不过并不会真正移除数据,只需要后移对应的指针就可以了。
指针1: 指向第一个已发送但是未接收到ack的字节
指针2: 指向第一个允许发送但是还未发送的字节
指针3: 发送窗口的大小
它主要的四个部分:
已经收到ack包的数据。可以覆盖
已经发送还未接收到ack包的数据
允许发送但是还未发送的数据
不允许发送的数据
从图中我们知道可用窗口大小计算方法是:
可用窗口大 = SND.WND -(SND.NXT - SND.UNA)
当可用窗口大小为0时,就不行发送数据了,只能等待ACK报文回来之后,可用窗口大小变为大于0才可以发送。
接收窗口
接收窗口存在于一个循环利用的缓冲区,接收数据 就是往缓冲区中写入数据。该缓存区由两个指针来维护。应用层读取数据后,就相当于从缓冲区中移除数据,不过并不会真正移除数据,只需要后移对应的指针就可以了。
指针1: 指向第一个可以接收但未接收的字节
指针2: 接收窗口大小WND
接收窗口由三部分组成:
应用层已经读取的数据
接收窗口中的数据,可以无序接收报文,所以接收窗口可以存在空隙的。如果需要重传,可以通过D-SACK告诉发送方那些数据需要重传
还未收到的数据
接收窗口中的字节序列号都是与发送窗口一一对应的。
接收窗口和发送窗口的大小是相等的吗?
并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制就是让发送方的数据发送速率不要太快,要让接收方来得及接收,避免数据丢失情况的发生。如果完全不考虑接收方处理能力,一直无脑的发数据给对方,但对方处理不过来,就会导致触发重发机制,而浪费网络资源。
我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整(在系统资源紧缺的时候)
当[接收方]的OS没办法及时处理缓存区的报文时,会告诉[发送方]减小窗口的大小,但是缓存区不会同时减小(防止丢包),等到稳定后再减小缓存区
为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间在减少缓存,这样就可以避免了丢包情况。
TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
窗口关闭潜在的危险
一般情况,当接收方处理完缓存之后,会通过ACK报文告诉发送方窗口大小非0,但是这个ACK报文不一直会成功发送,那么就会陷入了双方都在等待的问题了。
如何解决/防止 这种死锁现象
为了解决这个问题,TCP 连接一方收到对方的零窗口通知,就启动持续计数器。如果持续计时器超时,就会发送窗口探测报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。如果还是0,那么重置该计时器。如下图:
当 [接收方] 来不及处理缓存区的数据,会导致 [发送方] 的发送窗口越来越小。
到最后, 如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
为什么不可以这样做
发报文是有成本的,TCP+IP的头就有40字节,而为了几个字节的数据,要搭上这么大的开销,这太不经济了。
怎么让接收方不通知小窗口呢?
当 [窗口大小] 小于 min(MSS,缓存空间/2), 就会告知 [发送方] 窗口大小为0
怎么让发送方避免发送小数据呢?
使用 Nagle 算法,算法思路是延时处理,满足以下两个条件的一条才可以发送数据:
- 窗口大小 >= MSS 或者 数据大小 >= MSS
- 收到之前发送数据的 ack 回包。
只要没满足上面条件的一条,发送方一直在囤数据,知道满足条件为止。
Nagle算法默认打开。对于小数据交互的场景,比如telnet或ssh 这样的交互性比较强的程序,需要关闭Nagle算法。
可以在 Socket 设置 TCP_NODELAY 关闭该算法
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
为什么要有拥塞控制,不是有流量控制了吗
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
为了在 [发送方] 调节所要发送数据的量,定义了一个叫做 [拥塞窗口] 的概念。
什么是拥塞窗口?和发送窗口有什么关系?
拥塞窗口 cwnd(congestion window) 是发送方维护的状态变量,它会根据网络的拥塞程度动态变化。
从上文我们知道,发送窗口 swnd 和接受窗口 rwnd 是约等于的关系;
在引入拥塞窗口的概念后,swnd = min(cwnd,rwnd) 发送窗口等于拥塞窗口和接受窗口的最小值。
cwnd 变化的规则:
- 只要网络中没有出现阻塞,cwnd 就会增大;
- 但网络中出现了阻塞,cwnd就会减小;
如何判定网路阻塞?
发生超时重传时,被认定网络出现了拥塞。
以下为四种拥塞控制算法
慢启动、拥塞避免、拥塞发生、快速恢复
慢启动的规则就是:收到一个ACK,拥塞窗口就+1。
可以看到,每一次接收到的 ACK 都能加大 cwnd。((((1+1)+2)+4)+8)
cwnd根据这种特性会呈现出指数型的增长。
增长到哪是个头?
有一个慢启动门限 ssthresh (slow start threshold)状态变量。
- 当 swnd < ssthresh 时,采用 慢启动算法。
- 当 swnd >= ssthresh 时, 采用 拥塞避免算法。
当 swnd >= ssthresh 时,采用拥塞避免算法:
拥塞避免的规则就是: swnd 每收到一个ACK 增加 1 / cwnd,也就是收到以前发送数据的所有 ACK,swnd 才能增加1。
推理可知,cwnd 根据 拥塞避免 的特性,是呈线性增长的。
这种情况下, cwnd 一直增长,网络就会慢慢进入了拥塞的状况了,也就会出现丢包的现象。此时要重发丢失的包。
当触发重传机制,也就进入了 [拥塞避免] 算法。
从上面重传机制我们知道,主要重传机制有两种:
- 超时重传
- 快速重传
这两中对应的拥塞发生算法是不同的。
发生超时重传的拥塞发生算法
这个时候,慢启动门阀 ssthresh 和 拥塞窗口cwnd 的值会发生变化:
- ssthresh 设为 cwnd/2
- cwnd 设为1
之后,也就是回到了慢启动,慢慢重新开始啦~
发生快速重传的拥塞发生算法
这种情况下:收到了三个连续的 ACK , 一般是某一个包丢了,整体的数据量传输还是没有问题的(SACK解决),但是为了以防万一,还是要将 ssthresh 和 cwnd 降低一点的。
- cwnd = cwnd / 2
- ssthresh = cwnd
注: 此时 cwnd 和 ssthresh 的值是一样的。然后就进入了快速恢复算法。
顾名思义,在只是丢了某一个包的情况下,我们将 阻塞窗口(cwnd) 降到了一半,那么我们就要快速恢复 cwnd 以便恢复到原来的传输速度。
它不用想 RTO 那么激烈,直接降到1,而是在只降一半的前提下+3然后拥塞避免。
快速恢复算法的规则如下:
- 在cwnd = cwnd/2, ssthresh = cwnd 之后
- cwnd += 3(确认有3个数据包(的ACK)收到了),然后继续拥塞避免算法。