1).本文以TCP的发展历程解析容易引起混淆,误会的方方面面
2).本文不会贴大量的源码,大多数是以文字形式描述,我相信文字看起来是要比代码更轻松的
3).针对对象:对TCP已经有了全面了解的人。因为本文不会解析TCP头里面的每一个字段或者3次握手的细节,也不会解释慢启动和快速重传的定义
4).除了《TCP/IP详解》( 卷一 , 卷二 )以及《 Unix网络编程 》以及Linux源代码之外,学习网络更好的资源是RFC
5).本文给出一个提纲,如果想了解细节,请直接查阅RFC
6).翻来覆去,终于找到了这篇备忘,本文基于这篇备忘文档修改。
ISO提出了OSI分层网络模型,这种分层模型是理论上的,TCP/IP最终实现了一个分层的协议模型,每一个层次对应一组网络协议完成一组特定的功能,该组网络协议被其下的层次复用和解复用。这就是分层模型的本质,最终所有的逻辑被编码到线缆或者电磁波。
分层模型是很好理解的,然而对于每一层的协议设计却不是那么容易。TCP/IP的漂亮之处在于:协议越往上层越复杂。我们把网络定义为互相连接在一起的设备,网络的本质作用还是“端到端”的通信,然而希望互相通信的设备并不一定要“直接”连接在一起,因此必然需要一些中间的设备负责转发数据,因此就把连接这些中间设备的线缆上跑的协议定义为链路层协议,实际上所谓链路其实就是始发与一个设备,通过一根线,终止于另一个设备。我们把一条链路称为“一跳”。因此一个端到端的网络包含了“很多跳”。
终止于IP协议,我们已经可以完成一个端到端的通信,为何还需要TCP协议?这是一个问题,理解了这个问题,我们就能理解TCP协议为何成了现在这个样子,为何如此“复杂”,为何又如此简单。
正如其名字所展示的那样,TCP的作用是传输控制,也就是控制端到端的传输,那为何这种控制不在IP协议中实现的。答案很简单,那就是这会增加IP协议的复杂性,而IP协议需要的就是简单。这是什么原因造成的呢?
首先我们认识一下为何IP协议是沙漏的细腰部分。它的下层是繁多的链路层协议,这些链路提供了相互截然不同且相差很远的语义,为了互联这些异构的网络,我们需要一个网络层协议起码要提供一些适配的功能,另外它必然不能提供太多的“保证性服务”,因为上层的保证性依赖下层的约束性更强的保证性,你永远无法在一个100M吞吐量的链路之上实现的IP协议保证1000M的吞吐量…
IP协议设计为分组转发协议,每一跳都要经过一个中间节点,路由的设计是TCP/IP网络的另一大创举,这样,IP协议就无需方向性,路由信息和协议本身不再强关联,它们仅仅通过IP地址来关联,因此,IP协议更加简单。路由器作为中间节点也不能太复杂,这涉及到成本问题,因此路由器只负责选路以及转发数据包。
因此传输控制协议必然需要在端点实现。在我们详谈TCP协议之前,首先要看一下它不能做什么,由于IP协议不提供保证,TCP也不能提供依赖于IP下层链路的这种保证,比如带宽,比如时延,这些都是链路层决定的,既然IP协议无法修补,TCP也不能,然而它却能修正始于IP层的一些“不可保证性质”,这些性质包括IP层的不可靠,IP层的不按顺序,IP层的无方向/无连接。
将该小节总结一下,TCP/IP模型从下往上,功能增加,需要实现的设备减少,然而设备的复杂性却在增加,这样保证了成本的最小化,至于性能或者因素,靠软件来调节吧,TCP协议就是这样的软件,实际上最开始的时候,TCP并不考虑性能,效率,公平性,正是考虑了这些,TCP协议才复杂了起来。
这是一个纯软件协议,为何将其设计上两个端点,参见上一小节,本节详述TCP协议,中间也穿插一些简短的论述。
我们习惯于将TCP作为协议栈的最顶端,而不把应用层协议当成协议栈的一部分,这部分是因为应用层被TCP/UDP解复用了之后,呈现出了一种太复杂的局面,应用层协议用一种不同截然不同的方式被解释,应用层协议习惯于用类似ASN.1标准来封装,这正体现了TCP协议作为多路复用/解复用器的重要性,由于直接和应用接口,它可以很容易直接被应用控制,实现不同的传输控制策略,这也是TCP被设计到离应用不太远的地方的原因之一。
IP协议是没有方向的,数据报传输能到达对端全靠路由,因此它是一跳一跳地到达对端的,只要有一跳没有到达对端的路由,那么数据传输将失败,其实路由也是互联网的核心之一,实际上IP层提供的核心基本功能有两点,第一点是地址管理,第二点就是路由选路。TCP利用了IP路由这个简单的功能,因此TCP不必考虑选路,这又一个它被设计成端到端协议的原因。
既然IP已经能尽力让单独的数据报到达对端,那么TCP就可以在这种尽力而为的网络上实现其它的更加严格的控制功能。TCP给无连接的IP网络通信增加了连接性,确认了已经发送出去的数据的状态,并且保证了数据的顺序。
任何的TCP分段都要在尽力而为的IP网络上传输,中间的路由器可能会随意的缓存任何的IP数据报,它并不管这个IP数据报上被承载的是什么数据,然而根据经验和互联网的大小,一个IP数据报最多存活MSL(这是根据地球表面积,电磁波在各种介质中的传输速率以及IP协议的TTL等综合推算出来的,如果在火星上,这个MSL会大得多…)。
现在我们考虑终止连接时的被动方发送了一个FIN,然后主动方回复了一个ACK,然而这个ACK可能会丢失,这会造成被动方重发FIN,这个FIN可能会在互联网上存活MSL。
如果没有TIME_WAIT的话,假设连接1已经断开,然而其被动方最后重发的那个FIN(或者FIN之前发送的任何TCP分段)还在网络上,然而连接2重用了连接1的所有的5元素(源IP,目的IP,TCP,源端口,目的端口),刚刚将建立好连接,连接1迟到的FIN到达了,这个FIN将以比较低但是确实可能的概率终止掉连接2.
为何说是概率比较低呢?这涉及到一个匹配问题,迟到的FIN分段的序列号必须落在连接2的一方的期望序列号范围之内。虽然这种巧合很少发生,但确实会发生,毕竟初始序列号是随机产生了。因此终止连接的主动方必须在接受了被动方且回复了ACK之后等待2*MSL时间才能进入CLOSE状态,之所以乘以2是因为这是保守的算法,最坏情况下,针对被动方的ACK在以最长路线(经历一个MSL)经过互联网马上到达被动方时丢失。
要知道,很多人很不喜欢看到服务器上出现大量的TIME_WAIT状态的连接,因此他们将TIME_WAIT的值设置的很低,这虽然在大多数情况下可行,然而确实也是一种冒险行为。最好的方式就是,不要重用一个连接。
基本上传输可靠性是靠确认号实现的,也就是说,每发送一个分段,接下来接收端必然要发送一个确认,发送端收到确认后才可以发送下一个字节。这个原则最简单不过了,教科书上的“停止-等待”协议就是这个原则的字节版本,只是TCP使用了滑动窗口机制使得每次不一定发送一个字节,但是这是后话,本节仅仅谈一下确认的超时机制。
怎么知道数据到达对端呢?那就是对端发送一个确认,但是如果一直收不到对端的确认,发送端等多久呢?如果一直等下去,那么将无法发现数据的丢失,协议将不可用,如果等待时间过短,可能确认还在路上,因此等待时间是个问题,另外如何去管理这个超时时间也是一个问题。
这个平均值如果仅仅直接通过计算多次测量值取算术平均,那是不恰当的,因为对于数据传输延时,我们必须考虑的路径延迟的瞬间抖动,否则如果两次测量值分别为2和98,那么超时值将是50,这个值对于2而言,太大了,结果造成了数据的延迟过大(本该重传的等待了好久才重传),然而对于98而言,太小了,结果造成了过度重传(路途遥远,本该很慢,结果大量重传已经正确确认但是迟到的TCP分段)。
设计单一计时器有两个原则:1.每一个报文在长期收不到确认都必须可以超时;2.这个长期收不到中长期不能和测量的RTT相隔太远。因此RFC2988定义一套很简单的原则:
我们看看这4条规则是如何做到以上两点的,根据a和c(在c中,注意到ACK是非冗余的),任何TCP分段只要不被确认,超时定时器总会超时的。然而为何需要c呢?只有规则a存在的话,也可以做到原则1。实际上确实是这样的,但是为了不会出现过早重传,才添加了规则c,如果没有规则c,那么万一在重传定时器到期前,发送了一些数据,这样在定时器到期后,除了很早发送的数据能收到ACK外,其它稍晚些发送的数据的ACK都将不会到来,因此这些数据都将被重传。有了规则c之后,只要有分段ACK到来,则重置重传定时器,这很合理,因此大多数正常情况下,从数据的发出到ACK的到来这段时间以及计算得到的RTT以及重传定时器超时的时间这三者相差并不大,一个ACK到来后重置定时器可以保护后发的数据不被过早重传。
这里面还有一些细节需要说明。一个ACK到来了,说明后续的ACK很可能会依次到来,也就是说丢失的可能性并不大,另外,即使真的有后发的TCP分段丢失现象发生,也会在最多2倍定时器超时时间的范围内被重传(假设该报文是第一个报文发出启动定时器之后马上发出的,丢失了,第一个报文的ACK到来后又重启了定时器,又经过了一个超时时间才会被重传)。虽然这里还没有涉及拥塞控制,但是可见网络拥塞会引起丢包,丢包会引起重传,过度重传反过来加重网络拥塞,设置规则c的结果可以缓解过多的重传,毕竟将启动定时器之后发送的数据的重传超时时间拉长了最多一倍左右。最多一倍左右的超时偏差做到了原则2,即“这个长期收不到中长期不能和测量的RTT相隔太远”。
还有一点,如果是一个发送序列的最后一个分段丢失了,后面就不会收到冗余ACK,这样就只能等到超时了,并且超时时间几乎是肯定会比定时器超时时间更长。如果这个分段是在发送序列的靠后的时间发送的且和前面的发送时间相隔时间较远,则其超时时间不会很大,反之就会比较大。
基本上传输可靠性是靠序列号实现的。
疑难杂症7:确认号和超时重传
确认号是一个很诡异的东西,因为TCP的发送端对于发送出去的一个数据序列,它只要收到一个确认号就认为确认号前面的数据都被收到了,即使前面的某个确认号丢失了,也就是说,发送端只认最后一个确认号。这是合理的,因为确认号是接收端发出的,接收端只确认按序到达的最后一个TCP分段。
另外,发送端重发了一个TCP报文并且接收到该TCP分段的确认号,并不能说明这个重发的报文被接收了,也可能是数据早就被接收了,只是由于其ACK丢失或者其ACK延迟到达导致了超时。值得说明的是,接收端会丢弃任何重复的数据,即使丢弃了重复的数据,其ACK还是会照发不误的。
标准的早期TCP实现为,只要一个TCP分段丢失,即使后面的TCP分段都被完整收到,发送端还是会重传从丢失分段开始的所有报文,这就会导致一个问题,那就是重传风暴,一个分段丢失,引起大量的重传。这种风暴实则不必要的,因为大多数的TCP实现中,接收端已经缓存了乱序的分段,这些被重传的丢失分段之后的分段到达接收端之后,很大的可能性是被丢弃。关于这一点在拥塞控制被引入之后还会提及(问题先述为快:本来报文丢失导致超时就说明网络很可能已然拥塞,重传风暴只能加重其拥塞程度)。
有个细节可能会引起误会,那就是TCP的窗口大小空间是序列号空间的一半,这样恰好在满载情况下,数据能填满发送窗口和接收窗口,序列号空间正好够用。然而事实上,TCP的初始序列号并不是从0开始的,而是随机产生的(当然要辅助一些更精妙的算法),因此如果初始序列号比较接近2的32次方,那么很快就会回绕。
当然,如今可以用时间戳选项来辅助作为序列号的一个识别的部分,接收端遇到回绕的情况,需要比较时间戳,我们知道,时间戳是单调递增的,虽然也会回绕,然而回绕时间却要长很多。这只是一种策略,在此不详谈。还有一个很现实的问题,理论上序列号会回绕,但是实际上,有多少TCP的端点主机直接架设在1G的网络线缆两端并且接收方和发送方的窗口还能恰好被同时填满。另外,就算发生了回绕,也不是一件特别的事情,回绕在计算机里面太常见了,只需要能识别出来即可解决,对于TCP的序列号而言,在高速网络(点对点网络或者以太网)的两端,数据发生乱序的可能性很小,因此当收到一个序列号突然变为0或者终止序列号小于起始序列号的情况后,很容易辨别出来,只需要和前一个确认的分段比较即可,如果在一个经过路由器的网络两端,会引发IP数据报的顺序重排,对于TCP而言,虽然还会发生回绕,也会慢得多,且考虑到拥塞窗口(目前还没有引入)一般不会太大,窗口也很难被填满到65536。
端到端的流量控制使用滑动窗口来实现。滑动窗口的原理非常简单,基本就是一个生产者/消费者模型
问题2解决:Nagle算法
Nagel算法很简单,标准的Nagle算法为:
<span style=
"font-size: 18px;"
>IF 数据的大小和窗口的大小都超过了MSS<br> Then 发送数据分段<br>ELSE<br> IF 还有发出的TCP分段的确认没有到来<br> Then 积累数据到发送队列的末尾的TCP分段<br> ELSE<br> 发送数据分段<br> EndIF<br>EndIF<br></span>
这样如果发出了一个MSS大小的分段还没有被确认,后面也是可以随时发送一个小分段的,这个改进降低了算法对延迟时间的影响。这个算法体现了一种自适应的策略,越是确认的快,越是发送的快,虽然Nagle算法看起来在积累数据增加吞吐量的同时也加大的时延,可事实上,如果对于类似交互式的应用,时延并不会增加,因为这类应用回复数据也是很快的,比如Telnet之类的服务必然需要回显字符,因此能和对端进行自适应协调。
注意,Nagle算法是默认开启的,但是却可以关闭。如果在开启的情况下,那么它就严格按照上述的算法来执行。
如果等了一段可以接受的时间,还是没有数据要发往发送端,此时就需要单独发送一个ACK了,然而即使如此,这个延迟的ACK虽然没有等到可以被捎带的数据分段,也可能等到了后续到来的TCP分段,这样它们就可以取最大者一起返回了,要知道,TCP的确认号是收到的按序报文的最后一个字节的后一个字节。最后,RFC建议,延迟的ACK最多等待两个分段的积累确认。
三个问题导致的结果是相同的,但是要知道它们的原因本质上是不同的,问题1几乎总是出现在接收端窗口满的情况下,而问题2几乎总是发生在窗口闲置的情况下,问题3看起来是最无聊的,然而由于TCP的要求,必须要有确认号,而且一个确认号就需要一个TCP分段,这个分段不含数据,无疑是很小的。
三个问题都导致了网络利用率的降低。虽然两个问题导致了同样的结果,但是必须认识到它们是不同的问题,很自然的将这些问题的解决方案汇总在一起,形成一个全局的解决方案,这就是如今的操作系统中的解决方案。
Linux的TCP实现在这个问题上表现的更加灵活,它是这么判断能否发送的(在开启了Nagle的情况下):
IF 数据的大小和窗口的大小都超过了MSS
Then 发送数据分段
ELSE
IF 还有发出的TCP分段的确认没有到来
Then 积累数据到发送队列的末尾的TCP分段
ELSE
发送数据分段
EndIF
EndIF
曾经我也改过Nagle算法,确切的说不是修改Nagle算法,而是修改了“到底何时能发送数据”的策略,以往都是发送端判断能否发送数据的,可是如果此时有延迟ACK在等待被捎带,而待发送的数据又由于积累不够或者其它原因不能发送,因此两边都在等,这其实在某些情况下不是很好。我所做的改进中对待何时能发送数据又增加了一种情况,这就是“ACK拉”的情况,一旦有延迟ACK等待发送,判断一下有没有数据也在等待发送,如果有的话,看看数据是否大到了一定程度,在此,我选择的是MSS的一半:
IF (没有超过拥塞窗口大小的数据分段未确认 || 数据分段中包含FIN ) &&
数据分段没有超越窗口边界
Then
IF 分段在中间(上述例子中的分段1和2) ||
分段是紧急模式 ||
通过上述的Nagle算法(改进后的Nagle算法)
Then 发送分段
EndIF
ELSE IF 有延迟ACK等待传输 &&
发送队列中有待发送的TCP分段 &&
发送队列的头分段大小大于MSS的一半
Then 发送队列头分段且捎带延迟ACK
EndIF
另外,发送队列头分段的大小是可以在统计意义上动态计算的,也不一定非要是MSS大小的一半。我们发现,这种算法对于交互式网路应用是自适应的,你打字越快,特定时间内积累的分段就越长,对端回复的越快(可以捎带ACK),本端发送的也就越快(以Echo举例会更好理解)。
首先服务器发了两个分段:
数据段12:ack 14
数据段13:ack 14,54:56
然后客户端发了两个分段:
数据段14:ack 54,14:17
数据段15:ack 56,17:18
可以看到数据段14本来应该确认56的,但是确认的却是54。也就是说,数据段已经移出队列将要发送但还未发送的时候,数据段13才到来,软中断处理程序抢占了数据段14的发送进程,要知道此时只是把数据段14移出了队列,还没有更新任何的状态信息,比如“发出但未被确认的分段数量”,此时软中断处理程序顺利接收了分段13,然后更新窗口信息,并且检查看有没有数据要发送,由于分段14已经移出队列,下一个接受发送检查的就是分段15了,由于状态信息还没有更新,因此分段15顺利通过发送检测,发送完成。
可以看Linux的源代码了解相关信息,tcp_write_xmit这个函数在两个地方会被调用,一个是TCP的发送进程中,另一个就是软中断的接收处理中,两者在调用中的竞态就会引起《详解》中的那种情况。注意,这种不加锁的发送方式是合理的,也是最高效的,因此TCP的处理语义会做出判断,丢弃一切不该接收或者重复接收的分段的。
其它情况下是不会拥塞的。因为一个TCP总是希望独享整条网络通路,而这对于多个连接而言是不可能的,必须保证TCP的公平性,这样这种拥塞控制机制才合理。本质上,拥塞的原因就是大家都想独享全部带宽资源,结果导致拥塞,这也是合理的,毕竟TCP看不到网络的状态,同时这也决定了TCP的拥塞控制必须采用试探性的方式,最终到达一个足以引起其“反应”的“刺激点”。
拥塞控制需要完成以下两个任务:1.公平性;2.拥塞之后退出拥塞状态。
另外,不要把路由器想成一种线速转发设备,再好的路由器只要接入网络,总是会拉低网络的总带宽,因此即使只有一个TCP连接,由于TCP的发送方总是以发送链路的带宽发送分段,这些分段在经过路由器的时候排队和处理总是会有时延,因此最终肯定会丢包的。
最后,丢包的延后性也会加重拥塞。假设一个TCP连接经过了N个路由器,前N-1个路由器都能顺利转发TCP分段,但是最后一个路由器丢失了一个分段,这就导致了这些丢失的分段浪费了前面路由器的大量带宽。
在介绍拥塞控制之前,首先介绍一下拥塞窗口,它实际上表示的也是“可以发送多少数据”,然而这个和接收端通告的接收窗口意义是不一样的,后者是流量控制用的窗口,而前者是拥塞控制用的窗口,体现了网络拥塞程度。
拥塞控制整体上分为两类,一类是试探性的拥塞探测,另一类则是拥塞避免(注意,不是常规意义上的拥塞避免)。
当进入快速重传时,为何要将拥塞窗口缩小为当前阀值的一半加上3呢?加上3是基于数据包守恒来说的,既然已经收到了3个冗余ACK,说明有三个数据分段已经到达了接收端,既然三个分段已经离开了网络,那么就是说可以在发送3个分段了,只要再收到一个冗余ACK,这也说明1个分段已经离开了网络,因此就将拥塞窗口加1个MSS。直到收到新的ACK,说明直到收到第三个冗余ACK时期发送的TCP分段都已经到达对端了,此时进入正常阶段开始加性增加拥塞窗口。
当然,BIC算法提高了加性增的效率,不再一个一个MSS的加,而是一次加比较多的MSS,采取二分查找的方式逐步找到不丢包的点,然后加性增。
其中a是大多数的状态,因为一般而言,TCP连接都是建立在互联网上的,而且是大量的,比如Web浏览,电子邮件,网络游戏,Ftp下载等等。TCP发送端用慢启动或者拥塞避免方式不断增加其拥塞窗口,直到丢包的发生,然后进入慢启动或者拥塞避免阶段(要看是由于超时丢包还是由于冗余ACK丢包),此时发送窗口将下降到1或者下降一半,这种情况下,一般接收端的接收窗口是比较大的,毕竟IP网络并不是什么很快速的网络,一般的机器处理速度都很快。
但是如果接收端特别破,处理速度很慢,就会导致其通告一个很小的窗口,这样的话,即使拥塞窗口再大,发送端也还是以通告的接收窗口为发送窗口,这样就不会发生拥塞。最后,如果唯一的TCP连接运行在一个直连的两台主机上,那么它将独享网络带宽,这样该TCP的数据流在最好的情况下将填满网络管道(我们把网络管道定义为带宽和延时的乘积),其实在这种情况下是不存在拥塞的,就像你一个人独自徘徊在飘雨黄昏的街头一样…
前面我们描述的拥塞控制方式都是试探性的检测,然后拥塞窗口被动的进行乘性减,这样在接收端窗口很大的情况下(一般都是这样,网络拥堵,分段就不会轻易到达接收端,导致接收端的窗口大量空置)就可能出现锯齿形状的“时间-窗口”图,类似在一个拥堵的北京X环上开车,发送机发动,车开动,停止,等待,发动机发动,车开动…听声音也能听出来。
虽然TCP看不到下面的IP网络,然而它还是可以通过检测RTT的变化以及拥塞窗口的变化推算出IP网络的拥堵情况的。就比方说北京东四环一家快递公司要持续送快递到西四环,当发件人发现货到时间越来越慢的时候,他会意识到“下班高峰期快到了”…
可以通过持续观测RTT的方式来主动调整拥塞窗口的大小而不是一味的加性增。然而还有更猛的算法,那就是计算两个差值的乘积:
(当前拥塞窗口-上一次拥塞窗口)x(当前的RTT-上一次的RTT)
如果结果是正数,则拥塞窗口减少1/8,若结果是负数或者0,则窗口增加一个MSS。注意,这回不再是乘性减了,可以看出,减的幅度比乘性减幅度小,这是因为这种拥塞控制是主动的,而不是之前的那种被动的试探方式。在试探方式中,乘性减以一种惩罚的方式实现了公平性,而在这里的主动方式中,当意识到要拥塞的时候,TCP发送者主动的减少了拥塞窗口,为了对这种自首行为进行鼓励,采用了小幅减少拥塞窗口的方式。需要注意的是,在拥塞窗口减小的过程中,乘积的前一个差值是负数,如果后一个差值也是负数,那么结果就是继续缩减窗口,直到拥塞缓解或者窗口减少到了一定程度,使得后一个差值成了正数或者0,这种情况下,其实后一个差值只能变为0。
这就是路由器能帮TCP做的事。
如果想从设计的角度来说,这两本书都很烂。我觉得应该先看点入门的,比如Wiki之类的,然后看RFC文档,793,896,1122等),这样你就明白TCP为何这么设计了,而这些你永远都不能在Richard Stevens的书中得到。最后,如果你想,那么就看一点Richard Stevens的书,最重要的还是写点代码或者敲点命令,然后抓包自己去分析。
TCP协议是一个端到端的协议,虽然话说它是一个带流量控制,拥塞控制的协议,然而正是因为这些所谓的控制才导致了TCP变得复杂。同时这些特性是互相杂糅的,流量控制带来了很多问题,解决这些问题的方案最终又带来了新的问题,这些问题在解决的时候都只考虑了端到端的意义,但实际上TCP需要尽力而为的IP提供的网络,因此拥塞成了最终的结症,拥塞控制算法的改进也成了一个单独的领域。
在学习TCP的过程中,切忌一锅粥一盘棋的方式,一定要分清楚每一个算法到底是解决什么问题的,每一个问题和其他问题到底有什么关联,这些问题的解决方案之间有什么关联,另外TCP的发展历史也最好了解一下,这些都搞明白了,TCP协议就彻底被你掌控了。接下来你就可以学习Socket API了,然后高效的TCP程序出自你手!
<br />