天天看点

为什么说TCP是可靠的网络传输协议?

作者:linux技术栈

我们知道TCP是流式协议,通过字节流的形式在各个网络设备间流动,当这些字节流入到目标机器之后,由目标机器的网络协议栈通过每个数据段里的标记(TCP头)还原数据。不难想到,基于流式的传输至少有两大问题需要解决。第一,假如某个数据段丢了怎么办?第二,传输过程中发生拥堵怎么办?当然,实际的TCP协议要解决的远不止这两个问题。

这里说明一下,很多时候我们把链路层、IP层、TCP层发送的数据都称为数据包,其实是不准确的,每一层都有自己的叫法,链路层叫数据帧(Frame),IP层叫数据包(Packet),TCP层叫数据段(Segment)。

MSS

上面我们说TCP协议中传输的数据叫数据段,而负责确定数据段大小上限的便是MSS(Max Segment Size)最大数据段,这个大小指的是TCP实际传输的数据,不包含IP和TCP头。

MSS的存在,一是为了一次尽可能多的传输数据,二是为了避免IP层MTU拆包。关于MSS和MTU可以参见为什么MSS比MTU小?

我们先来看第一个问题,假如某个数据段丢了怎么办?

为什么说TCP是可靠的网络传输协议?

重传与确认

比较容易想到的是,每发一个数据段,都等待接收端的一个信号,同时启动一个定时器,在定时器触发之前如果收到了接收端的信号就说明数据发送成功了,如果在规定时间内没有收到信号就触发定时器重新发送一次,直至收到接收端的信号,然后才能发送下一个数据段,这就是TCP协议最初的ACK机制,如下图:

为什么说TCP是可靠的网络传输协议?

上面这种方式,虽然可以保证数据不丢,但是效率非常低,一次只能发一个数据段,并且只能收到接收端的ACK之后才能发下一个数据段,效率低下。

所以,人们又想,发送端是不是不用等到接收端的ACK,连续发送多个数据段,目标机器接收到数据之后,目标机器对收到的数据段依次发送ACK,表示这个数据段我收到了,如下图:

为什么说TCP是可靠的网络传输协议?

上面这种方式,虽然提升了网络效率,但还是存在很多问题。这里先忘掉TCP的头、MSS等各种协议内容。假如现在发送一串字符串"hello world",过程如下:

为什么说TCP是可靠的网络传输协议?

一共分三次发出去,第一次"hel",第二次"lo wor",第三次"ld"。当目标机器收到数据的时候,如何还原数据呢?实际上目标机器只需要知道每次发送的数据相对于原数据的开始结束位置就可以还原出原数据了。

比如上面的例子,我们把"hello world"看成一个长度为11的字符数组,将"hel"发送出去的时候告诉目标机器这部分数据在0-2的位置,"lo wor"发出去告诉目标机器这部分数据在3-8的位置,同理"ld"在9-10的位置。这样,接收端收到这些数据之后,通过所在的位置就可以把数据还原了。如下图:

为什么说TCP是可靠的网络传输协议?

上面的的例子中,需要有一个前提,每一次发的数据段是有序的。比如"ld",不能是"dl"。这样,目标机器接收到的每个数据段永远都是有序的数据。但是段与段并不要求是有序的,"ld"、"lo wor"、"hel"可以是无序的,比如:

为什么说TCP是可靠的网络传输协议?

由于每次发送的数据段里面都包含了原始数据的起始结束位置,所以就算不是按顺序到达目标机器,目标机器也可以还原出数据。

相关视频推荐

tcp/ip,accept,11个状态,细枝末节的秘密,还有哪些你不知道?

100行代码实现tcp/ip协议栈,自行准备好Linux系统

面试 从网卡 聊到tcp/ip协议栈,再到应用程序

需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

为什么说TCP是可靠的网络传输协议?

正常情况下,上面的方案看起来很完美,但是由于网络天生的复杂环境可能会出现各种异常情况。比如,数据段是有可能丢失的,例如,上面例子中第二个数据段丢了,这个时候目标机器就没法还原数据了,怎么办?一种办法是它可以向发送端索要,比如接收端收到了"hel",但是"lo wor"一直没收到,它就告诉发送端下一个数据段你要从3这个位置发,客户端收到之后就知道从3开始的这个数据段丢了,然后就可以重发这个数据段。

其实上面的例子可以再简化一下,我们让发送端只携带一个相对原数据的偏移值,比如:

为什么说TCP是可靠的网络传输协议?

上图中,每次发送的数据段都携带了这个数据段结尾相对于原数据的相对位置。当目标机器收到数据的时候,就可以从第一块开始进行组装。假如第二个数据块发生了丢包,那接收端就问发送端要3~8这些位置的数据。并且就算发生丢包,接收端还是可以根据数据段的大小和偏移位置将后面的数据填充到对应的位置,如下:

为什么说TCP是可靠的网络传输协议?

上面的这个过程,就是TCP的序列号机制。

很多文章里对TCP的序列号仅仅停留在随机数、累加就完了,但我觉得序列号才是TCP协议的精华所在,是值得花时间搞明白的。

TCP协议序列号机制

在TCP头中,有两个32位的序列号,一个是发送序列号,另一个是确认序列号。发送序列号表示发送端要从哪里开始发,确认序列号表示这之前的数据目标机器都收到了。

我们可以进入Wireshark的 Statistic>Flow Graph 查看数据段的流转图,下面是一个http请求的过程。

为什么说TCP是可靠的网络传输协议?

上面是一个Wireshark抓的数据包,经过3次握手之后双方的序列号和确认号都是1,这里要说明的是,TCP协议的序列号并不是从0开始的,而是每次都生成一个随机数,Wireshark为了查看方便使用了相对序列号,也就是在随机的序列号上加上一个偏移值。

第4个包,客户端发送了361字节数据,此时客户端的序列号和确认号都还是1。

第5个包,服务端收到361字节数据,在发送端的序列号基础上加上361字节,所以确认号是362字节,此时服务端的序列号还是1。

第6个包开始,服务端连续发送了6个1350字节大小的数据,此时序列号分别是1、1351、2701、4051、5401、6751,确认号是362。

第12个包,客户端一次性确认6个数据段,确认号是1350*6 + 1 = 8101,这期间客户端并没有发送数据,所以序列号还是362。

可以看到,通过序列号可以在连续发送的情况下保证数据的顺序性。

通过上面的例子可以发现,序列号是越来越大的,那是不是可以一直增长下去呢?

实际上,TCP的序列号最大为2^32,也就是4G字节,超过之后会从0开始重新计算,如下图:

为什么说TCP是可靠的网络传输协议?

序列号的这个特点会导致一个问题,就是序列号的回绕,比如下面这个例子:

为什么说TCP是可靠的网络传输协议?

上面例子中,时间点B发送的1G:2G的数据包丢失了,如果在F时刻重发,网络协议栈就没法区分到底是B时刻的数据段还是F时刻的数据段了。

为了解决这个问题,TCP协议在每个数据段的头里面加了一个timestamp的Option。其kind等于8,长度为10字节,如下:

为什么说TCP是可靠的网络传输协议?

有了时间戳之后通过判断时间戳大小就可以解决上面的序列号回绕的问题了。

RTO和RTT

TCP的重传机制离不开两个变量,RTO和RTT。下面我们先来搞明白RTO和RTT是什么,它们之间又有什么样的关系?

RTT可以认为是数据段从发送到收到ACK之间消耗的总时长,如下图:

为什么说TCP是可靠的网络传输协议?

在实际的网络环境中,可能有各种各样的情况,如果只是通过发送出去的时间和收到ACK的时间来计算RTT,有可能是不准确的。比如,考虑下面这种情况:

为什么说TCP是可靠的网络传输协议?

图中,(a)中发生了重传,这个时候我们是以第一次为准?还是第二次发送时间为准呢?(b)在发生了重传之后,又收到上一次发送的ACK,这个时候又以哪个为准呢?

为了搞明白这个问题,我们先来看RTO,RTO是TCP的定时重传计时器,当数据段发送出去之后都会启动一个RTO,当在规定的时间内没有收到ACK,就重发数据段。

我们可以考虑一下,这个RTO设置成多少比较合适呢?假如我们设置成1s,如果数据段都能在1s之内发送完就不会触发RTO重传。但是,如果我们网络环境不好的情况下,可能出现非常多的数据段发送超过1s,这个时候就会触发大量的重传,显然这会急剧降低网络传输效率。

你可能会说,那就设置成2s嘛,或者1分钟。好,假设我们设置成2s。不巧的是,中间出现了丢包,这个时候要等2s才能触发重传。显然,RTO也不是越长越好。

比较好的方式是,我们知道每次传输所花费的时间,也就是RTT,将RTO设置成比RTT稍大,这样就即可以减少重传同时也可以在出现丢包时尽快的重传。但实际情况中RTT可能会遇到各种各样的问题,比如下面这样:

为什么说TCP是可靠的网络传输协议?

图中,a)由于RTT比实际要小,所以导致RTO也比较小,最终就会导致大量无效的重传。b)RTT如果取RTT1又太大,取RTT2又可能太小,似乎怎么取都不太合适。所以,要计算RTO就要先有一个比较准确的RTT。

TCP协议解决这个问题的方式也很简单,我们上面提到说,TCP的头部包含了一个时间戳的信息,再回顾一下,这个时间戳Option实际是包含了两个时间戳。一个接收时的时间戳,一个是回显时间戳,也就是发送时的时间戳。通过这两个时间戳就可以计算出准确的RTT了。这也是目前主流操作系统TCP计算RTT的方式。

但是,仔细考虑一下,由于网络传输环境时刻都在变化。例如,一个RTT计算好了,但是当发送数据段的时候实际的RTT又变长了,这个时候原本目标机器可以正常收到数据段,但因为实际RTT变长了,就有可能导致频繁的重传。所以,这种方案似乎并不完美。

更平滑的RTO

在文档RFC793中提出一种计算平滑RTO的方案,引入了两个定量α和β,RTO的计算公式如下:

SRTT = ( α * SRTT ) + ((1 - α) * RTT)

RTO = min(UBOUND, max(LBOUND, (β * SRTT) ))

RFC793

其中,SRTT是上一次计算出的RTT,第一次为0。α是一个常量,推荐值0.9,β在推荐是1.3~2之间,值越大越平滑。UBOUND是指测试时间最大值,LBOUND指测试时间最小值,比如测量时间最大1分钟,最小1秒钟。

但是,这种计算方式,在RTT波动特别大的时候并不好使,所以目前很多操作系统并没有使用这种方式,而是基于RTT方差计算RTO。

基于RTT方差计算RTO

在文档RFC6298中提出了使用RTT方差计算RTO,而实现原理也很简单,首次计算,R是第一次测出的RTT

SRTT = R

RTTVAR = R/2

RTO = SRTT + max (G, K*RTTVAR)

RFC6298

后续的计算

SRTT = (1 - α) * SRTT + α * R’

RTTVAR = (1 - β) * RTTVAR + β * |SRTT - R’|

RTO = SRTT + max (G, K*RTTVAR)

RFC6298

其中,α = 1/8, β = 1/4,K = 4,G 为最小时间颗粒,它们的取值并没有特别的依据,都是根据历史大量数据统计得到的一个公认最好的一组值。

通过RTT方差即使在网络波动比较大的情况下也可以计算出一个相对平滑的RTO。

滑动窗口

我们试想一种情况,当接收端已经非常繁忙了,但发送端很称职,还在不断的发送数据。此时,接收端由于处理不过来,数据包就会丢失,从而触发重传。这样,网络中会出现大量的重传数据包,传输效率会变得非常低,甚至出现宕机。

为了解决这个问题,TCP引入了滑动窗口的概念,通信双方都有一个发送窗口和接收窗口,如下:

为什么说TCP是可靠的网络传输协议?

0~31字节表示已经收到ACK的数据,32~45字节的数据表示已经发送出去了,但还没收到ACK,46~51字节表示还可以发送的字节数。51之后的字节表示超出了接收方处理的范围。

上面的例子中,32~51字节就是发送窗口,可以简单认为和对端的接收窗口大小是一样的,但由于网络因素实际情况是在同一时刻可能并不一样。

当Category #3为0的时候也就表示对端已经不能继续处理了。直到,已发送未确认的部分收到对端的确认ACK之后,将窗口向右移动。如下:

为什么说TCP是可靠的网络传输协议?

有了滑动窗口之后,双方就可以不断的协商窗口的大小,从而进行流控。比如在3次握手阶段就会告诉对方自己的接收窗口大小,比如:

为什么说TCP是可靠的网络传输协议?

第一次,客户端发送的SYN包里告诉对方自己的接收窗口大小是65535字节,服务端回复ACK的时候告诉客户端自己的接收窗口是28960字节。通过三次握手之后,服务端的发送窗口就是65535字节,客户端的发送窗口就是28960字节,这样就完成了发送窗口的协商。

实际上,这个窗口在真正发送数据的过程中,每次都会带到TCP头中,这样对端就可以通过和当前自己的发送窗口比较,从而决定是扩充还是收缩窗口。

下面通过一个例子来进行说明,我们假设MSS固定不变,窗口不变,我们将发送窗口分为三部分,如下:

为什么说TCP是可靠的网络传输协议?

各个部分如下:

  • SND.WND 表示发送窗口总大小
  • SND.UNA 表示发送窗口第一个字节所在位置
  • SND.NXT 下一个可发送的字节所在位置

接收窗口如下:

为什么说TCP是可靠的网络传输协议?

各个部分如下:

  • RCV.WND 表示接收窗口大小
  • RCV.NXT 表示下一个要处理的字节所在位置

然后我们来看下面这个具体的例子,这里假设MSS和窗口不发生变化,如下表:

为什么说TCP是可靠的网络传输协议?

下面是客户端处理过程:

为什么说TCP是可靠的网络传输协议?

对于客户端,一开始SND.UNA和SND.NXT都是1,SND.WND=360, RCV.NXT=241, RCV.WND=200,接着,客户端发送了140字节数据,此时序列号是1,客户端的发送窗口SND.UNA=1,SND.NXT=141,发送窗口还可以发送220字节。

服务端对140字节数据确认并发送了80字节数据,客户端收到ACK之后发送窗口右移140字节,SND.WND又回到了300字节大小。

服务端的窗口变迁过程:

为什么说TCP是可靠的网络传输协议?

一开始,Server端的SND.NXT=241,SND.UNA=241,第一次发送了80字节数据之后,SND.NXT变成了321(241+80),此时发送窗口可用空间为120字节。

在收到Client端对80字节的ACK之前,Server端又发了120字节数据。此时,SND.NXT=441(421+120),发送窗口就占满了,可发送空间为0。

直到,收到Client端对80字节数据的ACK,Server端的发送窗口向右移动80字节。此时,SND.UNA变成了321,发送窗口可用空间为80字节。

然后Server端再次收到Client端的对120字节的ACK,SND.UNA变成441,发送窗口可用空间又回到200字节。

Server端再发送160字节,SND.NXT=601(441+160),发送窗口可用空间变为40字节。

最后,Server端收到160字节的ACK,SND.UNA变成601,发送窗口可用空间又回到200字节。

滑动窗口与网络协议栈缓存的关系

滑动窗口在操作系统中实际上就是分配了一块固定大小的内存,而窗口的调整也会导致缓存的收缩,两台设备之间的通信,如果机器的配置不一样,会怎么影响实际的网络传输呢?下面我们就来看一下常见的几种情况。

1. 应用层没有及时读取缓存

为什么说TCP是可靠的网络传输协议?

上图中,到第9步的时候,由于应用层没有及时从网络协议栈的缓存读取数据,导致接收窗口被填满了,这个时候,发送窗口和接收窗口都为0。当发送窗口为0的情况下,发送端将停止发送数据。

但是,上面的这种情况会有一个问题,当发送端停止发送数据之后,如果接收端不主动通知发送端,就无法更新窗口大小,这个连接就卡在这里了。

所以,在TCP协议中,出现这种情况之后都会有一定时器,定期发送窗口通告。从而有机会恢复数据的传输。

2. 收缩窗口导致的丢包

为什么说TCP是可靠的网络传输协议?

上图中,第2步服务端给到客户端一个窗口通告,窗口大小为100字节,但此时客户的发送窗口的缓存上还保存着180字节的数据,这就会导致剩下的80字节数据丢失。

所以,现代操作系统一般都是先收缩窗口再调整缓存大小。在窗口关闭后定时探测窗口大小。

飞行中报文的适合数量

为什么说TCP是可靠的网络传输协议?

调整接收窗口与应用缓存

net.ipv4.tcp_adv_win_scale = 1

应用缓存 = buffer / (2^tcp_adv_win_scale)

Linux中对TCP缓冲区的调整方式

• net.ipv4.tcp_rmem = 4096 87380 6291456

• 读缓存最小值、默认值、最大值,单位字节,覆盖 net.core.rmem_max

• net.ipv4.tcp_wmem = 4096 16384 4194304

• 写缓存最小值、默认值、最大值,单位字节,覆盖net.core.wmem_max

• net.ipv4.tcp_mem = 1541646 2055528 3083292

• 系统无内存压力、启动压力模式阀值、最大值,单位为页的数量

• net.ipv4.tcp_moderate_rcvbuf = 1

• 开启自动调整缓存模式

糊涂窗口综合症

糊涂窗口综合症指的是,由于某些原因(比如服务端非常繁忙的情况下)导致非常小的窗口通告,导致的传输效率下降的问题,如下图:

为什么说TCP是可靠的网络传输协议?

一开始Client端的发送窗口是360字节,并一次性发送了360字节到Server端。Server端收到之后,对这360字节进行了ACK,同时给Client端发送了一个120字节大小的窗口通告。

Client端收到窗口通告之后将发送窗口设置为了120字节,又发了120字节数据到Server端。Server端因为非常繁忙处理不过来,将窗口又改为了80字节。

由于窗口变小,每次发送的有效数据变小,网络效率也会变得非常低。所以,要提升网络传输效率就应该尽量避免小窗口。

SWS避免算法

SWS应该说是一种解决方案,发送端和接收端通过不同的算法实现。

在接收端使用David D Clard算法,这个算法的原理是通过每次窗口移动的大小来决定发送窗口通告的大小,具体实现也很简单,当窗口边界移动值小于min(MSS, 缓存大小/2)的时候,窗口大小为0。

在发送端,使用Nagle算法,其原理可以总结如下:

  • 不存在已发送未确认的报文段时,立刻发送数据
  • 存在未确认报文段时,满足下面两个条件时再发送
    • a.没有已发送未确认报文段,
    • b.数据长度达到MSS大小

Nagle算法的示例如下图:

为什么说TCP是可靠的网络传输协议?

延迟确认

积极的确认会导致大量没有携带有效数据的数据段(比如只包含了TCP头)的ACK,然后发送端还要一条条处理对端过来的ACK。

我们想象一下,当发送端发送一连串的数据段之后,其实接收端可以收了这一批数据之后再统一回ACK,或者等有数据发送给发送端的时候一并回复ACK,这就是TCP的延迟确认。它的核心逻辑如下:

  • 当有响应数据(发送给对端的数据)要发送时,ACK会随着响应数据立即发送给对方
  • 如果没有响应数据,ACK的发送将会有一个延迟,以等待看是否有响应数据可以一起发送
  • 如果在等待发送ACK期间,对方的第二个数据段又到达了,这时要立即发送ACK

下图中右边是一个延迟确认的例子:

为什么说TCP是可靠的网络传输协议?

可以看到,右图中,当"H"发送出去之后,接收端没有响应数据,此时第二个数据段还没机会发送。所以,"H"的ACK在500ms之后才被发送出去。

当Nagle遇上延迟确认

上面Nagle算法解决小数据段问题,延迟确认可以减少接收端积极的ACK导致的网络性能问题,但是当Nagle和延迟确认这两种机制组合在一起又会产生一些问题,下面我们来具体看,如图:

为什么说TCP是可靠的网络传输协议?

可以看到,W1发送之后,根据Nagle算法的规则,此时存在一个已发送未确认的数据段,数据段长度也没达到MSS的长度。所以,后面的数据暂时不会被发送。而此时接收端没有要响应的数据,也没有第二个未确认的ACK,然后就进入到延迟确认的计时器,并在200ms之后发送ACK。

可以看到,整个过程中,由于触发了延迟确认,整个传输过程增加了不必要的耗时。

现代操作系统的网络编程接口都可以通过设置套接字选项来选择性的关闭延迟确认和Nagle算法。设置项分别为:

延迟确认:TCP_QUICKACK

Nagle:TCP_NODELAY

更加激进的Nagle: TCP_CORK

在Linux中还有一种更激进的Nagle实现,其原理是结合Sendfile零拷贝技术,就是不需要将数据先拷贝到用户态再拷贝到内核态,而是可以直接将发送的文件数据拷贝到内核态的内存上。

拥塞控制

在整个网络世界里,数以亿计的网络设备参与其中,一个数据包可能要经过许多中间网设备才能最终到达目的地。而中间所经过设备的性能和繁忙程度各不一样。要想让网络达到最佳的传输效率就需要考虑中间所经过的网络设备。而不能只考虑发送端和接收端。

而拥塞控制就是为了解决在整个传输链路上根据各个网络设备的实现情况将传输性能最大化。

为什么说TCP是可靠的网络传输协议?

拥塞控制前后经历了4个版本,也可以说是4种不同的解决方案,分别是:

  • 在RFC6582中提出的 Reno & New Reno
  • 可以参考http://intronetworks.cs.luc.edu/current/html/reno.html#tcp-reno-and-fast-recovery
  • BIC算法,在Linux内核2.6.8-2.6.18有实现
  • BIC算法实际上是基于ACK驱动的,检测丢包来实现拥塞窗口的调整。但是基于ACK就相当于和RTT强相关,而网络中因为各种原因都会导致RTT的波动。BIC算法的实现原理可以参考:https://blog.csdn.net/dog250/article/details/53013410
  • 在RFC8312文档中提出了CUBIC算法并在Linux2.6.19中实现
  • CUBIC算法可以不依赖RTT进行拥塞窗口的调整,可以参考:https://blog.csdn.net/dog250/article/details/53013410
  • B B R算法在Linux4.9开始支持
  • B B R算法是由Google提出的一个拥塞控制算法。

假如我们把网络传输比喻成一根水管,最理想的情况是水管里面的水量 = 水管直经 * 长度。对于同一条传输路径来讲,这条网络路径的容量 = 带宽 * RTT。这里的带宽表示单位时间能传输的数据大小,而RTT我们在前面已经详细讲过了。

水管在现实世界中有大有小,有长有短。在网络中也是一样,带宽有大有小,RTT有快有慢。

前面提到了滑动窗口,主要是用来解决发送端与接收端之间的窗口协商,但无法感知到中间设备的情况。而拥塞控制就是考虑了整个链路发送过快或者过慢的问题。为了实现拥塞控制又引入了拥塞窗口(Congestion Window)简称cwnd。

拥塞窗口的引入使得TCP协议变得更加复杂。实际发送窗口大小 = min(拥塞窗口, 发送窗口 ),你可以停下来想想为什么实际发送窗口要取拥塞窗口和发送窗口的最小值。

拥塞控制一般都是配合慢启动来实现,如下图:

为什么说TCP是可靠的网络传输协议?

每收到一个ACK,cwnd(拥塞窗口)扩充一倍,但是第一次启动窗口大小如何确定的呢?慢启动的初始窗口IW(Initial Window)的变迁分为3个版本:

  1. 在1997年的RFC2001中规定,慢启动初始窗口为SMSS大小。
  2. 在1998年的RFC2414文件中规定如下:

IW = min(4*SMSS, max(2*SMSS, 4380 bytes))

3. 在2013年的RFC6928文档中规定如下:

IW = min(10*MSS, max(2*MSS, 14600))

为什么说TCP是可靠的网络传输协议?

拥塞避免

显然,每次翻倍很快就会达到网络链路中某个设备的极限,为了避免因为慢启动频繁导致的拥塞问题,我们就需要避免频繁触发的拥塞问题,这便是拥塞避免算法。

拥塞避免使用一个慢启动阈值ssthresh(slow start threshold)来控制窗口大小,其原理是当拥塞窗口达到ssthresh 后,以线性方式增加拥塞窗口大小,而不是每次翻倍,cwnd += SMSS * SMSS/cwnd,如下图:

为什么说TCP是可靠的网络传输协议?

通过拥塞避免算法,就可以避免频繁的拥塞问题,如下图:

为什么说TCP是可靠的网络传输协议?

可以看到,通过引入拥塞控制的阈值ssthresh之后,当触发了拥塞控制算法就可以在接下来有效的避免每次因为拥塞窗口增长过快频繁导致的拥塞问题。如果不理解拥塞控制算法解决了什么问题,可以回顾一下滑动窗口那一小节。

失序数据段

前面不止一次提到数据段丢失的问题,而数据段丢失一般情况下都会出现失序数据段。比如报文丢失产生的连续失序ACK段,网络路径与设备导致数据段失序也会产生少量失序ACK段,若报文重复也会产生少量失序ACK段。

为什么说TCP是可靠的网络传输协议?

快速重传

为了解决失序数据段的问题,RFC2581文档引入了快速重传算法,它的运行逻辑如下:

对于接收方来讲

  1. 当收到一个失序数据段时,立刻发送它所期待的缺口ACK序列号。
  2. 当接收到填充失序缺口的数据段时,立刻发送它所期待的下一个ACK序列号。

对于发送方来讲:

当接收到3个重复的失序ACK段(4个相同的失序ACK段)时,不再等待重传定时器的触发,立刻基于快速重传机制重发报文段

下图描述了快速重传机制的工作过程。

为什么说TCP是可靠的网络传输协议?

超时不会导致快速重传

为什么说TCP是可靠的网络传输协议?

快速重传一定要进入慢启动吗?我们知道慢启动会突然减少数据流,所以对于快速重传来讲,我们并不希望进入慢启动流程。而是直接通过ssthresh阈值计算得到一个拥塞窗口,如下图:

为什么说TCP是可靠的网络传输协议?

快速恢复

在RFC2581文档中提出了快速恢复算法 ,它的触发时机是启动快速重传且正常未失序ACK段到达前触发快速恢复,这句话啥意思呢?简单来说就是出现了失序数据段并且触发了快速重传到恢复正常不再有失序数据段的这段时间内会触发快速恢复,再简单点说就是快速恢复是由快速重传触发的,它的运行逻辑如下:

  1. 将ssthresh设置为当前拥塞窗口的一半,设当前cwnd为ssthresh加上3*MSS
  2. 每收到一个重复ACK, cwnd增加1个MSS
  3. 当新数据ACK到达后,设置cwnd为ssthresh
为什么说TCP是可靠的网络传输协议?

SACK与选择性重传算法

上面分析了TCP的序列号机制,为了确保数据的有序及安全,接收端会针对最后收到的连续有序的数据段发送ACK,告诉发送端自己期望下次收到的数据段的序列号。但仔细想想其实还可以进一步优化,提升传输效率。下图中,丢失了序列号201的数据段。那么下次期望对端发送的序列号就是201,直到收到序列号201的数据段。

为什么说TCP是可靠的网络传输协议?

在这种机制下,发送端可以积极悲观的重传所有的数据段,也可以乐观的仅重传丢失的数据段。但这两种方式都有各自的问题,重传所有数据段显然会有大量重复的数据包发送,造成带宽浪费,同时效率也不高。仅重传丢失数据段在大量丢包的情况下由于需要依赖对端的ACK又会导致传输效率低下。如下图:

为什么说TCP是可靠的网络传输协议?

假如发送端连续发送了三个数据段p1、p2、p3,序列号分别为s1, s2, s3,接收端先收到了s3,此时由于p1和p2还没收到,所以给发送端ACK中期望的序列号还是s1。此时,发送端就有可能触发p1、p2、p3的重传,显然p3的重传是重复的。基于此,TCP协议引入了选择性重传算法,还是回到刚刚的场景,当接收端先收到p3的时候,在ACK中依然期望从s1开始重传,但同时告诉对端s2-s3已经收到了,不用再重传了。这就是SACK算法 。

SACK(TCP Selective Acknowledgement)算法

SACK算法可以参考RFC2018文档,其原理是当出现失序数据包时在ACK报文中同时给出已经收到的失序的数据段。可以通过设置Option选项来激活SACK算法。

  1. Option kind = 4 表示支持SACK选择性确认中间报文段功能
  2. Option kind = 5 表示确认报文段,选择性确认窗口中间的Segments报文段

通常SACK算法都是配合选择性确认算法一起使用,如下图:

为什么说TCP是可靠的网络传输协议?

上图中,当序列号201的数据段丢了之后,在收到下一个数据段(序列号361)之后,回给发送端的ACK除了期望的序列号201还带上了一个SACK[361, 500],告诉发送端这个范围的数据段已经收到本地网络协议栈已经在处理了,可以先不传。最后,发送端只发送了序列号201的数据段,当接收端本地网络协议栈处理完序列号361的数据之后又会发送一个ACK告诉发送端期望下个收到的数据包序列号为501。

上面还有一种情况,假如接收端本地网络协议栈对361序列号的数据段处理失败了,那么下次的ACK里期望的序列号就是361。

下面是激活SACK算法之后的数据包,可以看到,Options Kind 为5,选择性重传的数据段分别是74031-78111、59071-72671、45599-58121三个范围内的数据段。

为什么说TCP是可靠的网络传输协议?

从丢包到测量驱动的拥塞控制算法

前面我们说拥塞控制是从整个网络链路的宏观角度去考虑的,这里面要考虑参与网络传输的所有中间设备,而网络传输效率的上限其实也取决于整个网络链路中效率最差的那个设备。

当发送端和接收端的收发效率都很高的情况下,假如中间设备效率低下,就会发生丢包,产生丢包的原因可能是中间设备内存不足也有可能是因为负载过高导致的超时。如下图:

为什么说TCP是可靠的网络传输协议?

实际上,目前的拥塞控制基本上还是基于丢包来实现的。但是简单通过丢包来触发拥塞控制会产生一些问题。这是因为拥塞控制会触发慢启动,当达到上限之后又会触发拥塞控制从而触发慢启动,不断重复这个过程,这就是传统的基于丢包的拥塞控制算法,很明显这会影响传输的效率。如下图:

为什么说TCP是可靠的网络传输协议?

你可能会说,我们固定一个大小不就可以避免慢启动了吗?但仔细想想是有问题的。比如,当某一时刻拥塞窗口缩小到了20,但是过了一会网络环境变好了,整个链路中可以发送的最大数据段变成了1000。此时,如果还一直使用20的拥塞窗口显然是不合理的。

在上面的基础上,CUBIC算法在上面的基础上做了优化,可以让每次触发拥塞控制的慢启动过程中发送数据段的大小变动的范围更小,下图中红色线条是使用CUBIC拥塞控制算法的效果。

为什么说TCP是可靠的网络传输协议?

上面提到的基于丢包的拥塞控制会有一些问题,由于没法提前预判控制点,会出现大量的丢包,而且随着内存越来越便宜,各个中间设备的内存容量越来越大,延时也越来越长。

那么,怎么样才能找到一个合适的控制点呢?我们看下面这张图:

为什么说TCP是可靠的网络传输协议?

图中,上面图中的Y轴是RTT,下面图中的Y轴是带宽,当带宽增长到一定程度的时候,网络协议栈的缓冲区开始堆积,此时RTT是无法进行准确测量的。显然,上图中的最佳控制点应该是在最大带宽、最小延时和最低丢包率。但这个控制点由于RTT和带宽没法在同一时间准确测量,所以要找到它并不容易。这里,你可以想一下,为啥RTT和带宽不能在同一时刻准确测量呢?

从网络协议栈缓冲区的角度来看,什么情况下传输效率最高呢?我们先看一张图,如下:

为什么说TCP是可靠的网络传输协议?

图中,State1可以认为缓冲区是空的,有数据过来直接就可以处理,不会产生堆积。State2中缓冲区开始堆积,数据得不到及时的处理,所以会出现延时。State3中缓冲区填满了,如果继续发送数据就会出现丢包。所以,效率最高的情况应该是每次发送的数据刚好可以被处理掉也就是上图中State1的情况。

B B R拥塞控制算法

Google在2016年发布了B B R(TCP Bottleneck Bandwidth and Round-trip propagation time)拥塞控制算法,并在Linux4.9内核引入,最新的QUIC协议也支持B B R算法。下图中绿色线条是使用B B R之后的效果。

为什么说TCP是可靠的网络传输协议?

引入B B R之后的传输效率有了非常大的提升,如下图:

为什么说TCP是可靠的网络传输协议?

上面,我们说RTT和带宽在同一时刻要准确测量是很困难的,比如RTT变高,但带宽不变的情况下,到底是某个设备负载变高了?还是中间链路发生了变化?这是很难搞清楚的。而B B R的核心就是为了找到上面我们提到的那个最佳控制点。

首先,要解决的一个问题就是排除掉RTT里的噪声,比如ACK的延迟确认以及网络设备缓冲区都会产生RTT噪声。去除RTT噪声之后得到的应该是一个物理属性,也就是经过各个物理设备的时间总和,中间不包括由于TCP协议栈的程序所产生的时延,比如ACK延迟确认。这个物理属性叫作RTprop。如下图:

为什么说TCP是可靠的网络传输协议?

那如何测量出RTprop呢?下面是计算RTprop的公式:

为什么说TCP是可靠的网络传输协议?
为什么说TCP是可靠的网络传输协议?

这个公式看起来挺唬人,实际上它做的事情很简单,通过不断测量得到一个平均的噪声值,最终得到一个近似的RTprop的值。

有了RTprop之后剩下的就是测量带宽BtlBw了,公式如下:

为什么说TCP是可靠的网络传输协议?

原理也是类似,反复取多次发送速率,取最大值。

上面是对同一个链路进行计算,但网络传输的链路时刻都在发生变化,当传输链路发生变化时,B B R算法会基于pacing_gain调整,pacing_gain的原理是周期性的增加并减少传输速率,从而使原来的RTprop和RtlBw失效并重新计算RTprop和RTlBw,如下图:

为什么说TCP是可靠的网络传输协议?

下面我们来看一下,当线路发生变化的时候,pacing_gain是如何重新计算得到最佳控制点的。如下图:

为什么说TCP是可靠的网络传输协议?

上图中,最上面表示在链路发生变化之后速度变大的情况,在20秒的时候速率提升到了20M,此时RTT并没变大,所以继续提升速率,直到21秒多的时候RTT增大,此时就可以计算得到线路发生变化之后的最佳控制点。

再来看图中下面的部分,表示的是链路发生变化后,速率变小的情况。在40秒的时候开始增加发送速率,但是RTT也随之变大,根据pacing_gain的规则增加速率之后又会降低速率,所以在42秒的时候又降到了20M的速率直到达到最佳控制点。这就是pacing_gain的运行原理。

下面通过代码来直观感受一下B B R算法的实现。根据B B R的原理不难推断出,B B R算法是重度依赖ACK的,通过ACK携带的信息来计算最佳的控制点。下面是B B R算法处理ACK的过程的代码实现。

function onAck(packet)
  // 计算rtt
  rtt = now - packet.sendtime
  // 找到最小的rtt更新到RTprop
  update_min_filter(RTpropFilter, rtt)
  delivered += packet.size
  delivered_time = now
  // 计算速率
  deliveredRate = (delivered-packet.delivered)/(delivered_time-packet.delivered_time)
  if (deliveryRate > BtlBwFilter.currentMax || !packet.app_limited)
    // 更新BtlBw
    update_max_filter(BtlBwFilter, deliveryRate)
  if (app_limited_until > 0) 
    app_limited_until = app_limited_until - packet.size           

B B R在发送数据时的处理过程代码如下:

function send(packet)
  bdp = BtlBwFilter.currentMax * RTpropFilter.currentMin
  // 如果发送速率超出了发送速率就等一等再发送
  if (inflight >= cwnd_gain * bdp)
    return
  if (now >= nextSendTime)
    packet = nextPacketToSend()
    if (!packate) 
      app_limited_until = inflight
      return
    packet.app_limited = (app_limited_until > 0)
    packet.sendtime = now
    packet.delivered = delivered
    packet.delivered_time = delivered_time
    ship(packet)
    // pacing_gain周期性的探测
    nextSendTime = now + packet.size / (pacing_gain * BtlBwFilter.currentMax)
  timerCallbackAt(send, nextSendTime)           

pacing_gain探测的速率增长规律是5/4, 3/4, 1,1,1,1,1,1,也就是1.25、0.75、1、1、1、1、1、1。

关于B B R更详细的内容可以参考:https://dl.acm.org/doi/pdf/10.1145/3012426.3022184

到这里,TCP是如何保证它的可靠性就讲完了,其中涉及到非常多的实际细节,如果有什么纰漏还希望大家可以帮忙指出来。