第19章 TCP的交互数据流
首先,上面叫交互数据流,原文解释:
- 块数据:如FTP、电子邮件和Usenet新闻。
- 交互数据:如Telnet和Rlogin。
那么交互数据流就是客户端与服务端需要频繁交互数据的那种数据流。
这一章主要以Rlogin举例,也就是远程登录。
信息交互过程
在Rlogin的交互中,需要进行图上的信息传递:
- 客户端告知服务端按键信息。
- 服务端的对按键信息的确认。
- 服务器告知客户端的按键显示信息(回显)。
- 客户端对按键显示信息的确认。
在我们平常使用ssh的过程中可能感觉不到,这里每次只传送一个按键信息。
经受时延的确认
考虑到上面的交互过程,2和3步服务器有两个信息要传递给客户端,一个ACK信息,一个数据信息。
那么十分自然的想法,这两个信息如果用两个报文来传递有点浪费资源,因为它们可以不冲突的放在以一个报文里面。 (即数据捎带ACK)
如果要做到这一点,就要想到,服务器在收到按键信息后,需要经过一定时间的处理,然后才会发出回显信息, 那么很明显的,按键确认信息要比回显信息先构建出来,它们之间差多少时间无法确定,所以就有了这里经受时延的确认。
经受时延的确认:
通常TCP在接收到数据时并不立即发送ACK;相反,它推迟发送,以便将ACK与需要沿该方向发送的数据一起发送(有时 称这种现象为数据捎带ACK)。绝大多数实现采用的时延为200ms,也就是说,TCP将以最大200ms的时延等待是否有数据一起发送。
注意到这里说的是最大200ms时延,也就是这里有一个200ms定时器,但是TCP使用了一个200 ms的定时器,该定时器以相对于内核引导的200 ms固定时间溢出
,
所以这个定时器不是在ACK开始等待的时候触发的,而是一定有的,ACK在等待的时候,就可能会在1~200ms的任意时刻碰到定时器溢出。
看书上给出的例子:
ACK等待时间为:123.5、65.6、109.0、132.2、42.0、140.3和195.8ms。 ACK发出时间为:139.9、539.3、940.1、1339.9、1739.9、1940.1和2140.1ms。
ACK发出时间相差均为200的整数倍,证明了这个200ms的定时器与ACK开始等待的时间无关,而是在200ms固定时间间隔溢出。
Nagle算法
正如上面说的,Rlogin交互过程中每次只传送一个按键信息,比如你一瞬间打(粘贴)了个abcdefg
,
结果它竟然是发送7个报文,分别报告这几个字母,如果网络资源并不是那么丰富的情况下,就有点太浪费了。
那么我们自然就想一次多发送几个字母过去,也就是Nagle算法做的事情。
Nagle算法:
该算法要求一个TCP连接上最多只能有一个未被确认的未完成的小分组,在该分组的确认到达之前不能发送其他的小分组。 相反,TCP收集这些少量的分组,并在确认到来时以一个分组的方式发出去。
也就是当你按下字母a
时,报文被发送,再按下bcdefg
时,它们不会被发送,直到收到报文a
的ACK,
这时就将bcdefg
放在一个报文里面发送出去。
该算法的优越之处在于它是自适应的:确认到达得越快,数据也就发送得越快。
Nagle算法的目的在于减少网络中小分组的数目,但是实际上会增加整个发送过程的时延, 在延迟比较敏感的场景下,禁用它能提升体验。
窗口大小通告
图中的win 4096
、win 8192
等就是窗口大小的通告,代表的是自己的接收窗口还有多少剩余空间。
注意上图的14~18报文:
客户端通过14、15报文一共发送了4个字节(
14:18(4)
)的数据到服务器。服务器回复
ACK 18
,表示收到了这4个字节的数据,但是只回显了3个字节56:59(3)
,所以还留有1个字节数据未处理,此时窗口大小为$8191 = 8192 - 1$。客户端确认回显(
ACK 59
),并继续发送3字节(18:21(3)
)数据,但是窗口大小$4093 = 4096 - 3$,说明收到的3字节回显还没有处理。服务器回复
ACK 21
,并发送回显1个字节59:60(1)
,那么18:21(3)
这3字节数据都还没处理,所以窗口大小为$8189 = 8192 - 3$。
wireshark抓包测试
首先先建立一个ssh连接:
开启wireshark抓包:
敲一个a看什么效果:
可以看到一共发送了三个报文,和书上所述一致。
这里直接粘贴一个字符串TCP
进去,结果如下:
可以看到这里显然同时为T、C、P三个字符分别发送了报文,可以看出,这里显然关闭了Nagle算法。
另外.233
发送了三个回显报文,但是.150
只发送了2个ACK,这显然是经受时延的确认
带来的效果。
TCP的成块数据流
在上一节说的是有关于交互数据流的一些东西,这一节介绍了关于成块数据流上的一些东西。
这一章介绍了滑动窗口协议、PUSH标志、慢启动和紧急方式,这些概念。
滑动窗口协议
在滑动窗口之前,书上已经介绍了停止等待协议(停等协议),这个协议十分简单:
发送方发送一个报文之后,必须等到这个报文的ACK到来,才能发送下一个报文
。
在成块数据传输时(例如传一个文件),如果使用停等协议来进行传输,那么速度就太慢了,相当于每个RTT时间只能传输一个报文, 传输效率基本只与RTT时间有关,链路容量再大也是白给。所以这时候就需要同时传输多个报文,也就形成了滑动窗口协议。
如书中所给的图,1、2、3…10、11…等就表示报文,数字就是它们的序号。方框就表示当前时刻窗口的位置,
可以看到窗口外的左边是已发送并且已确认的报文,窗口外的右边是还未发送也不能发送
的报文,
窗口内的左右两边则是已发送但还未确认
、未发送但可以发送
的报文。
显然之所以叫做滑动窗口,就是因为它会随着报文的发送确认而不断的向右滑动。
书中使用三个术语来描述窗口左右边沿的运动:
- 称窗口左边沿向右边沿靠近为窗口合拢。这种现象发生在数据被发送和确认时。
- 当窗口右边沿向右移动时将允许发送更多的数据,我们称之为窗口张开。这种现象发生在另一端的接收进程读取已经确认的数据并释放了T C P的接收缓存时。
- 当右边沿向左移动时,我们称之为窗口收缩。
Host Requirements RFC
强烈建议不要使用这种方式。但TCP必须能够在某一端产生这种情况时进行处理。
另外注意到,一个ACK是对它之前所有报文的确认。
窗口大小:
需要注意到的是,滑动窗口存在于发送方,但是它的窗口的大小是由接收方提供的。
观察书中给出的例子:
- 在第二个报文(SYN)中,接收方通报窗口大小为
6144
,mss大小为1024
。 - 由于窗口大小为
6144
,发送方直接发送了6个1024大小的报文。 - 接收方发出
ACK 6145
,通报窗口大小为2048
。 - 发送方收到
ACK 6145
,刚才窗口内的6个报文全部被确认,窗口左边直接移动到6145字节位置,由于通报窗口大小为2048
,窗口右边沿移动到8192
位置。 - 发送方将现在窗口里面的两个报文发出。
- 接收方不断发出ACK,确认报文接收以及通报窗口大小,可惜发送方已经没有更多的数据发送了。
PUSH标志
可以看到上面的例子中,一些报文被设置了PSH标志,直接引用书中所述,描述了PUSH标志的作用:
发送方使用该标志通知接收方将所收到的数据全部提交给接收进程。 这里的数据包括与PUSH一起传送的数据以及接收方TCP已经为接收进程收到的其他数据。
这里描述了PUSH标志的用途:
通过允许客户应用程序通知其TCP设置PUSH标志, 客户进程通知TCP在向服务器发送一个报文段时不要因等待额外数据而使已提交数据在缓存中滞留。 类似地,当服务器的TCP接收到一个设置了PUSH标志的报文段时, 它需要立即将这些数据递交给服务器进程而不能等待判断是否还会有额外的数据到达。
也就是说,在没有设置PUSH的情况下,TCP收到数据之后,可能并不会立即上传给应用,也许它想要等到更多的数据到来再一起上传给应用, 以提高效率。
但是例如Rlogin这种交互式应用,每次就发送一个字节的数据过去,如果TCP想要等待更多的数据到来,则可能会增大延迟, 这时就需要PUSH标志来告知TCP不要进行等待,即使只收到一个字节数据,也要立即上传给应用。
但是书中又说了:
然而,目前大多数的API没有向应用程序提供通知其TCP设置PUSH标志的方法。的确, 许多实现程序认为PUSH标志已经过时,一个好的TCP实现能够自行决定何时设置这个标志。
需要注意到不同的TCP实现方式处理PUSH标志时可能也有所不同,
例如由于源于伯克利的实现一般从不将接收到的数据推迟交付给应用程序,因此它们忽略所接收的PUSH标志。
PUSH标志通常在以下情形被设置:
- 应用的一次数据写入完成。
- 发送缓冲区没有更多的数据等待发送。
当然,还是有些时候还是挺玄学,具体还是要看使用TCP版本的实现。
慢启动
在滑窗协议那里所举的例子中,发送方首先就直接发送了6个报文,看起来这样可以提高发送效率, 但是实际中需要考虑整个路由路径上的拥塞情况,如果每个人都没有节制的发送自己的报文, 那么结果就是路径堵死,谁都别想成功发送报文。
为了避免这种情况,就需要每个人限制自己的发送速度,那么如何知道自己能以多快的速率发送呢? 既然全靠自觉,那么就只能一点点去试,逐渐增加自己的发送速度,直到丢包,这就是慢启动。
慢启动为发送方的TCP增加了另一个窗口:拥塞窗口(congestion window),记为cwnd。
- 当与另一个网络的主机建立TCP连接时,拥塞窗口被初始化为1个报文段(即另一端通告的报文段大小)。
- 每收到一个ACK,拥塞窗口就增加一个报文段(cwnd以字节为单位,但是慢启动以报文段大小为单位进行增加)。
需要注意到发送方取拥塞窗口与通告窗口中的最小值作为发送上限。 拥塞窗口是发送方使用的流量控制,而通告窗口则是接收方使用的流量控制。
注意到虽然名字是慢
启动,但是其实启动速度是指数增长的,如书上的例子(先不考虑通告窗口的大小):
- 初始化拥塞窗口大小为1,发送一个报文。
- 收到
ACK 513
,拥塞窗口大小增加为2,窗口右移,发送两个报文。 - 收到
ACK 1025
,拥塞窗口大小增加为3,窗口右移,发送两个报文。 - 收到
ACK 1537
,拥塞窗口大小增加为4,窗口右移,发送两个报文。 - 收到
ACK 2049
、ACK 2561
,拥塞窗口大小增加为6,窗口右移,发送最后一个报文。
在这一步时,一共发送了8个报文,收到了5个ACK,还有3个报文没有收到ACK,当这三个ACK都收到时, 拥塞窗口大小将变为9。
如果发出的报文ACK立刻返回,那么拥塞窗口的大小变化规律就是:
- 1 -> 2
- 2 -> 4
- 4 -> 8
- …
也就是一个RTT时间之后,窗口大小就会翻倍,所以说是指数增长。
成块数据的吞吐量
通常发送一个分组的时间取决于两个因素: 传播时延(由光的有限速率、传输设备的等待时间等引起) 和一个取决于媒体速率(即媒体每秒可传输的比特数)的发送时延。
对于一个给定的两个接点之间的通路,传播时延一般是固定的,而发送时延则取决于分组的大小。 在速率较慢的情况下发送时延起主要作用,而在千兆比特速率下传播时延则占主要地位。
如书中所举的例子:
在例子中的最后时刻,发送方不断发送报文和接收ACK,已经填满了整个路径, 这时窗口的增加已经无法增加发送速率了,也就是连接的理想稳定状态。
带宽时延乘积:
这里给出了通道容量的计算公式,注意到它并不是吞吐量。
正如上面的例子中的图,如果一个报文完全写入通道需要时间A,RTT大小为B, 相当于发送方不间断的写入报文时,从第一个报文开始写入,直到第一个ACK到达发送方时, 发送方一共发送了$\frac{B}{A}$个报文。
假设一个报文的长度为C,那么通道容量为:
注意到B就是RTT时间,$\frac{C}{A}$就是链路带宽,即:
计算这个值的意义在于指导TCP的缓存大小,如果发送或者接收缓冲小于通道容量, 那么传输速度就一定达不到理论上的最大值,因为当发送缓冲塞满时,就会停止发送报文。
拥塞:
当数据到达一个大的管道(如一个快速局域网)并向一个较小的管道(如一个较慢的广域网)发送时便会发生拥塞。 当多个输入流到达一个路由器,而路由器的输出流小于这些输入流的总和时也会发生拥塞。
正如书中的例子:
整个链路的链路带宽由最小的那部分决定。
紧急方式
TCP提供了
紧急方式(urgent mode)
,它使一端可以告诉另一端有些具有某种方式的“紧急数据”已经放置在普通的数据流中。 另一端被通知这个紧急数据已被放置在普通数据流中,由接收方决定如何处理。
这个东西目前还不好理解,它是一种在已有的TCP连接上传输紧急数据的方式。
具体参考:
TCP的超时与重传
TCP提供可靠的运输层,也就是说,使用TCP传输数据,它保证接收端能收到发送端所发给它的所有数据。
达到可靠的阻碍在于报文在传输过程中会发生丢包,而且你还无法知道这个丢包的发生, 所以就需要使用超时来判断是否丢包,使用重传来重新传输丢掉的数据。
往返时间测量
TCP超时与重传中最重要的部分就是对一个给定连接的往返时间(RTT)的测量。
假设RTT时间为A秒,正常情况下,当一个报文发出后的A秒,发送方就应该接收到ACK, 那如果没有收到ACK,我们就有理由怀疑报文丢失了,考虑到网络的抖动,我们可能再等待一段时间, 如果这时还没有收到ACK,那么就可以考虑开始重传了。
所以,超时时间的计算是基于RTT时间的,在一个局域网里面,超时时间相比与一个广域网来说,肯定是要短一点的。
首先TCP必须测量在发送一个带有特别序号的字节和接收到包含该字节的确认之间的RTT。
注意到发送报文与ACK并不是一一对应的,一个报文2可以通过ACK 5
来确认,所以这里书中表述为接收到包含该字节的确认
。
由于这个问题和网络抖动会造成测量的RTT时间在一定范围内变化,那么这里就需要多次对RTT的测量来计算得到一个相对准确的值, 一种简单的平滑方式如下:
其中$R$表示平滑得到的RTT,$M$表示测量得到的RTT,$\alpha$为平滑因子,推荐值0.9。
在RFC 793
中推荐的重传超时时间RTO(Retransmission Time Out)的值应该设置为:
这里的$\beta$为时延离散因子,推荐值为2。也就是说超时时间为2倍的RTT。
Jacobson认为这种方式太简单,容易更不上网络的变化,他认为方差也是一个需要考虑的因素, 所以它提出了下面的RTO估计方法:
这里$M$表示测量得到的RTT,$A$表示平滑得到的RTT,$D$表示平滑得到的均值偏差,$g$取值$\frac{1}{8}$,$h$取值0.25。
观察这个计算方式可以发现,当$Err$持续是一个很小的值,也就是RTT的测量值比较稳定时,$D$趋于0,RTO就趋于$A$, 也就是RTO就是RTT,而上面简单的计算公式中,RTO总是两倍的RTT。这其实很好理解,如果RTT的测量值比较稳定, 就证明RTT波动很小,一旦某个报文在一个RTT范围内没有ACK回来,那么它就是一个异常点,大概率是发生了丢包, 所以立刻开始重传,而不是等待两倍的RTT时间。
Karn算法:
在重传数据的确认最后到达之前,不能更新RTT估计器。
因为所收到的重传数据的ACK,无法确定是哪一次重传报文的ACK。
另外,在重传数据时,已经采用了指数退避的策略来计算RTO,每次重传都将RTO乘2。
往返时间RTT的测量:
大多数源于伯克利的TCP实现在任何时候对每个连接仅测量一次RTT值。 在发送一个报文段时,如果给定连接的定时器已经被使用,则该报文段不被计时。
这里的定时器使用滴答计数器来进行:
可以看到对于RTT的测量并不是精确的,而是通过它经历了几个滴答来计算它的时间, 就像图中的第一次测量,即使实际上是1.061秒,但是它经过了3个滴答,所以被记录为1.5秒。
RTO的初始化以及重传时的计算:
变量$A$和$D$分别被初始化为0和3秒。
也就是说初始的重传时间就是:
因子2D只在这个初始化计算中使用。正如前面提到的,以后使用4D和A相加来计算RTO。
那么第一次超时时间就是6s,当接着发生第二次超时的情况下,首先使用正确的公式计算$RTO_1$,再进行指数退避(乘2):
再往后接着进行指数退避:
- $RTO_3 = 2RTO_2 = 48s$
- $RTO_4 = 2RTO_3 = 64s$
- $RTO_5 = 64s$
- $RTO_6 = 64s$
- $RTO_7 = 64s$
- …
注意到指数退避的最大值为64s。
拥塞避免算法
拥塞避免算法是一种处理丢失分组的方法。
该算法假定由于分组受到损坏引起的丢失是非常少的(远小于1%), 因此分组丢失就意味着在源主机和目的主机之间的某处网络上发生了拥塞。
有两种分组丢失的指示:发生超时和接收到重复的确认。
注意到之前说了慢启动算法,它会一直增加cwnd窗口大小,那么这样增长下去,很可能到达一个上限,造成网络拥塞, 以至于产生丢包。那么此时仅仅重传丢失的报文是不够的,因为现在的cwnd窗口大小明显是过大了的, 所以还要将它缩小,这就是这里的拥塞避免算法。
拥塞避免算法和慢启动算法需要对每个连接维持两个变量:一个拥塞窗口cwnd和一个慢启动门限ssthresh。
这样得到的算法的工作过程如下:
对一个给定的连接,初始化cwnd为1个报文段,ssthresh为65535个字节。
TCP输出例程的输出不能超过cwnd和接收方通告窗口的大小。拥塞避免是发送方使用的流量控制, 而通告窗口则是接收方进行的流量控制。前者是发送方感受到的网络拥塞的估计, 而后者则与接收方在该连接上的可用缓存大小有关。
当拥塞发生时(超时或收到重复确认),ssthresh被设置为当前窗口大小的一半 (cwnd和接收方通告窗口大小的最小值,但最少为2个报文段)。 此外,如果是超时引起了拥塞,则cwnd被设置为1个报文段(这就是慢启动)。
当新的数据被对方确认时,就增加cwnd,但增加的方法依赖于我们是否正在进行慢启动或拥塞避免。 如果cwnd小于或等于ssthresh,则正在进行慢启动,否则正在进行拥塞避免。 慢启动一直持续到我们回到当拥塞发生时所处位置的一半时候才停止(因为我们记录了在步骤2中给我们制造麻烦的窗口大小的一半), 然后转为执行拥塞避免。
ssthresh:标记上一次拥塞发生时,窗口大小的一半。
也就是说,首先慢启动(其实是指数增长),然后为了降低窗口增长速度, 所以需要在cwnd超过ssthresh时,降低窗口的增长速度,进入拥塞避免增长模式。
拥塞避免:求每次收到一个确认时将cwnd增加1/cwnd。也就是说一个往返时间内最多为cwnd增加1个报文段。
快速重传与快速恢复算法
在收到一个失序的报文段时,TCP立即需要产生一个ACK(一个重复的ACK)。 这个重复的ACK不应该被迟延。 该重复的ACK的目的在于让对方知道收到一个失序的报文段,并告诉对方自己希望收到的序号。
直接先来看算法过程:
当收到第3个重复的ACK时(不算第一个正常的ACK),将ssthresh设置为当前拥塞窗口cwnd的一半。重传丢失的报文段。 设置cwnd为ssthresh加上3倍的报文段大小。
每次收到另一个重复的ACK时,cwnd增加1个报文段大小并发送1个分组(如果新的cwnd允许发送)。
当下一个确认新数据的ACK到达时,设置cwnd为ssthresh(在第1步中设置的值)。 这个ACK应该是在进行重传后的一个往返时间内对步骤1中重传的确认。 另外,这个ACK也应该是对丢失的分组和收到的第1个重复的ACK之间的所有中间报文段的确认。 这一步采用的是拥塞避免,因为当分组丢失时我们将当前的速率减半。
这个算法由收到3个重复的ACK而启动,其中的道理在于:
首先TCP的发送顺序并不等于接收顺序,后发送的报文可能先被接收方所接收,这时也会产生重复ACK,所以这个值不能定得太小。
这大概率证明了这个ACK之后的那个报文丢失,所以无需等到超时重传计时器,而是立即重传。
丢失报文后面至少有三个报文得到了接收,并且ACK也成功的传送了回来,侧面证明了网络状况还好,所以没有必要直接进行慢启动。
每一个新的重复ACK,就代表一个报文被接收,更加证明了网络状况并不差。
看上面书中所举的例子,可以看到它的各个变量的变化情况就与上面的算法描述一致。
按每条路由进行度量
当一个TCP连接关闭时,如果已经发送了足够多的数据来获得有意义统计资料,且目的结点的路由表项不是一个默认的表项, 那么下列信息就保存在路由表项中以备下次使用:被平滑的RTT、被平滑的均值偏差以及慢启动门限。 所谓“足够多的数据”是指16个窗口的数据,这样就可得到16个RTT采样,从而使被平滑的RTT过滤器能够集中在正确结果的5%以内。
当建立一个新的连接时,不论是主动还是被动, 如果该连接将要使用的路由表项已经有这些度量的值,则用这些度量来对相应的变量进行初始化。
ICMP的差错
TCP能够遇到的最常见的ICMP差错就是源站抑制、主机不可达和网络不可达。
当前基于伯克利的实现对这些错误的处理是:
一个接收到的源站抑制引起拥塞窗口cwnd被置为1个报文段大小来发起慢启动,但是慢启动门限ssthresh没有变化, 所以窗口将打开直至它或者开放了所有的通路(受窗口大小和往返时间的限制)或者发生了拥塞。
一个接收到的主机不可达或网络不可达实际上都被忽略,因为这两个差错都被认为是短暂现象。 这有可能是由于中间路由器被关闭而导致选路协议要花费数分钟才能稳定到另一个替换路由。 在这个过程中就可能发生这两个ICMP差错中的一个,但是连接并不必被关闭。 相反,TCP试图发送引起该差错的数据,尽管最终有可能会超时。 当前基于伯克利的实现记录发生的ICMP差错,如果连接超时,ICMP差错被转换为一个更合适的的差错码而不是“连接超时”。
需要了解到对于ICMP的差错,TCP有针对的处理就行了。
重新分组
当TCP超时并重传时,它不一定要重传同样的报文段。相反,TCP允许进行重新分组而发送一个较大的报文段, 这将有助于提高性能(当然,这个较大的报文段不能够超过接收方声明的MSS)。 在协议中这是允许的,因为TCP是使用字节序号而不是报文段序号来进行识别它所要发送的数据和进行确认。
观察书中所举的例子:
可以看到13:27
字节在重传过程中,又键入了6个字符,于是重传就变成了13:33
字节,这就是TCP的重新分组。