摘抄至 TCP的那些事儿
数据传输中的 Sequence Number
下图是我从Wireshark
中截了个我在访问coolshell.cn时的有数据传输的图给你看一下,SeqNum是怎么变的。
你可以看到,SeqNum的增加是和传输的字节数相关的。上图的,三次握手后,来了两个Len:1440的包,而第二个包的SeqNum就成了1441。然后第一个ACK回的是1441,表示第一个1440收到了。
注意
:如果你用Wirseshark抓包程序看3次握手,你会发现SeqNum总是为0,不是这样的,Wireshark为了显示更友好,使用了Relative SeqNum——相对序号,你只要在右键菜单中 protocol preference 中取消就可以看到Absolute SeqNum
了
TCP重传机制
TCP要保证所有的数据包都可以到达,所以,必须要有重传机制。
注意
:接收端给发送端的Ack确认只会确认最后一个连续的包。比如发送端发了1,2,3,4,5一共5份数据,接收端收到了1,2于是回ACK=3,然后收到了4(注意此时3没有收到),此时的TCP会怎么办?我们要知道,因为正如前面说的,SeqNum和ACK是以字节数为单位的,所以ack的时候,不能跳着确认,只能确认最大的连续收到的包。不然发送端就以为之前都收到了。
超时重传机制】
一种是不回ACK,死等3,当发送方发现收不到3的ACK超时后,会重传3.一旦接收方收到3后,会回ACK=4,意味着3和4都收到了。
但是,这种方式会有比较严重的问题,那就是因为死等3,所以会导致4和5即便已经收到了,而发送方也完全不知道发生了什么事情,因为没有收到ACK。发送方可能会悲观地认为也丢了,所以有可能也会导致4和5重传。
对此有两种选择:
- 一种是仅重传timeout的包,也就是第3份数据。
- 另一种是重传timeout后所有的数据,也就是3,4,5这三份数据。
这两种方式有号也有不好。第一种会节省带宽,但是慢。第二种会快一点,但是会浪费带宽,也可能会有无用功。但总体来说都不好。因为都在等timeout,timeout可能会很长。
快速重传机制
于是,TCP引入了一种Fast Retransmit
的算法,不以时间驱动,而以数据驱动重传。也就是说,如果,包没有连续到达,就ACK最后那个可能被丢了的包,如果发送方连续收到了3次相同的ACK,就重传,Fast Retransmit
的好处是不用等timeout了再重传。
比如:如果发送方发出了1,2,3,4,5 个数据包,第一个份先送到了,于是就ACK回2,结果2因为某些原因没收到,3到达了,于是还是ACK回2,后面的4和5都到了,但是还是ACK回2,因为2还是没有收到,于是发送端收到了三个ACK=2的确认,知道了2还没有到,于是就马上重传2。然后,接收到收到了2,此时3,4,5都收到了,于是ACK回6。
Fast Retransmit
只解决了一个问题,就是timeout的问题,它依然面临一个艰难的选择,就是重传一个还是重传所有的问题。对于上面的实例来说,是重传2呢还是重传2,3,4,5呢?因为发送端并不清楚这个连续的3个ACK=2是谁传回来的,也许发送端发了20份数据,是6,10,20传来的呢。这样,发送端很有肯能要重传2到20的这堆数据(实际上有些TCP就是这样实现的),可见,这是把双刃剑。
SACK方法
另外一种更好的方式叫: Selective Acknowledgment(SACK),这种方式需要在TCP头里加一个SACK的东西,ACK还是Fast Retransmit的ACK,SACK则是汇报收到的数据碎片。
这样,在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。于是就优化了
Fast Retransmit
的算法。当然,这个协议需要两边都支持。在Linux下,可以通过tcp_sack
参数打开这个功能(Linux2.4后默认打开)。这里还需要注意一个问题——接收方
Reneging
,所谓Reneging
的意思就是接收方有权把已经报给发送端的SACK里的数据给丢了。这样干是不被鼓励的,因为这个事会把问题复杂化。但是接收方这么做可能会有一些极端情况,比如要把内存给别的更重要的东西。所以,发送方也不能完全依赖SACK,还是要依赖ACK,并维护Time-out,如果后续的ACK没有增长,那么还是要把SACK的东西重传。另外,接收端这边永远不能把SACK的包标记为ACK。注意:SACK会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆SACK的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗发送端的资源。
Duplicate SACK——重复收到数据的问题
Duplicate SACK又称D-SACK,其主要使用了SACK来告诉发送方有哪些数据被重复接收了。
D-SACK使用了SACK的第一个段来做标志。
- 如果SACK的第一个段的范围被ACK所覆盖,那么就是D-SACK
- 如果SACK的第一个段的范围被SACK的第二个段覆盖,那么就是D-SACK
- 实例 :ACK丢包 *
下面的实例中,丢了两个ACK,所以,发送端重传了第一个数据包(3000-3499)
,于是接收端发现重复收到了,于是回了一个SACK=3000-3500,因为ACK都到了4000意味着收到了4000之前的所有数据,所以这个SACK就是D-SACK——旨在告诉发送端我收到了重复的数据,而且我们的发送端还知道数据包没有丢,丢的是ACK包。
Transmitted Received ACK Sent
Segment Segment (Including SACK Blocks)
3000-3499 3000-3499 3500 (ACK dropped)
3500-3999 3500-3999 4000 (ACK dropped)
3000-3499 3000-3499 4000, SACK=3000-3500
- 实例:网络延误 *
下面的实例中,网络包(1000-1499)被网络给延误了,导致发送方没有收到ACK,而后面到达的三个包触发了Fast Retransmit算法
触发的重传不是因为发出去的包丢了,也不是因为回应的ACK包丢了,而是因为网络延迟了。
Transmitted Received ACK Sent
Segment Segment (Including SACK Blocks)
500-999 500-999 1000
1000-1499 (delayed)
1500-1999 1500-1999 1000, SACK=1500-2000
2000-2499 2000-2499 1000, SACK=1500-2500
2500-2999 2500-2999 1000, SACK=1500-3000
1000-1499 1000-1499 3000
1000-1499 3000, SACK=1000-1500
可见,引入了D-SACK,有以下几个好处:
- 可以让发送方知道,是发出去的包丢了,还是回来的ACK包丢了。
- 是不是直接的timeout太小了。导致重传的。
- 网络上出现了先发的包后到的情况(又称reordering)
- 网络上是不是把我的数据包复制了。
知道这些东西可以很好的帮助TCP了解网络情况,从而可以更好的做网络上的流控。Linux下的tcp_dsack
参数开启这个功能。(Linux 2.4后默认打开)
TCP的RTT算法
从前慢的TCP重传机制我们知道Timeout的设置对于重传非常重要。
- 设长了,重发就慢,丢了老半天才重发,没有效率,性能差。
- 设短了,会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
而且,这个超时时间再不同的网络情况下,根本没有办法设置一个死的值。只能动态的设置。为了动态的设置,TCP引入了RTT——Round Trip Time,这就是一个数据包从发出去到回来的时间。这样发送端就大约知道需要多少的时间,从而可以方便地设置Timeout——RTO(Retransmission TimeOut),以让我们的重传机制更高效。听起来似乎很简单,好像就是在发送端发包时记下t0,然后接收端再把这ACK回来时再记一个t1,于是RTT = t1 - t0。没有那么简单,这只一个采样,不能代表普遍情况。
经典算法
RFC793中定义的经典算法是这样的:
- 首先,先采用RTT,记下最近好几次的RTT值。
- 然后做平滑计算SRTT(Smoothed RTT)。公式为:
SRT = (α * SRTT) + ((1-α) * RTT)
`其中的α取值在0.8-0.9之间,这个算法英文叫做Exponential weighted movingaverage,中文叫: 加权移动平均`
- 开始计算RTO,公式如下:
RTO = min[UBOUND, max[LBOUND, (β * SRTT)]]
`UBOUND是最大的timeout时间,上限值`
`LBOUND是最小的timeout时间,下限值`
`β 值一般在1.3-2.0之间`
TCP滑动窗口
需要说明一下,如果你不了解TCP的滑动窗口这个事,你等于不了解TCP协议。我们都知道,TCP必需要解决的可靠传输以及包乱序(reordering)的问题。所以TCP必需要知道网络实际的数据处理带宽或是数据处理速度,这样才能不会引起网络拥塞,导致丢包。
所以,TCP引入了一些技术和设计来做网络流控,Sliding Window是其中一个技术。前面我们说过,TCP头里有一个字段叫做Window,又叫做Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓存区可以接受数据。于是发送端就可以根据这接收端的处理能力来发送数据,而不会导致接收端处理不过来。为了说明滑动窗口,我们需要先看一下TCP缓冲区的一些数据结构:
sliding_window-900x358.jpg
上图中,我们可以看到:
- 接收端
LastByteRead
指向了TCP
缓冲区读到的位置,NextByteExpected
指向的地方是收到的连续包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区。 - 发送端的
LastByteAcked
指向了被接收端ACK
过的位置(表示成功发送确认),LastByteSent
表示发出去了,但是没有收到成功确认的ACK
,LastByteWritten
指向是上层应用正在写的地方。
于是: - 接收端在给发送端回ACK中会汇报自己的
AdvertisedWindow = MaxRcvBuffer - LastByteRcvd - 1
-
而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理。
下面我们来看看发送方的滑动窗口示意图:
tcpswwindows.png
上图中分成可四个部分,分别是:(其中那个黑模型就是滑动窗口)
-
1 已收到ACK确认的数据。
-
2 已发还没有收到ACK。
-
3 在窗口中还没有发出的(接收方还有空间)。
-
4 窗口以为的数据(接收方没空间)。
下面是个滑动后的示意图(收到36的ack,并发出了46-51的字节)
tcpswslide.png
下面我们来看一个接受端控制发送端的图示:
tcpswflow.png
Zero Window
上图,我们可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCP Sligind Window给降成0的。此时,你一定会问,如果Window变成0了,TCP会怎么样?是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想象成Window Closed
,那你一定还会问,如果发送端不发数据了,接收方过一会有Window size
可用了,怎么通知发送端呢?
解决这个问题,TCP使用了Zero Window Probe 技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ACK他的Window尺寸,一般这个值会设置成3次,每次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会RST把链接断开。
注意:只要有等待的地方都可能出现DDoS攻击,Zero Window也不例外,一些攻击者会在和HTTP建好链接发完GET请求后,就把Window设置为0,然后服务端就只能等待进行ZWP,于是攻击者会并发大量的这样的请求,把服务器端的资源耗尽。
网友评论