TCP属于传输层协议,传输层的数据段是封装在网络层的IP数据包内传输的,而网络层传输并不可靠,比如可能会出现丢包情况,并且每个IP数据包在路由间跳转的时候都是独立选路的导致先发送的数据包可能比后发送的数据包晚到达等,TCP有一套健全的机制提供一个可靠的连接。

1 TCP头部

TCP实现可靠性需要依赖头部中的各种字段。TCP头部大小一般是20字节(没有额外的可选项的情况下),格式如下:

TCP头部

  • Source Port:16bit,发送方的端口号。
  • Destination Port:16bit,接收方端口号。
  • Sequence Number:32bit,发送序列号,表示本次发送的数据的第一个字节的序号。
  • Acknowledgement Number:32bit,接收序列号,表示该序号(不含)之前的数据都已经收到,因为TCP是全双工的(full-duplex,可以同时发送和接收),比如我们已经接收了一些数据,最后一个字节的序号是n,那么在发送数据时,就把TCP头部的Acknowledgement Number字段设成n+1
  • Data offset:数据偏移量,即TCP头部长度,表示TCP头部有多少个32bit长度。如果TCP头部包含了额外的选项,那么就不止20字节,所以这个字段是有必要的。注意这里的单位是32bit,也就是4字节,而这个字段长度是4bit,所以TCP头部的最大长度是15*4=60字节。
  • Reserved:保留空间,6bit,未来可能会使用。
  • 6个控制字段,每个1bit:
    • URG:表示Urgent Pointer(紧急指针)字段是否有效。
    • ACK:表示Acknowledgement Number字段是否有效。
    • PSH:push字段,发送方设置了告诉接收方收到数据后马上传给应用层,而不是留在缓存中,大部分实现没有把设置这个字段的API开放给应用层,而是由传输层自行设置。
    • RST:reset,重置连接,当连接出错或中止连接的时候会用到
    • SYN:synchronize,同步序列号,建立连接时用
    • FIN:finish,当所有数据已经发送时,设置这个字段(表示发送方完成任务,不代表接收方已经接收)
  • Window-size:本方的接收窗口大小,16bit。这个大小以字节为单位,头部不包含在内,TCP连接两端的窗口大小不必一致。
  • Checksum:检验和,16bit。它的计算方式是把参与计算的数据以16bit为单位划分(最后一段数据如果不是16bit,则用0补齐,但是补上的0只做检验和计算用,不会被当作数据传输),然后每个16bit取补码,把这些补码求和,这个和的补码就是检验和。TCP的检验和计算不仅包含头部和数据,还包括一个“伪头部”,伪头部信息依次是"源ip(32bit)-目的ip(32bit)-留白(全0,8bit)-协议类型(8bit)-TCP长度(16bit)",共12字节。因为检验和是以16bit为单位的求和计算,所以有明显的缺陷,比如数据中任何16bit对齐的数据交换了位置,检验和都是一样的。
  • Urgent Pointer:紧急指针,需要设置前面的URG字段才有效。这个指针的值是一个相对于发送序列号的偏移量(以字节为单位),偏移后指向紧急数据的最后一个字节,这个字节以及它前面的数据都是紧急数据。
  • Options:可选项,长度是8bit的整数倍。可选项可以包含TCP数据段最大长度(MSS,maximum segment size)等,详见rfc793。在计算检验和时也包含可选项。
  • Padding:填充项,内容全是二进制0,为了让TCP头部长度是32bit的整数倍。

2 TCP连接的建立-三次握手

TCP连接的双方需要先建立连接才能发送数据,也就是说,当应用层的数据传递到传输层,而传输层发现跟对应的socket(IP地址+端口号)没有建立连接,则会先尝试建立连接。

TCP连接的建立通常需要3个步骤,称为“三次握手”。我们假设发送方是客户端A,接收方是服务器B,那么在传输层的层面来看,建立连接的过程如下:

  1. A发送一个TCP数据段,内容是空的,但是头部SYN字段被设置,假设A当前的发送序列号是100
  2. B接收到A的数据段,返回一个数据段,设置ACK字段为101,这里注意,虽然A发送的是空的数据段,但是设置SYN字段会占用一个序列号。并且,B在确认接收到A发送的数据同时,也会要求A能接收并返回B发送的数据,所以B在返回的数据段头部也设置了SYN字段,假设B当前的发送序列号是456。这里相当于两步操作合并成一步。
  3. A接收到B返回的数据段,发现设置了SYN字段,于是A返回一个数据段,ACK的值是457

上面3个步骤的文字描述可以简化成:

  1. A->B,序列号100,SYN设置
  2. B->A,序列号456,ACK101,SYN设置
  3. A->B,ACK457

对于任何一端来说,必须满足下面2个条件,才能确认连接已建立:

  1. 自己发送了SYN包并收到了对方回应的ACK
  2. 收到了对方发送的SYN包,并回应ACK

因为TCP是全双工的,所以建立连接的时候就要确保双方都知道对方当前的序列号是多少,因为同一时刻不同的主机上面的序列号不一样(比如有的实现会在主机重启时重置序列号为0),如果没有这个过程,那么任何基于序列号的丢包检测以及重排等都没有意义。

我们来看一下3次握手如何满足这2个条件(每个条件都包含发送和接收两项):

  • 第1步的数据发出时,A满足了"发送SYN"这个条件,B收到数据后满足了“收到对方发送的SYN包”这个条件
  • 第2步的数据发出时,B满足了"发送SYN"、“回应ACK包”这两个条件,A收到后满足了 “收到对方回应的ACK包”、“收到对方发送的SYN包”这两个条件
  • 第3步的数据发出时,A满足了“回应ACK包”这个条件,B收到后满足“收到对方回应的ACK包”这个条件。

从上面的分析可知,直到第3次握手的数据发出时,A才进入“连接已建立”状态,而B则需要收到第3次握手的数据才进入“连接已建立”状态,所以3次握手是必不可少的。

握手的包丢失怎么办

因为TCP数据段是封装在IP包内传输的,可能会因为超时、路由跳数超过最大限值等原因发生丢包。

第1步丢包,A会触发重传机制

第2步丢包,因为A未收到ACK包,所以A也会触发重传,且B因为发送了SYN,丢包自然收不到ACK包,也会重传

第1步或第2步丢包的情况下,双方都不会进入“连接已建立”的状态,只要重传即可,具体的重传间隔跟实现有关。

第3步丢包比较特殊,这时A是“连接已建立”的状态,而B则在等待对应的ACK包。

但是继续往后看,这一点影响不大,因为A觉得连接已经建立,可能会向B发送数据,而B则不会发送数据,一直在等待ACK包,所以当A发送数据的时候,TCP头部中的acknowledgement number的值依然是B在等待的回应值,当数据段到达时,B读取到这个值,表明“连接已建立”,然后继续处理相应的数据。

双方同时发起连接

存在这种情况,双方同时发送SYN包,想跟对方建立连接,然后在收到相应的ACK包之前又收到了对面发来的SYN包,这种情况不同于常规的3次握手的"回合制"交流方式,大概的流程如下:

  1. 双方都作为主动发起方向对方发送SYN包
  2. 双方分别收到对方发来的SYN包,并回应ACK包,在回应的ACK包中依然设置SYN字段,序列号跟步骤1相同
  3. 双方分别收到相应的ACK包,进入"连接已建立"状态

用数据的方式表示则是:

  1. A->B,SYN设置,seq=100;B->A,SYN设置,seq=456
  2. A->B,SYN设置,seq=100,ack=457;B->A,SYN设置,seq=456,ack=101
  3. A,B都进入"连接已建立"状态

整个过程涉及到4次数据通信,且两端都是在收到对方的SYN包之后先进入"SYN-RECEIVED"状态,再完成连接建立,所以可以认为双方都是被动的打开连接的,虽然第3步收到的ACK包是对第1步的回应的,但是从状态变化来看就是先收到对方的SYN包,再收到对方的ACK。(常规的3次握手,A是发起方,先进入"SYN-SEND"状态,当收到B的回应ACK包以及SYN包后,紧接着就发送ACK包,进入"连接已建立"状态,而B则是先进入"SYN-RECEIVED"状态,再建立连接。)

至于同步发起连接的建立过程,只要满足前面说的建立连接的条件就可以,双方都是在第3步的通信完成后,收到ACK包才确认建立连接的。

3 关闭TCP连接

这里的关闭指的是正常的关闭连接,不包含异常中断等。

TCP连接的关闭不是3次握手,而是4次,因为TCP允许“半关闭”,每一方都独立的管理自己的关闭状态。当主动发起关闭时,发送FIN包,FIN跟SYN类似,也占用一个序列号的位置。

当一方发送FIN包时,表示自己没有数据要发送了,而不是表示不再接收对方发送的数据。

常见的关闭流程如下:

  1. A发送FIN包,A进入 FIN-WAIT-1 状态
  2. B收到后回应ACK包,A收到后进入 FIN-WAIT-2 状态,B进入CLOSE-WAIT状态
  3. B发送完本方的数据后,发送FIN包,进入LAST-ACK状态
  4. A收到后回应ACK包,B收到ACK包后关闭连接(CLOSED状态),而A进入TIME-WAIT状态

第2步和第3步之间,B依然可以发送数据,这时连接就是半关闭状态(half-close),虽然B暂时没有关闭连接,但是已经知道A发送了FIN,等B的数据发送完再进行一轮FIN-ACK交流就可以关闭连接了。

为什么A在最后一次发送ACK包后进入TIME-WAIT状态而B在收到ACK包后是直接关闭呢?

TIME-WAIT是一个等待状态,等待的时间是2*MSL,在这期间暂时不能重新建立原来的连接(双方IP地址+端口号)。MSL是Maximum Segemnt Lifetime,数据段的最大存活时间,rfc793里定义是2min,但是很多实现也采用30s或者1min。

A在发送ACK后进入TIME-WAIT状态有两个目的:

  1. A最后发送的ACK包可能会丢失,需要B重新发送FIN,触发B重传以及传输都需要时间,如果A发送ACK后直接关闭,并且丢包,那么B就没有办法正常关闭这个连接了
  2. 确保旧的重复数据不会被新的连接错误的接收。比如,A和B之间的连接断开后,马上又建立了相同的连接(双方的端口号和IP都跟之前相同),然后原先旧的连接有一个数据段是重复发送的(因为这个数据段在超时未到达后被认为是丢失的并补发过了,但实际上只是迟到了),在新的连接建立后到达了,并且序列号对新的连接来说是有效的,那么就会发生这个问题。

FIN包或者ACK包丢失,会触发相应的重传机制。

同时关闭连接

跟同时建立连接类似,双方也可能同时主动发起FIN包关闭连接。大致流程如下:

  1. 双方都发送FIN包,进入FIN-WAIT1 状态
  2. 双方都在收到ACK包之前收到了对方的FIN包,分别回应ACK包,进入 CLOSING 状态
  3. 双方都收到了ACK包,实际上是对方对第1步发送的FIN包的回应,进入 TIME-WAIT状态

这3步同样也都满足了 “自己发送FIN包被对方回应”,且"收到对方发送的FIN包并回应"这两个条件,跟常规的关闭不同的是,同时关闭的话双方都作为主动关闭方,都要进入 TIME-WAIT 状态

4 数据的超时和重传

当发送方发送数据后在一定时间内没有收到相应的ACK包时,就会触发重传,不管数据是真的丢了还是因为网络原因延迟送达。重传显然是发送方的行为,而接收端因此可能会收到重复的数据包。

关于重传的运作机制,需要先介绍协议栈维护的两个指标:RTTRTO

RTT

RTTRound-Trip Time,表示一个数据段从发出到收到ACK的时间。

通常的协议栈实现里,同一时间只会有一个“计时器”在记录RTT的值,如果一次发送多个数据包段,则当第1个数据段发出时,就启动计时器,直到收到这个数据段对应的ACK包或者被判断为异常(需要重传)之前,都不会有任何计时器被启动,不管期间发送了多少数据。

RTO

RTORetransmission Time Out,表示当一个数据段超过多长时间仍未收到ACK包则需要重传。

在rfc793中,RTO的值由下面的公式计算:

$$ RTO=min[UBOUND,max[LBOUND,(\beta *SRTT)]] $$

其中Smoothed RTT

$$ SRTT=\alpha *SRTT+ (1-\alpha)RTT $$

每次计算新的RTT时就更新SRTT,其中系数$\alpha$通常是0.9或者0.8。

这里的UBOUNDLBOUND表示超时上限和下限,比如后面提到的指数退避的上限。$\beta$的值通常在1.3-2.0 之间。

优化的RTO算法

《TCP/IP Illustrated, Volume 1: The Protocols》中引用了Van Jacobson的《Congestion Avoidance and Control》这篇论文的内容,提到了一种比上面更好的RTO算法,rfc793是1981年发布的,而这篇论文是1988年发布的,原文比较复杂,我们直接引用书中的结论。

先看上面rfc793中的算法,SRTT每次只取10%的偏差量(系数0.9的情况)计入新的SRTT,而RTO直接就是粗略的SRTT*2的方式,这种算法在RTT波动比较大的情况下不能及时的反映真实情况,比如短时间内突然RTT大增,而用这种算法需要很多次才能把SRTT修正到接近真实的水平,可能等到这个时候,RTT又变突然小了,始终“慢半拍”,并且因为“慢半拍”,导致前期更多的重传,让原本拥塞的网络雪上加霜。

优化的算法有如下变量:

$$\begin{aligned} Err&=RTT-A \\ A&=A+g*Err \\ D&=D+h(|Err|-D) \\ RTO&=A+4D \end{aligned}$$

其中RTT是当前的计算值,A是一个修正的RTT值,Err表示当前值跟修正RTT的偏差,D表示修正的偏差,系数g=1/8,h=1/4

用一个例子计算一下两种方法的结果,假设当前计算的RTT突增为10A(rfc793里的算法对应10SRTT),那么两种算法的RTO为:

  • rfc793: SRTT=0.9SRTT + 0.1*10SRTT=1.9SRTT; RTO=1.9倍的原RTO
  • 优化后算法:Err=10A-A=9A;A=A+1/8*9A=17/8A;D=D+1/4(9A-D)=3/4D+9/4A;RTO=A+3D+9A=10A+3D

虽然优化后的算法里面有D这个变量,但还是可以看出来,10A+3D相比于A+4D是变化比较大的,就算A=D,这个比值也有2.6(通常A比D大)。

靠RTO来判断重传的缺陷

假设所有数据在等待RTO时间后就马上重传,那么一个原本已经很繁忙的网络,可能会出现下面的情况:

  1. A发送数据段dataA给B
  2. A超过RTO未收到ACK包,重发dataA(1)
  3. B收到dataA,回应ackA
  4. A又超过RTO未收到ACK包,重发dataA(2)
  5. B收到重发的dataA(1),但是已经回应过了,丢掉
  6. A收到了ackA
  7. B收到dataA(2),丢掉

从上面可以看出来,A重发了3次数据,但是跟只发1次效果是一样的(如果考虑到B收到重复数据段的丢弃操作,那么还不如只发一次),如果网络更差,则发生的无意义重传就更多,指数退避就是为了解决这个问题。

指数退避(exponential backoff)

当数据重传后仍超时未收到ACK,则把RTO的值翻倍,并继续重传,直到RTO的值到达UBOUNDRTO这种指数级的增长叫做“指数退避”。当RTO达到限值后,并不会停止重传,只是RTO的值不再增长,一直以这个最大值不断的重传,直到尝试的时间达到重置连接的要求。

接收方的处理

收到重复的数据

在前面介绍TCP头部的时候就说过,当一方回应ACK时,表示相应的acknowledgement number的值之前的数据段都已经被接收,所以当收到重复的数据段时,其acknowledgement number的值必然是小于本地已经记录的值,这种情况下数据段就被直接丢弃了。

收到不连续的数据

不管是因为丢包或者延迟,还是正常的数据发送,接收方收到的数据段的Sequence Number的值都可能大于acknowledgement number的值,即这个数据不是接收方当前期待的数据,但它很可能是有用的,这种情况怎么办呢?

通常这些数据段会被缓存起来,等待丢失的那个数据到达。但是接收方收到乱序的数据后,也会回应ACK,只是回应的acknowledgement number依然是上一次的值。

注:有的实现会在发送方收到3次重复的ack包(包含正常回应的那个就是4个)后直接进行重传,这种方式就叫快速重传(Fast Retransmit)。因为3次重复的回应意味着后发送的3次数据都成功到达了,那么之前的那个包很可能已经丢失。

5 对网络状况的自适应-拥塞控制

TCP头部信息里包含一个window size的字段,表示的是对方可以接收的最大数据量,这个值虽然是实时更新的,但它只表示接收方“缓冲池"的状态,而不能反应网络的状况,假如网络很差,尽管发送方只在window size的限制大小内发送数据,也很容易丢包,进而导致重传等,加重网络的负担。

拥塞控制是发送方根据已发送数据的送达情况来维护一系列的值,从而推断当前网络的状态并调整发送量的一套机制。其中有一个值就是拥塞窗口大小,记为cwnd(congestion window),它是限制当前发送量的一个重要指标。

慢启动

在双方刚建立连接的时候,发送方只知道接收方的接收窗口大小,并不清楚网络的状况,如果冒然的一次性发送太多数据,可能会大量丢包,怎么办呢?

慢启动就是这样一个机制:起初,cwnd=1seg,每收到1个ack后,cwnd+=1 seg,所以启动时发送的数据量是指数增长的,1,2,4,8…,当然这是有上限的,记为ssthresh(slow start threshold size)

拥塞控制的运作机制

  • 建立连接时,初始化cwnd=1seg(数据段),ssthresh= 65536 Byte。
  • 发送量不会大于 min{cwnd,window size}
  • 先慢启动,直到达到ssthresh 或者 window size,如果ssthresh < window size,那么当 cwnd*2 > ssthresh 时就进入拥塞控制主导的增长阶段
  • 拥塞控制主导的增长阶段:每次收到ack,cwnd+=(1seg * 1seg)/cwnd (计算时都以字节为单位,并且结果向下取seg的整数倍),直到达到window size

上面说的是网络情况良好的理想状态,实际上经常需要重传,当需要重传时(收到3次重复ack,或者time out),则:

当前发送窗口的大小的一半(快速重传的情况下至少为 2 seg,超时引起的重传则为 1 seg)赋值给ssthresh,即 ssthresh= 1/2 min{cwnd,window size},当前窗口的大小跟是否处于慢启动阶段有关。

关于这个1/2取值的合理性,同样在Van Jacobson的论文(附录C)中给出了说明:

首先,cwnd的增长可以分为慢启动的快速增长阶段以及拥塞控制主导的平稳增长阶段,当cwnd达到window size时也可以视为属于平稳增长阶段,两个阶段出现重传时的窗口1/2缩放的原因:

  • 快速增长阶段(慢启动):这个阶段是指数增长的,当前的窗口大小是上一次窗口大小的2倍,如果当前网络出现重传,说明可能当前的发送量导致网络不稳定,而上一次的窗口大小必然是没有重传的(否则同样进行1/2缩放),所以把窗口大小回退到之前的状态以求稳定。
  • 平稳增长阶段(拥塞控制):此时的网络整体上已经比较稳定,突然出现的重传的一个主要原因:网络中增加了新的连接,发送的总数据量超出了路由设备的处理能力,所以有一些包被丢弃。假设网络中原本只有当前的1个连接,然后新增了1个,此时窗口缩小一半正好,双方平分整个带宽,如果原来网络中有多个连接并处于稳定的状态,只是新增了1个连接导致不稳定,那么把当前的连接缩小为1/2相对保守,但至少能保证网络稳定。

最后,把缩小后的窗口大小赋值给ssthresh意味着后续的cwnd只会平稳增长,而不会是指数增长。

平稳增长阶段的算法:cwnd+=(seg*seg)/cwnd,这保证每一轮的窗口增长都小于1 seg,比如当前cwnd=10 seg,那么收到10个ack时窗口增长为 (1/10+1/11+1/12+…+1/19)seg < 10x(1/10)seg=1 seg。

快速恢复 (Fast Recovery)

当超时触发重传时,cwnd重置为1,然后重新慢启动,因为超时的话表示很长一段时间内都没有收到超过3个ACK,那么很可能网络已经断开,而当经历一次快速重传后,只把cwnd缩小为一半(且至少为 2 seg),因为快速重传是收到3个重复的ACK,说明有其它的数据送达了,网络只是不稳定,这种做法就是快速恢复

快速恢复通常还会进行下面的优化:

  • 判断需要重传时,ssthresh=cwnd/2 (向下取 seg 整数倍)
  • 重传
  • cwnd=ssthresh+3 seg
  • 如果重传后至收到相应ACK期间,仍然收到重复的ACK,则cwnd+=1 seg
  • 收到重传seg对应的ACK后,cwnd=ssthresh,cwnd平稳增长

优化的目的是避免在 “重传数据后-收到ACK” 这段时间内(没丢包的话就是1RTT)因cwnd停止变化,导致数据的传送"先骤然停止后突然大量发送”。

举个例子:

  • 假设当前cwnd=10 seg,并且10seg都以发出,序号为1,2,3…,10
  • 收到ack2(接收方收到1),cwnd=11,发送11,12
  • 重复收到3次ack2(接收方收到3,4,5)
  • 重传,ssthresh=cwnd/2=11/2=5 seg
  • cwnd=ssthresh = 5 seg
  • 重复收到6次 ack2 (接收方收到6,7,8,9,10,11),不执行任何操作
  • 收到 ack13(接收方收到重传的1,并且把缓存中的数据一并ack),cwnd=sshtresh=5 seg
  • 当前 cwnd=5seg ,所以发送新的数据 13,14,15,16,17

优化后的例子:

  • 假设当前cwnd=10 seg,并且10seg都以发出,序号为1,2,3…,10
  • 收到ack2(接收方收到1),cwnd=11,发送11,12
  • 重复收到3次ack2(接收方收到3,4,5)
  • 重传,ssthresh=cwnd/2=11/2=5 seg
  • cwnd=ssthresh +3=8 seg
  • 重复收到6次 ack2 (接收方收到6,7,8,9,10,11),cwnd从8增加到14,而当前未被ack的数据为 2-12,共11 seg,所以当cwnd=12、13、14时,会发送新的数据13,14、15
  • 收到 ack13(接收方收到重传的1,并且把缓存中的数据一并ack,而13,14、15因为后发送,没有到达),cwnd=sshtresh=5 seg
  • 当前 cwnd=5seg ,而未收到ACK的数据为13、14、15,所以发送新的数据 16,17

从上面的例子可以看出来,优后以后,当触发重传时,期间还是有一部分数据会发送,等重传完成后,再进入拥塞控制阶段,从传输的角度看,尽管数据的发送量变小了,但并未完全停止。

6 TCP的其它特性

窗口侦测

在持续发送数据的情况下,TCP的滑动窗口大小是不断变化的,可能因为接收方太忙来不及处理,所以接收方返回了接收全部数据的ACK,但窗口大小为0。

而当接收方处理完数据后,要通知发送方窗口已打开,如果这个通知数据丢了怎么办?–发送方在等待窗口打开而不能发送数据,接收方也在等待发送方的数据(前提是接收方只收不发),这样就僵住了。

窗口侦测就是防止这种情况发生,发送方在收到窗口为0的通知后,就启动一个计时器(Persist Timer,持久计时器),定时的发送一段数据,这个数据段只包含一个字节,如果对方依然没有更新窗口,则重新计时,这个计时器也是"指数退避"的,从1.5,3,6,12,…,60,而计时器的上下限分别是5s和60s,所以表现出来的间隔就是5,5,6,12,…,60,这个计时器会一直运行下去除非连接断开。

Silly window syndrome(愚笨窗口综合症、糊涂窗口综合症) 和 Nagle Algorithm (Nagle 算法)

传输层的数据是由应用层传下来的,如果在持续传输的场景下(相对于ssh等场景的交互式传输),应用层总是传输小而频繁的数据,而传输层在收到数据后立刻发送,那么会导致大量的数据段被发送,而每个数据段内的TCP头+IP就占用了40字节,会造成网络拥堵。

发送方和接收方都可以采取相应的措施来避免这种情况:

  • 发送方:只在下列几种情形下才发送数据

    • 当前可发送的数据已经>=1 seg
    • 当前可发送的数据>= max window size/2,max window size 即握手时对方提供的窗口大小
    • 我们没有别的数据要发送的,所以不管当前有多少数据,都可以直接发送
  • 接收方:接收方不会频繁的更新窗口大小,直到满足下面的条件:当前可用窗口的大小跟上一次通知相比,已经增长了超过 1 seg 或者 接收方缓冲区的1半(缓冲区大小都常跟 max window size 一致)

Nagle 算法

Nagle 算法也是为了解决频繁发送小段数据的问题。

算法的描述是:当发送方有已经发送但未被ack的数据时,不能发送小段数据,这些数据都缓存起来直到收到上一次的ACK再发送。(这里小段数据指的是小于 1 seg的数据,实际上愚笨窗口综合症里发送方的第一条限制就是这个)

当网络情况较好时,交互型的连接的输入速度通常是小于 1 RTT的,但是一些速率较低的网络上,这种算法可以发挥作用。

Nagle算法通常可以在需要时手动关闭(是否支持取决于具体的实现)。

连接保活机制

从传输层的角度来看,连接的双方如果没有数据要发送,那么连接就一直处于闲置状态,如果在这期间一方失去了连接,另一方怎么知道呢?这种情况对于需要保持大量连接的服务器来说比较重要,及时的关闭已经无效的连接,可以避免浪费资源。(保活机制应用层也可以提供,这里只讨论传输层本身)

TCP本身没有定义一种轮询机制来解决这个问题,但是大部分实现都提供了这个功能,通过一个保活计时器(Keepalive Timer),在连接进入“空闲”状态后定时(2小时)发送一个侦测数据(侦测数据段的序列号设为ack-1,目的是让接收方回应这个“错误”的数据段,是否携带数据取决于实现,通常不携带),返回下面3种结果之一:

  1. 对方正确的回应了,连接有效,收到回应后重置保活计时器
  2. 对方没有回应,表现为连接超时,这种情况下每75s重新发送一次侦测,总计10次侦测后视为连接无效
  3. 对方回应reset,表示对方已经重启过了,原来的连接已经无效,需要reset

第1种情况应用层是无感的,而第2种、第3种情况应用层都会收到相应的错误信息,第2种情况可能是对方关机,也可能是网络问题,比如某一个路由器不能用了。

保活机制是一个饱受争议的特性,它有如下有优缺点: 优点:

  • 应用层不用实现轮询机制的代码
  • 传输的数据量比应用层实现要少,因为通常不用传任何数据,应用层传下来的话就携带了应用层头部等信息

缺点:

  • 可能会因为路由器的临时故障导致连接被错误的关闭,这个问题属于保活机制的问题,跟哪个层面实现无关
  • 2小时一次的侦测间隔不够灵活,有的实现可能不允许应用层修改这个值

窗口放大 (Window Scale)

窗口放大是在rfc1323(新版为rfc7323)中补充的一个选项,rfc793是1981年发布,rfc1323是1992年发布,scale虽然是缩放的意思,但实际中窗口只会放大,而不会小于16bit。

这个选项共3字节,格式是:

kind=3|length=3|shift

其中shift虽然是1字节,但最大有效值为14,所以放大后的窗口上限为2^30 byte=1GB,最终的窗口大小值在内部是用一个32bit的值来记录的,不管放大了多少倍。

窗口放大选项只能发生在SYN包中设置,其它包里的放大因子都被忽略,SYN包中的放大因子不会作用于SYNN包本身。

如果一方设置了放大因子,但对方未回应(不支持),则发送方也重置为0,否则双方不一致。

双方在握手建立连接时会发送自己的接收因子R,并记录对方的接收因子(即我们要发送的因子)为S,所以,假设有A,B两台主机,那么:

  • RA=SB
  • SA=RB
  • A发送数据给B,先右移RB(SA)位,B收到后再左移RB位进行放大

也可以这样理解,由接收方确立该方向的放大因子

MTU发现 (MTU discovery)

当TCP双方建立连接时,会交换MSS信息,但是中间的路由器可能只支持更小的分包,为了避免数据发送过程中发生分片,需要MTU发现机制来获取当前线路的MTU。

机制的运作如下:首先按min[对方的MSS,本方的发送接口]发送,如果收到了ICMP错误表明要分片,并且错误中提供了MTU,则以这个MTU大小为依据发送seg(减去40 Byte的IP头和TCP头),这种情况下的重传会初始化慢启动,但是不重置拥塞窗口(慢启动是以seg为单位增长的,而拥塞窗口是byte 为单位,所以重新慢启动后初始发送的数据没有变小,比如原来1024byte的数据现在分成2个512发送)

时间戳选项(Timestamp Option)

时间戳选项主要是用来更准确的测量rtt,还可以用来应对序列号回绕问题。

格式:

kind=8(1byte)|length=10(1byte)|timestamp value(4byte)|timestamp echo value(4byte)

总长度是10byte,但是因为TCP头部是32bit对齐的,所以启用这个选项后头部由20字节变为32字节,跟窗口放大一样,当发起方设置了时间戳选项,只有对方也回应了时间戳选项也能正式启用。

用时间戳进行RTT测量

时间戳测RTT的方式就是发送方发送本地的时间戳,然后接收方收到的时候,在ACK的timestamp echo value中回复同一个时间戳,当发送方收到这个ACK时,根据当前的时间戳值就可以计算往返的时间差值,从而得知相应的RTT,所以时间戳也不需要双方同步。

时间戳一般在机器重启的时候重置为0,增长幅度在1ms~1000ms之间。

前面提到过,传统的RTT测量在同一时间只能运行一个测量,这很粗糙,可能发送了100个seg,

用时间戳测量rtt时,对于延迟ack和乱序到达等情况,主要策略如下:

  1. 当seg1,seg2按顺序到达,延迟一起ack时,用的是seg1中的时间戳,这就把延迟ack产生的时间包括在了rtt里。
  2. 当seg1,seg3,seg2这样的顺序到达时,先ack1,用的是seg1的时间戳,然后还是ack1,用的是seg1的时间戳,最后ack3,用的是seg2的时间戳。这样处理的原因是,当seg3到达时,判断seg2丢包,那么回应seg1的时间戳可以把rtt变大,而当后面的seg2到达时(不管是延迟到达还是丢包),用的seg2的时间戳,当seg2是延迟到达时,这能反应出seg2相应的rtt,当seg2是丢包重传时,这也能反映出重传的seg2相应的rtt,不管怎样seg3相应的rtt都没有考虑了。

防止序列号回绕(PAWS,Protection Against Wrapped Sequence Numbers)

时间戳本身也是32位的,所以可以用来当作seg的第2标识,一个seg最长的存活时间是MSL,大部分实现是30s,rfc规定是2min,而rfc规定时间戳的更新频率是1ms~1000ms,就算是1ms的频率,时间戳重复的时间也远远大于2min。

当在一个非常快的网络上发生丢包,然后序列号回绕了,那么此时时间戳就可以作为第2个特征来判断这个数据是否有效(是否处于当前发送窗口中的数据使用的时间戳范围内)。

虽然同是32bit,但是在一个快速的网络上,时间戳的增长幅度肯定是远远小于序列号的增长的,尤其是启用了窗口放大选项时。

长肥管道

长肥管道指是那些 带宽x延时这个乘积很大的TCP连接,而不是带宽大且延时大,所以现在较快的网络基本都是长肥网络(长肥管道就是建立在长肥网络的基础上)。长肥管道有几个问题:

  • 网络利用率低:16bit的窗口只能表示64KB,而1Gb的带宽*20ms的延时=2500KB,网络利用率很底,前面提到的窗口放大可以解决这个问题
  • 丢包时性能损失严重:当网络稳定运行时,因为管道容量大,所以拥塞窗口也可能很大,一旦丢包,拥塞窗口减半,管道利用率也直接减半,需要好几个RTT才能恢复。丢包本身就会减小网络流量,只是在长肥管道上这个问题更突出。
  • 需要更准确的RTT测量方案:之前提到当一次RTT测量未完成时,期间发送的数据都不会被测量(同一时间只能有一个RTT测量),时间戳选项可以解决这个问题。
  • 序列号回绕:序列号只有32bit,只能表示2^32=4GByte=32Gbit,如果网络是1000Mbps(1000*10^6 b/s),那么34.4s就会开始新的,网络速率越快,越可能发生回绕(前面有提到大部分实现的MSL是30s),可以用前面提到的时间戳来解决这个问题。