如果对网络工程基础不牢,建议通读《细说OSI七层协议模型及OSI参考模型中的数据封装过程?》
下面就是TCP/IP(Transmission Control Protoco/Internet Protocol )协议头部的格式,是理解其它内容的基础,就关键字段做一些说明
Source Port和Destination Port:分别占用16位,表示源端口号和目的端口号;用于区别主机中的不同进程,而IP地址是用来区分不同的主机的,源端口号和目的端口号配合上IP首部中的源IP地址和目的IP地址就能唯一的确定一个TCP连接;
Sequence Number:TCP连接中传送的字节流中的每个字节都按顺序编号,用来标识从TCP发送端向TCP收收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节在数据流中的序号;主要用来解决网络报乱序的问题;
Acknowledgment Number:期望收到对方下一个报文的第一个数据字节的序号个序号,因此,确认序号应当是上次已成功收到数据字节序号加1。不过,只有当标志位中的ACK标志(下面介绍)为1时该确认序列号的字段才有效。主要用来解决不丢包的问题;
Offset:它指出TCP报文的数据距离TCP报文段的起始处有多远,给出首部中32 bit字的数目,需要这个值是因为任选字段的长度是可变的。这个字段占4bit(最多能表示15个32bit的的字,即4*15=60个字节的首部长度),因此TCP最多有60字节的首部。然而,没有任选字段,正常的长度是20字节;
TCP Flags:TCP首部中有6个标志比特,它们中的多个可同时被设置为1,主要是用于操控TCP的状态机的,依次为URG,ACK,PSH,RST,FIN。每个标志位的意思如下:
SYN (Synchronize Sequence Numbers)-同步序列编号-同步标签
The segment is a request to synchronize sequence numbers and establish a connection. The sequence number field contains the sender's initial sequence number.
该标志仅在三次握手建立TCP连接时有效。它提示TCP连接的服务端检查序列编号,该序列编号为TCP连接初始端(一般是客户端)的初始序列编号。在这里,可以把TCP序列编号看作是一个范围从0到4,294,967,295的32位计数器。通过TCP连接交换的数据中每一个字节都经过序列编号。在TCP报头中的序列编号栏包括了TCP分段中第一个字节的序列编号。
在连接建立时用来同步序号。当SYN=1而ACK=0时,表明这是一个连接请求报文。对方若同意建立连接,则应在响应报文中使SYN=1和ACK=1.因此, SYN置1就表示这是一个连接请求或连接接受报文。
ACK (Acknowledgement Number)-确认编号-确认标志
The segment carries an acknowledgement and the value of the acknowledgement number field is valid and contains the next sequence number that is expected from the receiver.
大多数情况下该标志位是置位的。TCP报头内的确认编号栏内包含的确认编号(w+1,Figure-1)为下一个预期的序列编号,同时提示远端系统已经成功接收所有数据。
TCP协议规定,只有ACK=1时有效,也规定连接建立后所有发送的报文的ACK必须为1
网络上有很多错误说法,比如:ACK是可能与SYN,FIN等同时使用的,比如SYN和ACK可能同时为1,它表示的就是建立连接之后的响应,如果只是单个的一个SYN,它表示的只是建立连接。TCP的几次握手就是通过这样的ACK表现出来的。其实:ACK&SYN是标志位,
FIN (Finish)-结束标志
The sender wants to close the connection
用来释放一个连接。
当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放连接。
URG (The urgent pointer)-紧急标志
Segment is urgent and the urgent pointer field carries valid information.
当URG=1,表明紧急指针字段有效。告诉系统此报文段中有紧急数据
PSH (Push)-推标志
The data in this segment should be immediately pushed to the application layer on arrival.
PSH为1的情况,一般只出现在 DATA内容不为0的包中,也就是说PSH=1表示有真正的TCP数据包内容被传递。
RST (Reset)-复位标志
There was some problem and the sender wants to abort the connection.
当RST=1,表明TCP连接中出现严重差错,必须释放连接,然后再重新建立连接
Window(Advertised-Window)—窗口大小:滑动窗口,用来进行流量控制。占2字节,指的是通知接收方,发送本报文你需要有多大的空间来接受
CWR (Congestion Window Reduced)
Set by an ECN-Capable sender when it reduces its congestion window (due to a retransmit timeout, a fast retransmit or in response to an ECN notification.
ECN (Explicit Congestion Notification)
During the three-way handshake it indicates that sender is capable of performing explicit congestion notification. Normally it means that a packet with the IP Congestion Experienced flag set was received during normal transmission. See RFC 3168 for more information.
TCP的连接建立和连接关闭,都是通过请求-响应的模式完成的。我们来看下图,应该基本能够理解TCP握手挥手过程
正在上传...取消
Three-way Handshake 三次握手
三次握手的目的是:为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。推荐阅读《TCP的三次握手与四次挥手(详解+动图》
当然,如果那边同时打开,就有可能是四次握手
在此推荐阅读《面试题·TCP 为什么要三次握手,四次挥手?》
TCP 连接的初始化序列号能否固定
单个TCP包每次打包1448字节的数据进行发送(以太网Ethernet最大的数据帧是1518字节,以太网帧的帧头14字节和帧尾CRC校验4字节(共占18字节),剩下承载上层协议的地方也就是Data域最大就只剩1500字节. 这个值我们就把它称之为MTU(Maximum Transmission Unit))。
那么一次性发送大量数据,就必须分成多个包。比如,一个 10MB 的文件,需要发送7100多个包。
发送的时候,TCP 协议为每个包编号(sequence number,简称 SEQ),以便接收的一方按照顺序还原。万一发生丢包,也可以知道丢失的是哪一个包。
第一个包的编号是一个随机数—初始化序列号(缩写为ISN:Inital Sequence Number)
为了便于理解,这里就把它称为1号包。假定这个包的负载长度是100字节,那么可以推算出下一个包的编号应该是101。这就是说,每个数据包都可以得到两个编号:自身的编号,以及下一个包的编号。接收方由此知道,应该按照什么顺序将它们还原成原始文件。
如果初始化序列号可以固定,我们来看看会出现什么问题?
假设ISN固定是1,Client和Server建立好一条TCP连接后,Client连续给Server发了10个包,这10个包不知怎么被链路上的路由器缓存了(路由器会毫无先兆地缓存或者丢弃任何的数据包),这个时候碰巧Client挂掉了,然后Client用同样的端口号重新连上Server,Client又连续给Server发了几个包,假设这个时候Client的序列号变成了5。接着,之前被路由器缓存的10个数据包全部被路由到Server端了,Server给Client回复确认号10,这个时候,Client整个都不好了,这是什么情况?我的序列号才到5,你怎么给我的确认号是10了,整个都乱了。
RFC793中,建议ISN和一个假的时钟绑在一起,这个时钟会在每4微秒对ISN做加一操作,直到超过2^32,又从0开始,这需要4小时才会产生ISN的回绕问题,这几乎可以保证每个新连接的ISN不会和旧的连接的ISN产生冲突。这种递增方式的ISN,很容易让攻击者猜测到TCP连接的ISN,现在的实现大多是在一个基准值的基础上进行随机的。
注:这些内容引用自《从 TCP 三次握手说起:浅析TCP协议中的疑难杂症 》,推荐查看。
正在上传...取消
初始化连接的 SYN 超时问题
Client发送SYN包给Server后挂了,Server回给Client的SYN-ACK一直没收到Client的ACK确认,这个时候这个连接既没建立起来,也不能算失败。这就需要一个超时时间让Server将这个连接断开,否则这个连接就会一直占用Server的SYN连接队列中的一个位置,大量这样的连接就会将Server的SYN连接队列耗尽,让正常的连接无法得到处理。
目前,Linux下默认会进行5次重发SYN-ACK包,重试的间隔时间从1s开始,下次的重试间隔时间是前一次的双倍,5次的重试时间间隔为1s, 2s, 4s, 8s, 16s,总共31s,第5次发出后还要等32s都知道第5次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 63s,TCP才会把断开这个连接。由于,SYN超时需要63秒,那么就给攻击者一个攻击服务器的机会,攻击者在短时间内发送大量的SYN包给Server(俗称SYN flood攻击),用于耗尽Server的SYN队列。对于应对SYN 过多的问题,linux提供了几个TCP参数:tcp_syncookies、tcp_synack_retries、tcp_max_syn_backlog、tcp_abort_on_overflow 来调整应对。
什么是 SYN 攻击(SYN Flood)
SYN 攻击指的是,攻击客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送SYN包,服务器回复确认包,并等待客户的确认。由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的SYN包将长时间占用未连接队列,正常的SYN请求被丢弃,导致目标系统运行缓慢,严重者会引起网络堵塞甚至系统瘫痪。
SYN 攻击是一种典型的 DoS(Denial of Service)/DDoS(:Distributed Denial of Service) 攻击。
如何检测 SYN 攻击?
检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。在 Linux/Unix 上可以使用系统自带的 netstats 命令来检测 SYN 攻击。
如何防御 SYN 攻击?
SYN攻击不能完全被阻止,除非将TCP协议重新设计。我们所做的是尽可能的减轻SYN攻击的危害,常见的防御 SYN 攻击的方法有如下几种:
缩短超时(SYN Timeout)时间
增加最大半连接数
过滤网关防护
SYN cookies技术
如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75分钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。
Four-way Handshake 四次挥手
现来看下TCP各种状态含义解析(节选改编自《TCP、UDP 的区别,三次握手、四次挥手》
TCP 的 Peer 两端同时断开连接
由上面的”TCP协议状态机 “图可以看出
TCP的Peer端在收到对端的FIN包前 发出了FIN包,那么该Peer的状态就变成了FIN_WAIT1
Peer在FIN_WAIT1状态下收到对端Peer对自己FIN包的ACK包的话,那么Peer状态就变成FIN_WAIT2,
Peer在FIN_WAIT2下收到对端Peer的FIN包,在确认已经收到了对端Peer全部的Data数据包后,就响应一个ACK给对端Peer,然后自己进入TIME_WAIT状态。
但是如果Peer在FIN_WAIT1状态下首先收到对端Peer的FIN包的话,那么该Peer在确认已经收到了对端Peer全部的Data数据包后,就响应一个ACK给对端Peer,然后自己进入CLOSEING状态,Peer在CLOSEING状态下收到自己的FIN包的ACK包的话,那么就进入TIME WAIT 状态。于是
TCP的Peer两端同时发起FIN包进行断开连接,那么两端Peer可能出现完全一样的状态转移 FIN_WAIT1——>CLOSEING——->TIME_WAIT,也就会Client和Server最后同时进入TIME_WAIT状态
TCP 的 TIME_WAIT 状态
要说明TIME_WAIT的问题,需要解答以下几个问题:
Peer两端,哪一端会进入TIME_WAIT呢?为什么?
相信大家都知道,TCP主动关闭连接的那一方会最后进入TIME_WAIT。
那么怎么界定主动关闭方呢?
是否主动关闭是由FIN包的先后决定的,就是在自己没收到对端Peer的FIN包之前自己发出了FIN包,那么自己就是主动关闭连接的那一方。对于TCP 的 Peer 两端同时断开连接 描述的情况,那么Peer两边都是主动关闭的一方,两边都会进入TIME_WAIT。为什么是主动关闭的一方进行TIME_WAIT呢,被动关闭的进入TIME_WAIT可以不呢?
我们来看看TCP四次挥手可以简单分为下面三个过程
过程一.主动关闭方 发送FIN;
过程二.被动关闭方收到主动关闭方的FIN后 发送该FIN的ACK,被动关闭方发送FIN;
过程三.主动关闭方 收到被动关闭方的FIN后发送该FIN的ACK,被动关闭方等待自己FIN的ACK问题就在过程三中,据TCP协议规范,不对ACK进行ACK。
如果主动关闭方不进入TIME_WAIT,那么主动关闭方在发送完ACK就走了的话:如果最后发送的ACK在路由过程中丢掉了,最后没能到被动关闭方,这个时候被动关闭方没收到自己FIN的ACK就不能关闭连接,接着被动关闭方 会超时重发FIN包,但是这个时候已经没有对端会给该FIN回ACK,被动关闭方就无法正常关闭连接了,所以主动关闭方需要进入TIME_WAIT以便能够重发丢掉的被动关闭方FIN的ACK。
TIME_WAIT状态为什么需要经过2MSL的时间才关闭连接呢?
为了保证A发送的最后一个确认报文段能够到达B。这个确认报文段可能会丢失,如果B收不到这个确认报文段,其会重传第三次“挥手”发送的FIN+ACK报文,而A则会在2MSL时间内收到这个重传的报文段,每次A收到这个重传报文段后,就会重启2MSL计时器。这样可以保证A和B都能正常关闭连接。
为了防止已失效的报文段出现在下一次连接中。A经过2MSL时间后,可以保证在本次连接中传输的报文段都在网络中消失,这样一来就能保证在后面的连接中不会出现旧的连接产生的报文段了。
TIME_WAIT状态是用来解决或避免什么问题呢?
TIME_WAIT主要是用来解决以下几个问题:
上面解释为什么主动关闭方需要进入TIME_WAIT状态中提到的: 主动关闭方需要进入TIME_WAIT 以便能够重发丢掉的被动关闭方FIN的ACK。如果主动关闭方不进入TIME_WAIT,那么在主动关闭方对被动关闭方FIN包的ACK丢失了的时候,被动关闭方由于没收到自己FIN的ACK,会进行重传FIN包,这个FIN包到主动关闭方后,由于这个连接已经不存在于主动关闭方了,这个时候主动关闭方无法识别这个FIN包,协议栈会认为对方疯了,都还没建立连接你给我来个FIN包?于是回复一个RST包给被动关闭方,被动关闭方就会收到一个错误(我们见的比较多的:connect reset by peer,这里顺便说下 Broken pipe,在收到RST包的时候,还往这个连接写数据,就会收到 Broken pipe错误了),原本应该正常关闭的连接,给我来个错误,很难让人接受。
防止已经断开的连接1中在链路中残留的FIN包终止掉新的连接2(重用了连接1的所有的5元素(源IP,目的IP,TCP,源端口,目的端口)),这个概率比较低,因为涉及到一个匹配问题,迟到的FIN分段的序列号必须落在连接2的一方的期望序列号范围之内,虽然概率低,但是确实可能发生,因为初始序列号都是随机产生的,并且这个序列号是32位的,会回绕。
防止链路上已经关闭的连接的残余数据包(a lost duplicate packet or a wandering duplicate packet) 干扰正常的数据包,造成数据流的不正常。这个问题和2)类似
TIME_WAIT会带来哪些问题呢
TIME_WAIT带来的问题注意是源于:一个连接进入TIME_WAIT状态后需要等待2*MSL(一般是1到4分钟)那么长的时间才能断开连接释放连接占用的资源,会造成以下问题
作为服务器,短时间内关闭了大量的Client连接,就会造成服务器上出现大量的TIME_WAIT连接,占据大量的tuple,严重消耗着服务器的资源。
作为客户端,短时间内大量的短连接,会大量消耗的Client机器的端口,毕竟端口只有65535个,端口被耗尽了,后续就无法在发起新的连接了。
( 由于上面两个问题,作为客户端需要连本机的一个服务的时候,首选UNIX域套接字而不是TCP )
TIME_WAIT很令人头疼,很多问题是由TIME_WAIT造成的,但是TIME_WAIT又不是多余的不能简单将TIME_WAIT去掉,那么怎么来解决或缓解TIME_WAIT问题呢?可以进行TIME_WAIT的快速回收和重用来缓解TIME_WAIT的问题。
有没一些清掉TIME_WAIT的技巧呢?
修改tcp_max_tw_buckets:tcp_max_tw_buckets 控制并发的TIME_WAIT的数量,默认值是180000。如果超过默认值,内核会把多的TIME_WAIT连接清掉,然后在日志里打一个警告。官网文档说这个选项只是为了阻止一些简单的DoS攻击,平常不要人为的降低它。
利用RST包从外部清掉TIME_WAIT链接:根据TCP规范,收到任何的发送到未侦听端口、已经关闭的连接的数据包、连接处于任何非同步状态(LISTEN, SYS-SENT, SYN-RECEIVED)并且收到的包的ACK在窗口外,或者安全层不匹配,都要回执以RST响应(而收到滑动窗口外的序列号的数据包,都要丢弃这个数据包,并回复一个ACK包),内核收到RST将会产生一个错误并终止该连接。我们可以利用RST包来终止掉处于TIME_WAIT状态的连接,其实这就是所谓的RST攻击了。为了描述方便:假设Client和Server有个连接Connect1,Server主动关闭连接并进入了TIME_WAIT状态,我们来描述一下怎么从外部使得Server的处于 TIME_WAIT状态的连接Connect1提前终止掉。要实现这个RST攻击,首先我们要知道Client在Connect1中的端口port1(一般这个端口是随机的,比较难猜到,这也是RST攻击较难的一个点),利用IP_TRANSPARENT这个socket选项,它可以bind不属于本地的地址,因此可以从任意机器绑定Client地址以及端口port1,然后向Server发起一个连接,Server收到了窗口外的包于是响应一个ACK,这个ACK包会路由到Client处,这个时候99%的可能Client已经释放连接connect1了,这个时候Client收到这个ACK包,会发送一个RST包,server收到RST包然后就释放连接connect1提前终止TIME_WAIT状态了。提前终止TIME_WAIT状态是可能会带来(问题二、)中说的三点危害,具体的危害情况可以看下RFC1337。RFC1337中建议,不要用RST过早的结束TIME_WAIT状态。
TCP的延迟确认机制
TCP在何时发送ACK的时候有如下规定:
当有响应数据发送的时候,ACK会随着数据一块发送
如果没有响应数据,ACK就会有一个延迟,以等待是否有响应数据一块发送,但是这个延迟一般在40ms~500ms之间,一般情况下在40ms左右,如果在40ms内有数据发送,那么ACK会随着数据一块发送,对于这个延迟的需要注意一下,这个延迟并不是指的是收到数据到发送ACK的时间延迟,而是内核会启动一个定时器,每隔200ms就会检查一次,比如定时器在0ms启动,200ms到期,180ms的时候data来到,那么200ms的时候没有响应数据,ACK仍然会被发送,这个时候延迟了20ms.
这样做有两个目的。
这样做的目的是ACK是可以合并的,也就是指如果连续收到两个TCP包,并不一定需要ACK两次,只要回复最终的ACK就可以了,可以降低网络流量。
如果接收方有数据要发送,那么就会在发送数据的TCP数据包里,带上ACK信息。这样做,可以避免大量的ACK以一个单独的TCP包发送,减少了网络流量。
如果在等待发送ACK期间,第二个数据又到了,这时候就要立即发送ACK!
按照TCP协议,确认机制是累积的。也就是确认号X的确认指示的是所有X之前但不包括X的数据已经收到了。确认号(ACK)本身就是不含数据的分段,因此大量的确认号消耗了大量的带宽,虽然大多数情况下,ACK还是可以和数据一起捎带传输的,但是如果没有捎带传输,那么就只能单独回来一个ACK,如果这样的分段太多,网络的利用率就会下降。为缓解这个问题,RFC建议了一种延迟的ACK,也就是说,ACK在收到数据后并不马上回复,而是延迟一段可以接受的时间。延迟一段时间的目的是看能不能和接收方要发给发送方的数据一起回去,因为TCP协议头中总是包含确认号的,如果能的话,就将数据一起捎带回去,这样网络利用率就提高了。延迟ACK就算没有数据捎带,那么如果收到了按序的两个包,那么只要对第二包做确认即可,这样也能省去一个ACK消耗。由于TCP协议不对ACK进行ACK的,RFC建议最多等待2个包的积累确认,这样能够及时通知对端Peer,我这边的接收情况。Linux实现中,有延迟ACK(Delay Ack)和快速ACK,并根据当前的包的收发情况来在这两种ACK中切换:在收到数据包的时候需要发送ACK,进行快速ACK;否则进行延迟ACK(在无法使用快速确认的条件下也是)。
一般情况下,ACK并不会对网络性能有太大的影响,延迟ACK能减少发送的分段从而节省了带宽,而快速ACK能及时通知发送方丢包,避免滑动窗口停等,提升吞吐率。
关于ACK分段,有个细节需要说明一下:
ACK的确认号,是确认按序收到的最后一个字节序,对于乱序到来的TCP分段,接收端会回复相同的ACK分段,只确认按序到达的最后一个TCP分段。TCP连接的延迟确认时间一般初始化为最小值40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。
推荐查看《TCP-IP详解:Delay ACK》
TCP的重传机制以及重传的超时计算
前面说过,每一个数据包都带有下一个数据包的编号。如果下一个数据包没有收到,那么 ACK 的编号就不会发生变化
如果发送方发现收到三个连续的重复 ACK,或者超时了还没有收到任何 ACK,就会确认丢包,从而再次发送这个包。
TCP的重传超时计算
TCP交互过程中,如果发送的包一直没收到ACK确认,是要一直等下去吗?
显然不能一直等(如果发送的包在路由过程中丢失了,对端都没收到又如何给你发送确认呢?),这样协议将不可用,既然不能一直等下去,那么该等多久呢?等太长时间的话,数据包都丢了很久了才重发,没有效率,性能差;等太短时间的话,可能ACK还在路上快到了,这时候却重传了,造成浪费,同时过多的重传会造成网络拥塞,进一步加剧数据的丢失。也是,我们不能去猜测一个重传超时时间,应该是通过一个算法去计算,并且这个超时时间应该是随着网络的状况在变化的。为了使我们的重传机制更高效,如果我们能够比较准确知道在当前网络状况下,一个数据包从发出去到回来的时间RTT(Round Trip Time),那么根据这个RTT(我们就可以方便设置RTO(Retransmission TimeOut)了。
如何计算设置这个RTO?
设长了,重发就慢,丢了老半天才重发,没有效率,性能差;
设短了,会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
RFC793中定义了一个经典算法——加权移动平均(Exponential weighted moving average),算法如下:
首先采样计算RTT(Round Trip Time)值——也就是一个数据包从发出去到回来的时间
然后计算平滑的RTT,称为SRTT(Smoothed Round Trip Time),SRTT = ( ALPHA * SRTT ) + ((1-ALPHA) * RTT)——其中的 α 取值在0.8 到 0.9之间
RTO = min[UpBOUND,max[LowBOUND,(BETA*SRTT)]]——BETA(延迟方差因子(BETA is a delay variance factor (e.g., 1.3 to 2.0))
针对上面算法问题,有众多大神改进,难以长篇累牍,推荐阅读《TCP 的那些事儿》、《TCP中RTT的测量和RTO的计算》
TCP的重传机制
通过上面我们可以知道,TCP的重传是由超时触发的,这会引发一个重传选择问题,假设TCP发送端连续发了1、2、3、4、5、6、7、8、9、10共10包,其中4、6、8这3个包全丢失了,由于TCP的ACK是确认最后连续收到序号,这样发送端只能收到3号包的ACK,这样在TIME_OUT的时候,发送端就面临下面两个重传选择:
仅重传4号包
优点:按需重传,能够最大程度节省带宽。
缺点:重传会比较慢,因为重传4号包后,需要等下一个超时才会重传6号包
重传3号后面所有的包,也就是重传4~10号包
优点:重传较快,数据能够较快交付给接收端。
缺点:重传了很多不必要重传的包,浪费带宽,在出现丢包的时候,一般是网络拥塞,大量的重传又可能进一步加剧拥塞。
上面的问题是由于单纯以时间驱动来进行重传的,都必须等待一个超时时间,不能快速对当前网络状况做出响应,如果加入以数据驱动呢?
TCP引入了一种叫Fast Retransmit(快速重传 )的算法,就是在连续收到3次相同确认号的ACK,那么就进行重传。这个算法基于这么一个假设,连续收到3个相同的ACK,那么说明当前的网络状况变好了,可以重传丢失的包了。
快速重传解决了timeout的问题,但是没解决重传一个还是重传多个的问题。出现难以决定是否重传多个包问题的根源在于,发送端不知道那些非连续序号的包已经到达接收端了,但是接收端是知道的,如果接收端告诉一下发送端不就可以解决这个问题吗?于是,RFC2018提出了 SACK(Selective Acknowledgment)——选择确认机制,SACK是TCP的扩展选项
一个SACK的例子如下图,红框说明:接收端收到了0-5500,8000-8500,7000-7500,6000-6500的数据了,这样发送端就可以选择重传丢失的5500-6000,6500-7000,7500-8000的包。
SACK依靠接收端的接收情况反馈,解决了重传风暴问题,这样够了吗?接收端能不能反馈更多的信息呢?显然是可以的,于是,RFC2883对对SACK进行了扩展,提出了D-SACK,也就是利用第一块SACK数据中描述重复接收的不连续数据块的序列号参数,其他SACK数据则描述其他正常接收到的不连续数据。这样发送方利用第一块SACK,可以发现数据段被网络复制、错误重传、ACK丢失引起的重传、重传超时等异常的网络状况,使得发送端能更好调整自己的重传策略。
D-SACK,有几个优点:
发送端可以判断出,是发包丢失了,还是接收端的ACK丢失了。(发送方,重传了一个包,发现并没有D-SACK那个包,那么就是发送的数据包丢了;否则就是接收端的ACK丢了,或者是发送的包延迟到达了)
发送端可以判断自己的RTO是不是有点小了,导致过早重传(如果收到比较多的D-SACK就该怀疑是RTO小了)。
发送端可以判断自己的数据包是不是被复制了。(如果明明没有重传该数据包,但是收到该数据包的D-SACK)
发送端可以判断目前网络上是不是出现了有些包被delay了,也就是出现先发的包却后到了。
TCP的流量控制
ACK携带两个信息。
期待要收到下一个数据包的编号
接收方的接收窗口的剩余容量
TCP的标准窗口最大为2^16-1=65535个字节
TCP的选项字段中还包含了一个TCP窗口扩大因子,option-kind为3,option-length为3个字节,option-data取值范围0-14
窗口扩大因子用来扩大TCP窗口,可把原来16bit的窗口,扩大为31bit。这个窗口是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。也就是:
发送端是根据接收端通知的窗口大小来调整自己的发送速率的,以达到端到端的流量控制——Sliding Window(滑动窗口)。
TCP的窗口机制
TCP协议里窗口机制有2种:一种是固定的窗口大小;一种是滑动的窗口。
这个窗口大小就是我们一次传输几个数据。对所有数据帧按顺序赋予编号,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送;同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。
下面一张图来分析一下固定窗口大小有什么问题
假设窗口的大小是1,也是就每次只能发送一个数据只有接受方对这个数据进行确认了以后才能发送第2个数据。我们可以看到发送方每发送一个数据接受方就要给发送方一个ACK对这个数据进行确认。只有接受到了这个确认数据以后发送方才能传输下个数据。 这样我们考虑一下如果说窗口过小,那么当传输比较大的数据的时候需要不停的对数据进行确认,这个时候就会造成很大的延迟。如果说窗口的大小定义的过大。我们假设发送方一次发送100个数据。但是接收方只能处理50个数据。这样每次都会只对这50个数据进行确认。发送方下一次还是发送100个数据,但是接受方还是只能处理50个数据。这样就避免了不必要的数据来拥塞我们的链路。所以我们就引入了滑动窗口机制,窗口的大小并不是固定的而是根据我们之间的链路的带宽的大小,这个时候链路是否拥护塞。接受方是否能处理这么多数据了。
我们看看滑动窗口是如何工作的
首先是第一次发送数据这个时候的窗口大小是根据链路带宽的大小来决定的。我们假设这个时候窗口的大小是3。这个时候接受方收到数据以后会对数据进行确认告诉发送方我下次希望手到的是数据是多少。这里我们看到接收方发送的ACK=3(这是发送方发送序列2的回答确认,下一次接收方期望接收到的是3序列信号)。这个时候发送方收到这个数据以后就知道我第一次发送的3个数据对方只收到了2个。就知道第3个数据对方没有收到。下次在发送的时候就从第3个数据开始发。这个时候窗口大小就变成了2 。
这个时候发送方发送2个数据。
看到接收方发送的ACK是5就表示他下一次希望收到的数据是5,发送方就知道我刚才发送的2个数据对方收了这个时候开始发送第5个数据。
这就是滑动窗口的工作机制,当链路变好了或者变差了这个窗口还会发生变话,并不是第一次协商好了以后就永远不变了。
TCP滑动窗口剖析
滑动窗口协议的基本原理就是在任意时刻,发送方都维持了一个连续的允许发送的帧的序号,称为发送窗口;同时,接收方也维持了一个连续的允许接收的帧的序号,称为接收窗口。发送窗口和接收窗口的序号的上下界不一定要一样,甚至大小也可以不同。不同的滑动窗口协议窗口大小一般不同。
窗口有3种动作:展开(右边向右),合拢(左边向右),收缩(右边向左)这三种动作受接收端的控制。
合拢:表示已经收到相应字节的确认了
展开:表示允许缓存发送更多的字节
收缩(非常不希望出现的,某些实现是禁止的):表示本来可以发送的,现在不能发送;但是如果收缩的是那些已经发出的,就会有问题;为了避免,收端会等待到缓存中有更多缓存空间时才进行通信。
滑动窗口机制
比特滑动窗口协议
当发送窗口和接收窗口的大小固定为1时,滑动窗口协议退化为停等协议(stop-and-wait)。该协议规定发送方每发送一帧后就要停下来,等待接收方已正确接收的确认(acknowledgement)返回后才能继续发送下一帧。由于接收方需要判断接收到的帧是新发的帧还是重新发送的帧,因此发送方要为每一个帧加一个序号。由于停等协议规定只有一帧完全发送成功后才能发送新的帧,因而只用一比特来编号就够了。其发送方和接收方运行的流程图如图所示。
后退n协议
由于停等协议要为每一个帧进行确认后才继续发送下一帧,大大降低了信道利用率,因此又提出了后退n协议。后退n协议中,发送方在发完一个数据帧后,不停下来等待应答帧,而是连续发送若干个数据帧,即使在连续发送过程中收到了接收方发来的应答帧,也可以继续发送。且发送方在每发送完一个数据帧时都要设置超时定时器。只要在所设置的超时时间内仍收到确认帧,就要重发相应的数据帧。如:当发送方发送了N个帧后,若发现该N帧的前一个帧在计时器超时后仍未返回其确认信息,则该帧被判为出错或丢失,此时发送方就不得不重新发送出错帧及其后的N帧。
从这里不难看出,后退n协议一方面因连续发送数据帧而提高了效率,但另一方面,在重传时又必须把原来已正确传送过的数据帧进行重传(仅因这些数据帧之前有一个数据帧出了错),这种做法又使传送效率降低。由此可见,若传输信道的传输质量很差因而误码率较大时,连续测协议不一定优于停止等待协议。此协议中的发送窗口的大小为k,接收窗口仍是1。
选择重传协议
在后退n协议中,接收方若发现错误帧就不再接收后续的帧,即使是正确到达的帧,这显然是一种浪费。另一种效率更高的策略是当接收方发现某帧出错后,其后继续送来的正确的帧虽然不能立即递交给接收方的高层,但接收方仍可收下来,存放在一个缓冲区中,同时要求发送方重新传送出错的那一帧。一旦收到重新传来的帧后,就可以原已存于缓冲区中的其余帧一并按正确的顺序递交高层。这种方法称为选择重发(SELECTICE REPEAT),其工作过程如图所示。显然,选择重发减少了浪费,但要求接收方有足够大的缓冲区空间。
流量控制
所谓流量控制,主要是接收方传递信息给发送方,使其不要发送数据太快,是一种端到端的控制。主要的方式就是返回的ACK中会包含自己的接收窗口的大小,并且利用大小来控制发送方的数据发送。
上图中,我们可以看到:
接收端LastByteRead指向了TCP缓冲区中读到的位置,NextByteExpected指向的地方是收到的连续包的最后一个位置,LastByteRcved指向的是收到的包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区。
发送端的LastByteAcked指向了被接收端Ack过的位置(表示成功发送确认),LastByteSent表示发出去了,但还没有收到成功确认的Ack,LastByteWritten指向的是上层应用正在写的地方。
于是:
接收端在给发送端回ACK中会汇报自己的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理。
下面我们来看一下发送方的滑动窗口示意图:
发送端是怎么做到比较方便知道自己哪些包可以发,哪些包不能发呢?
一个简明的方案就是按照接收方的窗口通告,发送方维护一个一样大小的发送窗口就可以了。在窗口内的可以发,窗口外的不可以发,窗口在发送序列上不断后移,这就是TCP中的滑动窗口。如下图所示,对于TCP发送端其发送缓存内的数据都可以分为4类
[1]-已经发送并得到接收端ACK的;
[2]-已经发送但还未收到接收端ACK的;
[3]-未发送但允许发送的(接收方还有空间);
[4]-未发送且不允许发送(接收方没空间了)。
其中,[2]和[3]两部分合起来称之为发送窗口。
下面两图演示的窗口的滑动情况,收到36的ACK后,窗口向后滑动5个byte。
如果接收端通知一个零窗口给发送端,这个时候发送端还能不能发送数据呢?如果不发数据,那一直等接收端口通知一个非0窗口吗,如果接收端一直不通知呢?
下图,展示了一个发送端是怎么受接收端控制的。
由上图我们知道,当接收端通知一个zero窗口的时候,发送端的发送窗口也变成了0,也就是发送端不能发数据了。如果发送端一直等待,直到接收端通知一个非零窗口在发数据的话,这似乎太受限于接收端,如果接收端一直不通知新的窗口呢?显然发送端不能干等,起码有一个主动探测的机制。为解决0窗口的问题,TCP使用了ZWP(Zero Window Probe)。
Zero Window
发送端在窗口变成0后,会发ZWP的包给接收方,来探测目前接收端的窗口大小,让接收方来ack他的Window尺寸。一般这个值会设置成3次,每次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST掉这个连接。
注意:只要有等待的地方都可能出现DDoS攻击。攻击者可以在和Server建立好连接后,就向Server通告一个0窗口,然后Server端就只能等待进行ZWP,于是攻击者会并发大量的这样的请求,把Server端的资源耗尽。
Silly Window Syndrome
如果接收端处理能力很慢,这样接收端的窗口很快被填满,然后接收处理完几个字节,腾出几个字节的窗口后,通知发送端,这个时候发送端马上就发送几个字节给接收端吗?发送的话会不会太浪费了,就像一艘万吨油轮只装上几斤的油就开去目的地一样。我们的TCP+IP头有40个字节,为了几个字节,要达上这么大的开销,这太不经济了。
对于发送端产生数据的能力很弱也一样,如果发送端慢吞吞产生几个字节的数据要发送,这个时候该不该立即发送呢?还是累积多点在发送?
本质就是一个避免发送大量小包的问题。造成这个问题原因有二:
接收端一直在通知一个小的窗口;
在接收端解决这个问题,David D Clark’s 方案,如果收到的数据导致window size小于某个值,就ACK一个0窗口,这就阻止发送端在发数据过来。等到接收端处理了一些数据后windows size 大于等于了MSS,或者buffer有一半为空,就可以通告一个非0窗口。
发送端本身问题,一直在发送小包。这个问题,TCP中有个术语叫Silly Window Syndrome(糊涂窗口综合症)。解决这个问题的思路有两:
接收端不通知小窗口,
发送端积累一下数据在发送。
是在发送端解决这个问题,有个著名的Nagle’s algorithm。Nagle 算法的规则
如果包长度达到 MSS ,则允许发送;
如果该包含有 FIN ,则允许发送;
设置了 TCP_NODELAY 选项,则允许发送;
设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS )均被确认,则允许发送;
上述条件都未满足,但发生了超时(一般为 200ms ),则立即发送。
规则[4]指出TCP连接上最多只能有一个未被确认的小数据包。从规则[4]可以看出Nagle算法并不禁止发送小的数据包(超时时间内),而是避免发送大量小的数据包。由于Nagle算法是依赖ACK的,如果ACK很快的话,也会出现一直发小包的情况,造成网络利用率低。TCP_CORK选项则是禁止发送小的数据包(超时时间内),设置该选项后,TCP会尽力把小数据包拼接成一个大的数据包(一个 MTU)再发送出去,当然也不会一直等,发生了超时(一般为 200ms ),也立即发送。Nagle 算法和CP_CORK 选项提高了网络的利用率,但是增加是延时。从规则[3]可以看出,设置TCP_NODELAY 选项,就是完全禁用Nagle 算法了。
这里要说一个小插曲,Nagle算法和延迟确认(Delayed Acknoledgement)一起,当出现( write-write-read)的时候会引发一个40ms的延时问题,这个问题在HTTP svr中体现的比较明显。场景如下:
客户端在请求下载HTTP svr中的一个小文件,一般情况下,HTTP svr都是先发送HTTP响应头部,然后在发送HTTP响应BODY(特别是比较多的实现在发送文件的实施采用的是sendfile系统调用,这就出现write-write-read模式了)。当发送头部的时候,由于头部较小,于是形成一个小的TCP包发送到客户端,这个时候开始发送body,由于body也较小,这样还是形成一个小的TCP数据包,根据Nagle算法,HTTP svr已经发送一个小的数据包了,在收到第一个小包的ACK后或等待200ms超时后才能在发小包,HTTP svr不能发送这个body小TCP包;
客户端收到http响应头后,由于这是一个小的TCP包,于是客户端开启延迟确认,客户端在等待Svr的第二个包来在一起确认或等待一个超时(一般是40ms)在发送ACK包;这样就出现了你等我、然而我也在等你的死锁状态,于是出现最多的情况是客户端等待一个40ms的超时,然后发送ACK给HTTP svr,HTTP svr收到ACK包后在发送body部分。大家在测HTTP svr的时候就要留意这个问题了。
推荐阅读《TCP/IP之TCP协议:流量控制(滑动窗口协议)》
TCP的拥塞控制
由于TCP看不到网络的状况,那么拥塞控制是必须的并且需要采用试探性的方式来控制拥塞,于是拥塞控制要完成两个任务:[1]公平性;[2]拥塞过后的恢复。
重介绍一下Reno算法(RFC5681),其包含4个部分:
[1]慢热启动算法 – Slow Start
[2]拥塞避免算法 – Congestion Avoidance;
[3]快速重传 - Fast Retransimit;
[4]快速恢复算法 – Fast Recovery。
慢热启动算法 – Slow Start
我们怎么知道,对方线路的理想速率是多少呢?答案就是慢慢试。
开始的时候,发送得较慢,然后根据丢包的情况,调整速率:如果不丢包,就加快发送速度;如果丢包,就降低发送速度。慢启动的算法如下(cwnd全称Congestion Window):
连接建好的开始先初始化cwnd = N,表明可以传N个MSS大小的数据。
每当收到一个ACK,++cwnd; 呈线性上升
每当过了一个RTT,cwnd = cwnd*2; 呈指数让升
还有一个慢启动门限ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入"拥塞避免算法 - Congestion Avoidance"
根据RFC5681,如果MSS > 2190 bytes,则N = 2;如果MSS < 1095 bytes,则N = 4;如果2190 bytes >= MSS >= 1095 bytes,则N = 3;一篇Google的论文《An Argument for Increasing TCP’s Initial Congestion Window》建议把cwnd 初始化成了 10个MSS。Linux 3.0后采用了这篇论文的建议(Linux 内核里面设定了(常量TCP_INIT_CWND),刚开始通信的时候,发送方一次性发送10个数据包,即"发送窗口"的大小为10。然后停下来,等待接收方的确认,再继续发送)
拥塞避免算法 – Congestion Avoidance
慢启动的时候说过,cwnd是指数快速增长的,但是增长是有个门限ssthresh(一般来说大多数的实现ssthresh的值是65535字节)的,到达门限后进入拥塞避免阶段。在进入拥塞避免阶段后,cwnd值变化算法如下:
每收到一个ACK,调整cwnd 为 (cwnd + 1/cwnd) * MSS个字节
每经过一个RTT的时长,cwnd增加1个MSS大小。
TCP是看不到网络的整体状况的,那么TCP认为网络拥塞的主要依据是它重传了报文段。前面我们说过TCP的重传分两种情况:
出现RTO超时,重传数据包。这种情况下,TCP就认为出现拥塞的可能性就很大,于是它反应非常'强烈'
调整门限ssthresh的值为当前cwnd值的1/2。
reset自己的cwnd值为1
然后重新进入慢启动过程。
在RTO超时前,收到3个duplicate ACK进行重传数据包。这种情况下,收到3个冗余ACK后说明确实有中间的分段丢失,然而后面的分段确实到达了接收端,因为这样才会发送冗余ACK,这一般是路由器故障或者轻度拥塞或者其它不太严重的原因引起的,因此此时拥塞窗口缩小的幅度就不能太大,此时进入快速重传。
快速重传 - Fast Retransimit 做的事情有:
调整门限ssthresh的值为当前cwnd值的1/2。
将cwnd值设置为新的ssthresh的值
重新进入拥塞避免阶段。
在快速重传的时候,一般网络只是轻微拥堵,在进入拥塞避免后,cwnd恢复的比较慢。针对这个,“快速恢复”算法被添加进来,当收到3个冗余ACK时,TCP最后的[3]步骤进入的不是拥塞避免阶段,而是快速恢复阶段。
快速恢复算法 – Fast Recovery :
快速恢复的思想是“数据包守恒”原则,即带宽不变的情况下,在网络同一时刻能容纳数据包数量是恒定的。当“老”数据包离开了网络后,就能向网络中发送一个“新”的数据包。既然已经收到了3个冗余ACK,说明有三个数据分段已经到达了接收端,既然三个分段已经离开了网络,那么就是说可以在发送3个分段了。于是只要发送方收到一个冗余的ACK,于是cwnd加1个MSS。快速恢复步骤如下(在进入快速恢复前,cwnd 和 sshthresh已被更新为:sshthresh = cwnd /2,cwnd = sshthresh):
把cwnd设置为ssthresh的值加3,重传Duplicated ACKs指定的数据包
如果再收到 duplicated Acks,那么cwnd = cwnd +1
如果收到新的ACK,而非duplicated Ack,那么将cwnd重新设置为【3】中1)的sshthresh的值。然后进入拥塞避免状态。
细心的同学可能会发现快速恢复有个比较明显的缺陷就是:它依赖于3个冗余ACK,并假定很多情况下,3个冗余的ACK只代表丢失一个包。但是3个冗余ACK也很有可能是丢失了很多个包,快速恢复只是重传了一个包,然后其他丢失的包就只能等待到RTO超时了。超时会导致ssthresh减半,并且退出了Fast Recovery阶段,多个超时会导致TCP传输速率呈级数下降。出现这个问题的主要原因是过早退出了Fast Recovery阶段。为解决这个问题,提出了New Reno算法,该算法是在没有SACK的支持下改进Fast Recovery算法(SACK改变TCP的确认机制,把乱序等信息会全部告诉对方,SACK本身携带的信息就可以使得发送方有足够的信息来知道需要重传哪些包,而不需要重传哪些包),具体改进如下:
发送端收到3个冗余ACK后,重传冗余ACK指示可能丢失的那个包segment1,如果segment1的ACK通告接收端已经收到发送端的全部已经发出的数据的话,那么就是只丢失一个包,如果没有,那么就是有多个包丢失了。
发送端根据segment1的ACK判断出有多个包丢失,那么发送端继续重传窗口内未被ACK的第一个包,直到sliding window内发出去的包全被ACK了,才真正退出Fast Recovery阶段。
我们可以看到,拥塞控制在拥塞避免阶段,cwnd是加性增加的,在判断出现拥塞的时候采取的是指数递减。为什么要这样做呢?这是出于公平性的原则,拥塞窗口的增加受惠的只是自己,而拥塞窗口减少受益的是大家。这种指数递减的方式实现了公平性,一旦出现丢包,那么立即减半退避,可以给其他新建的连接腾出足够的带宽空间,从而保证整个的公平性。
TCP发展到现在,拥塞控制方面的算法很多,请查看《wiki-具体实现算法》,《斐讯面试记录—TCP滑动窗口及拥塞控制》
总的来说TCP是一个有连接的、可靠的、带流量控制和拥塞控制的端到端的协议。TCP的发送端能发多少数据,由发送端的发送窗口决定(当然发送窗口又被接收端的接收窗口、发送端的拥塞窗口限制)的,那么一个TCP连接的传输稳定状态应该体现在发送端的发送窗口的稳定状态上,这样的话,TCP的发送窗口有哪些稳定状态呢?
其实《不为人知的网络编程:浅析TCP协议中的疑难杂症》讲的非常细,而且一遍文章根本总结不了(我也只是搬运工而已,因为所知的太少,都不像笔记了
基础科普类:https://hit-alibaba.github.io/interview/basic/network/HTTP.html
推荐文章:
《TCP 协议简介》(阮一峰)
推荐书本:
图解HTTP,图解TCP/IP 系列书(日本人出的)或者head first 系列的,嗑书的话:清华大学出版社的TCP/IP原理(美版教程翻译版)。不要读啥 十天学会网络工程、tcp ip 这样学的 一些水货文章,基本上是浪费时间。