TCP如何实现一个靠谱的协议
- 为了保证顺序性,每个包都有一个ID。在建立连接的时候,商定起始的ID。然后按照ID一个一个发送。
- 收到包的一端需要对包做应答,一旦应答一个ID,就表明之前的ID都收到了,这个模式称为累计确认或者累计应答。
- 为了记录发送和接收的包,TCP在发送端和接收端都缓存记录。
发送端缓存结构
发送端的缓存按照包的ID一个个排列,分成4个部分:
(一)发送并且已经确认的
(二)发送了并且尚未确认的
(三)没有发送,但是已经确认要发送在等待的
(四)没有发送,并且暂时不会发送的
第三和第四部分区分开是为了流量控制,流量控制的依据是什么?TCP里接收端会给发送端报告一个窗口大小,叫Advertised Window。发送端需要保证上面第二和第三部分的长度加起来等于Advertised Window。
tcp发送端缓存结构
接收端缓存结构
接收端的缓存分成三个部分:
(一)接受并且确认过的
(二)还没接收,但是马上就能接收的,要等空格填满
(三)还没接收,也没法接收的,也就是超过工作量(max buffer)的部分
tcp接收端缓存结构 - MaxRcvBuffer:最大缓存的量
- LastByteRead之后是已经接收了,但是还没被应用层消耗
- NextByteExpected之后是等待接收的
Advertised Window其实就是等待接收未确认部分的大小。其中这部分中有可能是有空挡的,比如7到14有,但6是空的。那NextByteExpected就只能待在这个位置了。
TCP的确认和重发机制
发送端在发出一个包后,会设置一个定时器,超过一定时间没收到ACK,就会把这个包重发。应该如何评估这个时间的大小呢?这个时间不能太短,必须大于正常的一次往返的时间(RTT)。也不能太长,太长了发送的速度就太慢了。
所以最关键的是估算一个平均RTT,所以TCP需要不断地采样RTT,还要根据RTT的波动范围,做加权平均算出一个值来。由于重传时间是不断变化的,称为自适应重传算法。
快速重传算法
以上算法的问题是超时时间会越加越长,所以有一个快速重传的机制。当接收方收到一个序号大于下一个期望的报文段时,就是说接收缓存有了空格,那还是发送原来的ACK,比如我在等6,这时候收到7,我ACK 5。然后又收到8和9,我还都是ack 5。这样发送端接连收到3个ACK,发送端收到后,会马上重发6,不再等超时。这里还是有一个问题,发送端这时候可能已经发到20了,收到的3个ACK只能知道6没收到,7,8,9有没有收到不知道,只能把6之后的全部重发一遍。
Selctive Acknowledgment(SACK)
还有一种重传的方式称为SACK,这种方式是在TCP头里加一个SACK的东西,将缓存地图放进去,比如还是7丢了,可以地图是ACK6,SACK8,SACK9。发送方收到后,立马能看出是7丢了。Linux下面可以通过tcp_ack参数打开这个功能。
这里还是有一个问题,就是SACK不是最终保证,就是说接收端在发送SACK后是可以把数据再丢了的。虽然这么做不鼓励,但是不排除极端情况,比如内存不够了。所以发送端不能想当然的再也不发SACK的那些包了,还是要看这些包有没有正式的ACK才能最终确认。
Duplicate SACK(D-SACK)
D-SACK的作用是接收端告诉发送端包发重了。比如ACK 6丢了,那么发送端会重发,接收端发现收到两个6,现在我都要ACK 8了,则回复ACK 8,SACK 6。
引入D-SACK,有这么几个好处:
- 让发送方知道,是发的包丢了还是对端回的ACK丢了
- 是不是自己的timeout设置小了,导致重传了
- 网络上出现先发后到的情况
- 网络上有可能把我的包复制了
基于以上的认知,可以更好的做流控。Linux下使用参数tcp_dsack
开启这个功能。
RTT算法策略
以上提到重传的Timeout很重要,这个超时时间在不同的网络的情况下,根本没有办法设置一个死的值。只能动态地设置。 为了动态地设置,TCP引入了RTT——Round Trip Time,也就是一个数据包从发出去到回来的时间。这样发送端就大约知道需要多少的时间,从而可以方便地设置Timeout——RTO(Retransmission TimeOut),以让我们的重传机制更高效。
经典算法
首先采样最近几次的RTT,然后做平滑计算,算法叫加权移动平均。公式如下:(α取值在0.8到0.9之间)
SRTT = ( α * SRTT ) + ((1- α) * RTT)
然后计算RTO,公式如下
RTO = min [ UBOUND, max [ LBOUND, (β * SRTT) ] ]
其中:
UBOUND是最大的timeout时间,上限值
LBOUND是最小的timeout时间,下限值
β 值一般在1.3到2.0之间。
Karn / Partridge 算法
经典算法的问题就是RTT在有重传的时候怎么算?是用第一次发的时间算做开始时间还是重传的时间作为开始时间。比如下面这样:
所以为了避免这个坑,搞了一个Karn / Partridge 算法,这个算法的最大特点是——忽略重传,不把重传的RTT做采样。这样有一个问题就是突然有一段时间重传很多,如果都不算的话RTT就一直是原来的值,显然这个值已经不合适了。所以,算法想了一个办法,只要一发生重传,RTO翻倍。
Jacobson / Karels 算法
以上两种算法的问题就是RTT容易被平均掉,不能很好的应对突发情况。新的算法的公式如下:
SRTT = SRTT + α (RTT – SRTT) —— 计算平滑RTT
DevRTT = (1-β)DevRTT + β(|RTT-SRTT|) ——计算平滑RTT和真实的差距(加权移动平均)
RTO= µ * SRTT + ∂ *DevRTT —— 神一样的公式
(其中:在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4 ——这就是算法中的“调得一手好参数”,nobody knows why, it just works…) 这个算法就是今天的TCP协议中用的算法。
RTO终于被算出来了,接下来就是两个最重要的窗口了。
流量控制
上面讲到TCP在包的ACK中会携带一个窗口大小,发送方就可以做一个滑动窗口了(简称rwnd)。每收到一个ACK就把窗口往右移动一个,也就是从未发送待发送中取一个发出去,然后从不可发送中取一个出来放到待发送里面。
如果接收方下次来的ack中带的窗口大小变小,则说明接收方处理不过来了,那发送方就不能将窗口右移了,而是要将窗口变小。最后如果窗口变成0,发送端窗口就变成0,不会再发了。这个时候发送方会一直发送窗口探测数据包ZWP(Zero Window Probe),看是否有机会调整窗口的大小。一般会探测3次,每次间隔30-60s,当然不同的实现可能配置不一样。
SockStress攻击
有等待就有攻击,利用ZWP的攻击方式就是,客户端连上服务端后就把窗口设置成0,然后服务端就等,然后ZWP。等的多了自然资源就耗尽了,这种攻击叫做SockStress。
Silly Window Syndrome (糊涂窗口综合征)
在接收端将窗口调整成0后,如果这个时候应用消耗了一个包,那窗口会变成1,如果这时候发送端立马发送一个包会发生什么?TCP+IP的头加起来有40字节,如果为了几个字节直接发送的话主要的工作都消耗在发头上了,这是一个问题。
还有就是网络里有个MTU的概念,就是一次发送的包大小,以太网是1500字节,出去TCP+IP的40字节,本来可以用1460字节而只发几个字节,那就是浪费带宽。这个1460就是俗称的MSS(Max Segment Size)。以上的表现就被称为糊涂窗口综合征。
<meta charset="utf-8">
发送端和接收端是怎么来处理这种病的呢?
如果是接收端造成窗口是0,一旦缓冲区开始有地方了,接收端不会立马发送一个窗口大小1给发送端,而是会等窗口达到一定大小,比如缓冲区一半为空或者空间大于MSS了,才会更新窗口大小,在此之前还是一直回答0。
如果是发送端造成包比较小,那就是发送端负责攒数据,他有两个主要的条件:1)要等到 Window Size>=MSS 或是 Data Size >=MSS,2)收到之前发送数据的ack回包,他才会发数据。这个就是著名的 Nagle’s algorithm 。
Nagle算法默认是打开的,所以,对于一些需要小包场景的程序——比如像telnet或ssh这样的交互性比较强的程序,你需要关闭这个算法。你可以在Socket设置TCP_NODELAY选项来关闭这个算法。还有一个类似的参数TCP_CORK,其实是更激进的Nagle算法,完全禁止小包发送,而Nagle算法没有禁止小包发送,只是禁止了大量的小包发送。所以要分清楚这两个参数。
以上就是TCP的流量控制策略。
拥塞控制
流量控制通过滑动窗口来实现,但是rwnd窗口只考虑了发送端和接收端的问题,没考虑网络的问题。有可能接收端很快,但是网络拥塞了,所以加了一个拥塞窗口(cwnd)。拥塞窗口的意思就是一次性可以连续提交多少个包到网络中。最终的形态是LastByteSent-LastByteAcked<=min(cwnd,rwnd),由两个窗口共同控制发送速度。
TCP的拥塞控制主要避免两种现象,包丢失和包重传。网络的带宽是固定的,当发送端发送速度超过带宽后,中间设备处理不完多出来的包就会被丢弃,这就是包丢失。如果我们在中间设备上加上缓存,处理不过来的包就会被加到缓存队列中,不会丢失,但是会增加时延。如果时延到达一定的程度,就会超时重传,这就是包重传。
拥塞控制主要是四个算法:1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复
慢启动
拥塞窗口(cwnd)的大小应该怎么设置呢?一个TCP连接,开始的时候cwnd设置成一个报文段(MSS),一次只能发送一个;当收到ACK后则cwnd++;如果ACK正常收到,每当过了一个RTT则翻倍,以指数增长。如果网速很快的话增长速度还是很可观的。
[注] TCP的实现中cwnd并不都是从1个MSS开始的,Linux 3.0后依据google的论文《An Argument for Increasing TCP’s Initial Congestion Window》,初始化cwnd从10个MSS开始。Linux 3.0之前,cwnd是跟MSS的值来变的,如果MSS< 1095,则cwnd = 4;如果MSS>2190,则cwnd=2;其它情况下,则是3。
拥塞避免
cwnd一直涨下去不是办法,要设置一个限制。当涨到一次发送超过ssthresh(65535个字节),就会减速,改成每次加1/ cwnd,比如之前一次发送cwnd是10个MSS,现在每次收到一个确认cwnd加1/10个MSS,每过一个RTT加1个。这样一直加下去知道拥塞出现,比如包丢失。
拥塞发生时
前面虽然超过ssthresh时会减速,但是还是在涨,早晚会产生拥塞的。这时候有两种处理方式,1)一旦超时重传出现,则把ssthresh改成cwnd/2,cwnd窗口改成1,重新从头开始慢启动。2)还有一种情况就是收到接收端SACK要求重传,这种TCP认为不严重,ssthresh改成cwnd/2,cwnd降为cwnd/2+3进入快速恢复算法。
快速恢复
接着上一段拥塞发生的第二种情况,快速恢复算法的逻辑如下:
- cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)
- 重传Duplicated ACKs指定的数据包
- 如果再收到 duplicated Acks,那么cwnd = cwnd +1
- 如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。
其他的恢复算法有FACK,TCP Vegas等。
存在问题
拥塞控制用以上的方法控制窗口的大小有两个问题:
1)丢包不代表通道满了,也有可能是网络本来就有问题,所以这个时候收缩时不对的
2)等到发生丢包再收缩,其实已经晚了,应该在刚好用满时就不再加了
基于以上两个问题,又出现了TCP BBR拥塞算法。
更多的算法,可以从Wikipedia的 TCP Congestion Avoidance Algorithm 词条中找。
参考文章:
[极客时间] 趣谈网络协议 --刘超
TCP 的那些事儿 --左耳朵耗子
网友评论