赞
踩
首先,需要知道,我们程序的数据首先会打到TCP的Segment中,然后TCP的Segment会打到IP的Packet中,然后再打到以太网Ethernet的Frame中,传到对端后,各个层解析自己的协议,然后把数据交给更高层的协议处理。
其实,网络上的传输是没有连接的,包含TCP也是一样的。而TCP所谓的“连接”,其实只不过是在通讯的双方维护一个连接状态,让它看上去好像有连接。所以,TCP的状态变换是非常重要的。
下图是 “TCP协议的状态机” 和 “TCP建链接” 、 “TCP断链接” 、 “传数据” 的对照图。
第一次握手:SYN=1 seq=x;
第二次握手:SYN=1 seq=y ACK=1 ack=x+1
第三次握手:seq=x+1 ACK=1 ack=y+1
三次的话都有seq来标识窗口的序号,也即SYN SYN-ACK ACK
为什么建立链接需要三次握手?
三次握手的目的是双方确认自己和对方的发送与接收都是正常的,以及最主要的是初始化Sequence Number,即seq。
初始化seq的初始值,通信的双方要互相通知对方自己的初始化syn,也就是x和y。这个号要作为自己的数据通信的序号,以保证应用层接收到的数据不会因为网络传输的问题而乱序。(TCP会用这个序号来拼接数据)。
之后进行数据的传输。
第一次挥手:FIN=1 seq=x
第二次挥手:ACK=1 seq=y ack=x+1
第三次挥手:FIN=1 seq=z
第四次挥手:ACK=1 seq=x+1 ack=z+1
都有seq来标识窗口号,防止乱序。四次就是FIN ACK FIN ACK。
那为什么是四次挥手呢?
其实仔细观察四次挥手的话,就是2次。因为TCP是全双工的,关闭连接的话需要确保发送方和接收方都没有数据要发送了,即 发送方和接收方都需要Fin和Ack。只不过由有一方是被动的,所以看上去就成了所谓的4次挥手。
那为什么要等待TIME_WAIT呢即2MSL?
四次挥手发送完,客户端进入TIME_WAITING的状态,服务端收到ACK就CLOSED。而客户端需要在进入TIME_WAITING状态后等待2MSL才能关闭。
确保最后一个确认报文能够达到。 如果B没有收到A发送来的确认报文,那么就会重新发送连接释放请求报文,A等待这段时间就是为了处理这种情况;
关于建连接时SYN超时: 如果server接收到了client发的SYN后,发回SYN-ACK后client掉线了,server端没有收到client回来的ACK,那么,这个连接处于一个中间状态,即每成功,也没失败。于是,server端如果在一定时间内没有收到的TCP会重发SYN-ACK。在Linux下,默认重试次数为5次,重试的间隔时间从1s开始都翻倍,5次的重试时间间隔为1s,2s,4s,8s,16s,总共31s,第5次发出后还要等32s才知道第5次也超时了,所以总共需要1+2+4+8+16+32=63s,TCP才会断开这个连接。
关于SYN Flood攻击: 一些恶意的人就针对SYN 超时制造 SYN Flood攻击-给服务器发送一个SYN包后就下线,于是服务器需要默认等待63s才会断开连接,这样攻击者就可以把服务器的SYN连接的队列耗尽,让正常的连接请求不能处理。 于是Linux给其一个tcp_syncookies
的参数来应对这个事-即当SYN队列满了之后,TCP会通过源端口、目的端口和时间戳 打造出一个特别的sequence number即seq发回去,如果是攻击者则不会有响应,如果是正常连接,则会把这个SYN cookie发回来,然后服务端可以通过cookie来建立连接(即使不在SYN队列中)。 但是这种应对措施只是适用于SYN Flood攻击的情况。**千万不要用tcp_syncookie来处理正常的大负载的连接的情况。**因为syncookies是妥协版本的TCP协议,并不严谨。对于正常的请求,我们可以调整TCP的参数,第一个:tcp_synack_retries可以用来减少重试的次数;第二个是:tcp_max_syn_backlog:可以增大SYN连接数;第三个是:tcp_abort_on_overflow处理不过来就干脆直接拒绝连接;
TIME_WAIT: 在TCP的状态图中,从TIME_WAIT状态到CLOSED状态,有一个超时设置,这个超时设置是2*MSL。(linux中设置成了30s)。为什么要有TIME-WAIT?为什么不直接转成CLOSED状态呢?主要有两个原因:1)TIME_WAIT确保了有足够的时间让对端收到了ACK,如果被动关闭的那放没有收到ACK,就会重发FIN,一来一去刚好2个MSL。2)有足够的时间让这个连接不会跟后面的连接混在一起(你要知道,有些自作主张的路由器会缓存IP数据报,如果连接被重用了,那么这些延迟收到的包就可能会跟新连接混在一起了。)
关于TIME_WAIT数量太多: 如果在大并发的短链接下,TIME_WAIT就会太多,这回消耗很多资源。只要搜一下,你就会发现,十有八九的处理方式都是教你设置两个参数,一个叫tcp_tw_reuse,另一个叫tcp_tw_recycle的参数,这两个参数默认值都是被关闭的,后者recyle比前者resue更为激进,resue要温柔一些。另外,如果使用tcp_tw_reuse,必需设置tcp_timestamps=1,否则无效。这里,你一定要注意,打开这两个参数会有比较大的坑——可能会让TCP连接出一些诡异的问题(因为如上述一样,如果不等待超时重用连接的话,新的连接可能会建不上。正如官方文档上说的一样“It should not be changed without advice/request of technical experts”)。
其实,如果服务器是HTTP服务器,那么设置一个HTTP的KeepAlive有多重要。(游览器会重用一个TCP连接来处理多个HTTP请求。)因为关闭连接是客户端发起的。它们之间的连接并不会主动关闭,后续的读写操作也会继续使用这个连接。而保持长连接,就需要用到TCP的保活功能。 TCP保活功能:主要为服务器应用提供,服务器应用希望知道客户主机是否崩溃,从而代表客户是否使用资源。
在TCP的报文段中,有一个序列号的来给每个发送的包编号,以此来保证了TCP发送的包不乱序。
TCP要保证所有包都可以到达,所以必需要重传机制。
举例:接收端给发送端的ack确认智慧确认最后一个连续的包,比如发送端发了1,2,3,4,5一共5份数据,接收端收到了1,2,于是回ack 3,然后收到了4(注意此时3没收到),此时的TCP会怎么办呢?SeqNum和Ack是以字节数为单位,所以ack的时候,不能跳着确认,只能确认最大的连续收到的包,不然,发送端就以为之前的都收到了。
(1)重传算法-超时重传
一种是不回ack,死等3,当发送方发现收不到3的ack超时后,会重传3。一旦接收方收到3后,会ack 回 4——意味着3和4都收到了。
但是,这种方式会有比较严重的问题,那就是因为要死等3,所以会导致4和5即便已经收到了,而发送方也完全不知道发生了什么事,因为没有收到Ack,所以,发送方可能会悲观地认为也丢了,所以有可能也会导致4和5的重传。
对此有两种选择:
这两种方式有好也有不好。第一种会节省带宽,但是慢,第二种会快一点,但是会浪费带宽,也可能会有无用功。但总体来说都不好。因为都在等timeout,timeout可能会很长(在下篇会说TCP是怎么动态地计算出timeout的)
(2)重传算法-快速重传机制
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的这堆数据。
(3)重传算法-SACK方法
Selective Acknowledgment (SACK)(参看RFC 2018),这种方式需要在TCP头里加一个SACK的东西,ACK还是Fast Retransmit的ACK,SACK则是汇报收到的数据碎版。参看下图:
在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。于是就优化了Fast Retransmit的算法。当然,这个协议需要两边都支持。在 Linux下,可以通过tcp_sack参数打开这个功能(Linux 2.4后默认打开)。
这里还需要注意一个问题——接收方Reneging,所谓Reneging的意思就是接收方有权把已经报给发送端SACK里的数据给丢了。这样干是不被鼓励的,因为这个事会把问题复杂化了,但是,接收方这么做可能会有些极端情况,比如要把内存给别的更重要的东西。所以,发送方也不能完全依赖SACK,还是要依赖ACK,并维护Time-Out,如果后续的ACK没有增长,那么还是要把SACK的东西重传,另外,接收端这边永远不能把SACK的包标记为Ack。
注意: SACK会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆SACK的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源。
(4)重传算法-Duplicate SACK(DSACK) 重复收到数据的问题
Duplicate SACK又称D-SACK,其主要使用了SACK来告诉发送方有哪些数据被重复接收了。
D-SACK使用了SACK的第一个段来做标志,
如果SACK的第一个字段的范围不能被第二个字段覆盖,那么就是SACK 解读的话就是ACK之前的数据收到了,之后根据第一个字段和第二个字段的大小 来判断重复收到还是收到的情况。
如果SACK的第一个段的范围被SACK的第二个段覆盖,那么就是D-SACK 解读的话就是ack之前的数据都收到了,后面那个重复收到了,缺失的部分丢失掉了,以及收到了。
例子1来理解ACK丢包的情况:
由于ACK 4000大于[3000,3500],因此[4000, SACK=3000-3500]是D-SACK。发送端首先向接收端发送了3000-3499,3500-3999报文,接收端都收到了,但是接收端返回的ACK 3500及4000都丢失,导致发送端重传了3000-3499报文。接收端收到发送端重新发送的3000-3499报文,通过**[4000,SACK=3000-3500]**告知发送端,发送端就知道第一次的3000-3499报文接收端是收到了,由于当前ACK到了4000,那么4000之前的数据也都收到了。
例子2情况:
[4000, SACK=4500-5000]不满足D-SACK的条件,其是普通的SACK。而[4000, SACK=3000-3500, 4500-5000]是D-SACK,含义是:4000前的数据已收到,3000-3500的数据重复收到,4000-4499的包丢失,4500-5000的包收到。
例子3情况:
[4000, SACK=4500-5000]及[4000, SACK=4500-5500]都是普通的SACK,[4000, SACK=5000-5500, 4500-5500]是D-SACK(第二判断方法),含义是:4000前的包收到,5000-5499包重复收到,4500-5500的包都收到,4000-4499的包丢失。
例子4情况:
接收端通过[1000, SACK=2000-2500]告知发送端1000前的报文及2000-2499报文已经收到,1000-1999的报文没有收到。
于是接收端通过[1500, SACK=2000-2500]先告知发送端1500前的报文及2000-2499的报文都收到
D-SACK[2500, SACK=1000-1500]告知发送端2500前的报文全部收到,1000-1500的报文重复收到。
总之,引入了D-SACK,有这么几个好处:
从前面的TCP重传机制知道TimeOut的设置对于重传非常重要。
而且,这个超时时间在不同网络的情况下,根本没有办法设置一个固定的值。
为了动态地设置,TCP引入了RTT——Round Trip Time,也就是一个数据包从发出去到回来的时间。这样发送端就大约知道需要多少的时间,从而可以方便地设置Timeout——RTO(Retransmission TimeOut),以让我们的重传机制更高效。
听起来似乎很简单,好像就是在发送端发包时记下t0,然后接收端再把这个ack回来时再记一个t1,于是RTT = t1 – t0。没那么简单,这只是一个采样,不能代表普遍情况。
(1)经典算法
RFC793 中定义的经典算法是这样的:
首先,先采样RTT,记下最近好几次的RTT值。
然后做平滑计算SRTT( Smoothed RTT)。公式为:(其中的 α 取值在0.8 到 0.9之间,这个算法英文叫Exponential weighted moving average,中文叫:加权移动平均)
SRTT = ( α * SRTT ) + ((1- α) * RTT)
开始计算RTO。公式如下:
RTO = min [ UBOUND, max [ LBOUND, (β * SRTT) ] ]
(2)Karn / Partridge 算法
但是上面的这个算法在重传的时候会出有一个终极问题——你是用第一次发数据的时间和ack回来的时间做RTT样本值,还是用重传的时间和ACK回来的时间做RTT样本值?
这个问题无论你选那头都是按下葫芦起了瓢。 如下图所示:
这个算法的最大特点是——忽略重传,不把重传的RTT做采样(你看,你不需要去解决不存在的问题)。
但是,这样一来,又会引发一个大BUG——如果在某一时间,网络闪动,突然变慢了,产生了比较大的延时,这个延时导致要重转所有的包(因为之前的RTO很小),于是,因为重转的不算,所以,RTO就不会被更新,这是一个灾难。 于是Karn算法用了一个取巧的方式——只要一发生重传,就对现有的RTO值翻倍(这就是所谓的 Exponential backoff),很明显,这种死规矩对于一个需要估计比较准确的RTT也不靠谱。
(3)Jacobson / Karels 算法
前面两种算法用的都是“加权移动平均”,这种方法最大的毛病就是如果RTT有一个大的波动的话,很难被发现,因为被平滑掉了。所以,1988年,又有人推出来了一个新的算法,这个算法叫Jacobson / Karels Algorithm(参看RFC6289)。这个算法引入了最新的RTT的采样和平滑过的SRTT的差距做因子来计算。 公式如下:(其中的DevRTT是Deviation RTT的意思)
RTT = S****RTT + α (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协议中(Linux的源代码在:tcp_rtt_estimator)
TCP必需要解决的可靠传输以及包乱序(reordering)的问题,所以,TCP必需要知道网络实际的数据处理带宽或是数据处理速度,这样才不会引起网络拥塞,导致丢包。
TCP引入了一些技术和设计来做网络流控,Sliding Window是其中一个技术。 前面我们说过,TCP头里有一个字段叫Window,又叫Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。 为了说明滑动窗口,我们需要先看一下TCP缓冲区的一些数据结构:
图中,我们可以看到:
接收端LastByteRead指向了TCP缓冲区中读到的位置,NextByteExpected指向的地方是收到的连续包的最后一个位置,LastByteRcved指向的是收到的包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区。
发送端的LastByteAcked指向了被接收端Ack过的位置(表示成功发送确认),LastByteSent表示发出去了,但还没有收到成功确认的Ack,LastByteWritten指向的是上层应用正在写的地方。
TCP通过滑动窗口来做流量控制,但是TCP觉得还不够,因为滑动窗口需要依赖与连接的发送端和接收端,其实并不知道网络中间发生了什么。
TCP觉得网络还应该知道整个网络上的事情。
具体举例场景:我们知道TCP通过一个timer采样了RTT并计算RTO,但是如果网络上的延时突然增加,那么TCP对这件事做出的应对就只有重传数据,但是重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么形事,那么会形成“网络风暴”,TCP这个协议就会拖垮整个网络。
所以,TCP不能无脑的一个劲儿重发数据,对网络造成更大的伤害。对此TCP的设计理念是:当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要去抢路了。
拥塞控制的四个主要算法:(1)慢启动;(2)拥塞避免(3)拥塞发生;(4)快速恢复
TCP慢启动算法的意思是,刚刚加入网络的连接,一点一点的提速,不要一上来就告诉。
慢启动算法如下:
前面说过,还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:
这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。
前面说过,丢包的时候,会有两种情况:
快速重传和快速恢复算法一般同时使用。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。 注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:
然后,真正的Fast Recovery算法如下:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。