1. TCP重复确认和快速重传
当接收方收到乱序数据包时,会发送重复的 ACK,以便告知发送方要重发该数据包,当发送方收到 3 个重复 ACK 时,就会触发快速重传,立刻重发丢失数据包。
TCP 重复确认和快速重传的一个案例,用 Wireshark 分析,显示如下:
- 数据包 1 期望的下一个数据包 Seq 是 1,但是数据包 2 发送的 Seq 却是 10945,说明收到的是乱序数据包,于是回了数据包 3 ,还是同样的 Seq = 1,Ack = 1,这表明是重复的 ACK;
- 数据包 4 和 6 依然是乱序的数据包,于是依然回了重复的 ACK;
- 当对方收到三次重复的 ACK 后,于是就快速重传了 Seq = 1 、Len = 1368 的数据包 8;
- 当收到重传的数据包后,发现 Seq = 1 是期望的数据包,于是就发送了个确认收到快速重传的 ACK
注意:快速重传和重复 ACK 标记信息是 Wireshark 的功能,非数据包本身的信息。
以上案例在 TCP 三次握手时协商开启了选择性确认 SACK,因此一旦数据包丢失并收到重复 ACK ,即使在丢失数据包之后还成功接收了其他数据包,也只需要重传丢失的数据包。如果不启用 SACK,就必须重传丢失包之后的每个数据包。
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
2. TCP流量控制
TCP 为了防止发送方无脑的发送数据,导致接收方缓冲区被填满,所以就有了滑动窗口的机制,它可利用接收方的接收窗口来控制发送方要发送的数据量,也就是流量控制。
接收窗口是由接收方指定的值,存储在 TCP 头部中,它可以告诉发送方自己的 TCP 缓冲空间区大小,这个缓冲区是给应用程序读取数据的空间:
- 如果应用程序读取了缓冲区的数据,那么缓冲空间区就会把被读取的数据移除
- 如果应用程序没有读取数据,则数据会一直滞留在缓冲区。
接收窗口的大小,是在 TCP 三次握手中协商好的,后续数据传输时,接收方发送确认应答 ACK 报文时,会携带当前的接收窗口的大小,以此来告知发送方。
假设接收方接收到数据后,应用层能很快的从缓冲区里读取数据,那么窗口大小会一直保持不变,过程如下:
但是现实中服务器会出现繁忙的情况,当应用程序读取速度慢,那么缓存空间会慢慢被占满,于是为了保证发送方发送的数据不会超过缓冲区大小,服务器则会调整窗口大小的值,接着通过 ACK 报文通知给对方,告知现在的接收窗口大小,从而控制发送方发送的数据大小。
零窗口通知与窗口探测
假设接收方处理数据的速度跟不上接收数据的速度,缓存就会被占满,从而导致接收窗口为 0,当发送方接收到零窗口通知时,就会停止发送数据。
如下图,可以看到接收方的窗口大小在不断的收缩至 0:
接着,发送方会定时发送窗口大小探测报文,以便及时知道接收方窗口大小的变化。
以下图 Wireshark 分析图作为例子说明:
- 发送方发送了数据包 1 给接收方,接收方收到后,由于缓冲区被占满,回了个零窗口通知;
- 发送方收到零窗口通知后,就不再发送数据了,直到过了 3.4 秒后,发送了一个 TCP Keep-Alive 报文,也就是窗口大小探测报文;
- 当接收方收到窗口探测报文后,就立马回一个窗口通知,但是窗口大小还是 0;
- 发送方发现窗口还是 0,于是继续等待了 6.8(翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;
- 发送方发现窗口还是 0,于是继续等待了 13.5(翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;
可以发现,这些窗口探测报文以 3.4s、6.5s、13.5s 的间隔出现,说明超时时间会翻倍递增。
发送窗口的分析
在 Wireshark 看到的 Windows size 也就是 " win = ",这个值表示发送窗口吗?
这不是发送窗口,而是在向对方声明自己的接收窗口。
这不是发送窗口,而是在向对方声明自己的接收窗口。
你可能会好奇,抓包文件里有「Window size scaling factor」,它其实是算出实际窗口大小的乘法因子,「Window size value」实际上并不是真实的窗口大小,真实窗口大小的计算公式如下:
「Window size value」 * 「Window size scaling factor」 = 「Caculated window size 」
对应的下图案例,也就是 32 * 2048 = 65536。
实际上是 Caculated window size 的值是 Wireshark 工具帮我们算好的,Window size scaling factor 和 Windos size value 的值是在 TCP 头部中,其中 Window size scaling factor 是在三次握手过程中确定的,如果你抓包的数据没有 TCP 三次握手,那可能就无法算出真实的窗口大小的值,如下图:
如何在包里看出发送窗口的大小?
很遗憾,没有简单的办法,发送窗口虽然是由接收窗口决定,但是它又可以被网络因素影响,也就是拥塞窗口,实际上发送窗口是值是 min(拥塞窗口,接收窗口)。
发送窗口和 MSS 有什么关系?
发送窗口决定了一口气能发多少字节,而 MSS 决定了这些字节要分多少包才能发完。
举个例子,如果发送窗口为 16000 字节的情况下,如果 MSS 是 1000 字节,那就需要发送 1600/1000 = 16 个包。
发送方在一个窗口发出 n 个包,是不是需要 n 个 ACK 确认报文?
不一定,因为 TCP 有累计确认机制,所以当收到多个数据包时,只需要应答最后一个数据包的 ACK 报文就可以了。
3. TCP 延迟确认与 Nagle 算法
当我们 TCP 报文的承载的数据非常小的时候,例如几个字节,那么整个网络的效率是很低的,因为每个 TCP 报文中都会有 20 个字节的 TCP 头部,也会有 20 个字节的 IP 头部,而数据只有几个字节,所以在整个报文中有效数据占有的比重就会非常低。
这就好像快递员开着大货车送一个小包裹一样浪费。
那么就出现了常见的两种策略,来减少小报文的传输,分别是:
- Nagle 算法
- 延迟确认
Nagle 算法是如何避免大量 TCP 小数据报文的传输?
Nagle 算法做了一些策略来避免过多的小数据报文发送,这可提高传输效率。
Nagle 算法的策略:
- 没有已发送未确认报文时,立刻发送数据。
- 存在未确认报文时,直到「没有已发送未确认报文」或「数据长度达到 MSS 大小」时,再发送数据。
只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。
上图右侧启用了 Nagle 算法,它的发送数据的过程:
- 一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符;
- 接着,在还没收到对 H 字符的确认报文时,发送方就一直在囤积数据,直到收到了确认报文后,此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方;
- 待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去
可以看出,Nagle 算法一定会有一个小报文,也就是在最开始的时候。
另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY
选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)。
那延迟确认又是什么?
事实上当没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。
为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。
TCP 延迟确认的策略:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
延迟等待的时间是在 Linux 内核中定义的,如下图:
关键就需要 HZ
这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的 Linux 系统中 HZ 大小是 1000
,如下图:
知道了 HZ 的大小,那么就可以算出:
- 最大延迟确认时间是
200
ms (1000/5) - 最短延迟确认时间是
40
ms (1000/25)
TCP 延迟确认可以在 Socket 设置 TCP_QUICKACK
选项来关闭这个算法。
延迟确认 和 Nagle 算法混合使用时,会产生新的问题
当 TCP 延迟确认 和 Nagle 算法混合使用时,会导致时耗增长,如下图:
发送方使用了 Nagle 算法,接收方使用了 TCP 延迟确认会发生如下的过程:
- 发送方先发出一个小报文,接收方收到后,由于延迟确认机制,自己又没有要发送的数据,只能干等着发送方的下一个报文到达;
- 而发送方由于 Nagle 算法机制,在未收到第一个报文的确认前,是不会发送后续的数据;
- 所以接收方只能等待最大时间 200 ms 后,才回 ACK 报文,发送方收到第一个报文的确认报文后,也才可以发送后续的数据。
很明显,这两个同时使用会造成额外的时延,这就会使得网络"很慢"的感觉。
要解决这个问题,只有两个办法:
- 要不发送方关闭 Nagle 算法
- 要不接收方关闭 TCP 延迟确认