前言
开始这篇文章之前,我非常的紧张,因为要写好这个TCP协议说实话并不简单。作为TCP/IP协议簇最为核心的部分,《TCP/IP协议 卷一》花了整整八章的篇幅去介绍它。如何在保证正确的前提之下,合理有序的写出一些有意义的内容,这是一个很大的挑战。
整个看书学习的过程,实际也是一种享受。在了解TCP的各项策略时,你可以通过书本了解到前辈设计时的所思所想。如何在无连接不可靠的IP网络上实现一个可靠有连接的传输;如何根据已有的信息去推测诊断当前的网络环境;如何充分利用当前的资源以最高效的方式传输;如何动态的感知网络的波动。相信你在了解之后也一定会和我一样忍不住拍手称为。
本文介绍TCP,依然是从三次握手和四次挥手开始;之后介绍了两种不同情况下TCP的传输策略;在文章的结尾我们简单说了带宽时延乘积,这是一个非常重要的概念,理解它之后才会明白拥塞发生的情形,以及我们是如何把数据报的传递抽象成流。虽然是熟悉的概念,但文章尽力从一些网上其他文章没有提到的角度来分析这些问题,希望能够给你一些新的启示。
在计算机网络的学习过程中,概念和参数并不是最重要的。如何去理解一个协议,如何从现有的工具里去观察一个协议从而分析问题,如何去借助文档回答问题,这些能力才是我们真正应该重视的。
再谈三次握手和四次挥手
在其他介绍三次握手的文章里,经常会从类似如下的示意图开始
图上的内容非常的简单,但是实际的握手过程远比这个复杂。首先,我们要考虑的就是为什么TCP的建立需要三次握手。这个问题我们在这个系列的文章第一篇就进行了讨论,当时给出的结论是:
TCP需要在不可靠的信道上进行可靠的传输,那么必须要在通讯之前就某些问题达成一致。一条消息如果需要被单方面确认,需要一次单向握手,那么双方同时就某个问题达成一致就需要两次单向握手。
序号 | 方向 | 具体操作 |
---|---|---|
1) | A --> B | Send A’s SYN |
2) | A <-- B | ACK A’s SYN |
3) | A <-- B | Send B’s SYN |
4) | A --> B | ACK B’s SYN |
其中2 , 3两步可以合并成一条信息,这就是三次握手的由来。
但这里仍然留有几个模糊不清的点。
1.参考上面的流程图,整个握手结束之后实际只有B可以确认消息双方都已经得到,而A无法确定最后一条ACK B’s SYN是否送达。
这是计算机网络中一个非常有名的思想实验:两军问题
。实际上百度可以得到非常多的资料,但是各类博客抄来抄去,一些好的文章原作者已经不可考,所以这里我再简单做一下阐述。
两支军队(我们暂时称为A1和A2)预备从两边去攻打低洼的一座城池(暂称为B)。他们的力量对比是
- B < A1 + A1
- B > A1
- B > A2
A1和A2必须要约定好在同一个时间发起攻击,才可以获胜,单独行动都会被B消灭。但是A1和A2通信必须要经过B的城池,这也就意味着通信兵可能会被截获。如何设计一个通信的方案可以使得A1和A2必胜呢?
两军问题本质上和我们TCP遇到的情况一样:在不可靠的信道上试图就某些信息达成一致。目前的方案是每当发送一份消息,必须要返回一份回执来告知发送方消息已送达。
引出的一个问题就是发送回执的一方如何确认自己的回执被送达了呢?再返回一条回执来确认自己收到了对方的这条回执?所谓子子孙孙无穷尽也,大抵就是如此。这意味着不可靠的信道上最后一条被发送的消息永远都是无法被双方同时确认的。两军问题本质上是无解的。
还需要强调的是,可靠性并不会因为握手次数的增加而提高。三次握手是可靠性和效率两者平衡妥协的结果。最后一次通信的发送方必须要承担行动的风险。
除了连接建立时,在一端(我们假设为客户端)发起主动关闭时,也会遇到同样的问题。四次挥手的过程如下。
发起主动关闭的一方在发送最后一个FIN ACK之后会在TIME_WAIT状态停留2MSL的时间。这样做的目的其一就是为了实现全双工的TCP连接的可靠终止。
MSL是Maximum Segment Lifetime,指代任何报文段被丢弃前在网络内的最长时间
因为最后一条FIN包的ACK发出以后客户端是无法确认对端服务器一定收到的,如果客户端发送完FIN ACK之后认为已经结束关闭了这个连接,但实际FIN ACK又未送达,这时服务端重新发送了一个FIN包给客户端,会得到一条RST的响应,这会被服务器解析成一种错误,而实际客户端是正常关闭的。
为此客户端必须要维护状态信息2MSL的时间,并在结束时按照最后一条FIN ACK丢失的情况处理,重新发送一次FIN ACK。
TIME_WAIT另一个目的是允许之前连接的报文在网络中消逝。熟悉 socket
编程的朋友应该知道可以通过四元组(目的端IP地址和端口,源端IP地址和端口)来确定唯一的一条TCP连接。但是如果关闭了一个TCP连接之后,在相同四元组之上重新打开一个TCP连接,后一个连接会被认为是之前连接的化身。
这里的翻译比较让人困惑,在RFC 793中是这样描述的 : New instances of a connection will be referred to as incarnations of the connection.
后面我们会多次提到化身,指的是同一个四元组上新创建的连接。
存在一种可能是某个连接之前的重复分组在该连接终止后再现,如果此时创建了新的化身,很可能会带来误解。
为此TCP拒绝为处于TIME_WAIT的端口创建新的化身。TIME_WAIT的时间是2MSL,这保证了无论是哪个方向上的报文(存活时间MSL),还是另一端的应答(发送的报文最长存活MSL,返回的应答最多存活2MSL)都会在TIME_WAIT期间消逝。
实际这个规则存在例外,我们将在后面遇到这种情况
2. 在三次握手的过程中,双方尝试在哪些方法达成约定?
为了研究这个问题,我们可以打开Wireshark,找一个处于三次握手的TCP连接。下图是我任意找的一个报文。
有关SYN FIN的介绍非常多,本文不再介绍。如果你不太熟悉,没有必要一一去硬背下来,了解这几个名称实际指代的单词会有助于理解和记忆。
1. SYN = synchronization 同步。正如我们上文介绍所说,TCP连接的建立必须要就某些问题达成约定,也就是同步信息
2. PSH = push 发送端通知接收端不要因为等待额外数据而让已送达的数据在缓冲区滞留。类似flush()
3. FIN = finish 也就是我们所说的四次挥手。结束的含义
4. ACK = acknowledge 确认报文。你可以简单认为是回执,具体是确认哪一部分的数据,需要结合sequence number
这是一个非常典型的三次握手,上文所说的握手报文合并也可以在报文 489
看出。注意图中红框标示的信息。
-
49817 - > 443
是 源端口 -> 目的端口
细心的朋友应该看出443端口是为https服务指定的,实际也确实如此,下一个未展示的报文就是
Client Hello
。
-
SYN
标示指明了这是一个发起连接请求的报文。 -
Seq
就是我们马上将要介绍的重点Sequence Number
-
Win
是Window的意思,这一个字段我们会在后续仔细介绍 -
Len
是length,用以指明TCP数据部分的长度。注意这个长度并不包含报文首部,所以在SYN
包中Len
是0 -
WS
是表明发起端192.168.199.170
可以处理Selective Acknowledgements
。TSval
和TSecr
是时间戳选项相关的内容。这三个参数本文不做介绍,有兴趣的朋友可以自行查阅。what is 'WS' 'TSval' and 'SACK_PERM' mean in packet info columns???
我们需要关心的是Seq
字段。
在连接建立的过程中客户端和服务器会互相通告自己的ISN,也就是SYN
包中我们看到的Seq
字段的值。需要注意的是只有在SYN包中Seq
字段才是发送端的ISN
。
ISN = Initial Sequence Number
Seq
是序号的意思,它可以描述当前发送的数据报中的数据相对于整体数据开始位置的偏移量,单位是字节。与之类似的是ACK
数据报中的Ack
字段,它是为了通告对端已经接收的数据相对于整体数据开始位置的偏移量(也可以理解为对端期待接收的数据相对于整体数据开始位置的偏移量)。
下图描述的是一个最简单的TCP连接和中断的过程。
首先报文段1通告了srv的ISN也就是图中的1415531521。之后报文段2通告了bsdi的ISN也就是1823083521,但是它多了一条ack,注意它的数值是1415531522 = 1415531521 + 1!表明bsdi已经确认收到了srv的SYN包。
SYN
和FIN
包会让Seq
加一,你可以简单认为是一个长度为1的数据报。
注意报文段4,srv的Seq
被设置成1415531522。因为对端已经ack了SYN包,也就意味着我们发送的数据应该从这之后开始。
但是我们需要注意,包括上面我们截图的三条报文,打开Wireshark你去观察任一一个SYN
包,里面的Seq
字段永远都是0,而不是我们流程图里那一长串的数字。这是因为Wireshark展示的Seq
和Ack
字段全部都是相对数值也就是Seq/Ack - ISN。
我们必须要思考的一个问题就是,ISN
是如何选择的?如果仅仅是为了标记收发数据的偏移量,我们完全可以默认从0开始计算而不必加上ISN。这似乎更加简单。
在RFC 793中有关这部分做了讨论,它首先提出了一个问题: how does the TCP identify duplicate segments from previous incarnations of the connection? 比如在一个连接(四元组不变)上短时间内快速的重复打开关闭,或者一个连接因为内存不足而断开继而复位。连接的化身很可能会接收到之前连接存留在网络内的数据报。
我们上文介绍的TIME_WAIT设计的初衷,部分就是为了避免这种混淆。但是这并不保险。为了解决这个问题,TCP选择初始一个ISN,并在此基础之上累加,从而让连接的化身能够正确分辨数据报。
socket
编程中,我们可以指定SO_REUSEADDR
选项让处于TIME_WAIT状态的端口可用
主机或者TCP模块的崩溃也会遗失状态的记录。
ISN的生成器实质上是一个32比特的计数器,每隔一定的时间加1(通常是4ms,但不同系统实现不一样)。选择这样的生成方式是为了考虑到一种更极端的情况: even if a TCP crashes and loses all knowledge of the sequence numbers it has been using。ISN的生成器实质是和TCP模块互相独立的。
ISN的范围是0 ~ 2 ^ 32 - 1,达到最大之后ISN会环回到0开始。在4ms加1这种实现的系统里,大约需要4.55小时ISN环回一遍。这个时间是远远大于TIME_WAIT的,所以不必担心TIME_WAIT期间ISN发生回绕从而重复。
上文我们说过TIME_WAIT有一个特例:在源自Berkeley的实现当中,如果到达的ISN大于之前连接的结束序列号,那么Berkeley的实现是允许当前处于TIME_WAIT的端口复用。简单来看这么做是没有问题的,因为FIN包中的Seq
一定是当前连接最大的Sequence Number。如果新连接的ISN大于这个Seq
那么显而易见,这个SYN
包肯定不属于之前连接的。
但是问题出在ISN的选择是环回的!当Sequence Number
达到最大也就是2^32 - 1时会环回到0重新开始。假设之前连接的通讯过程中Sequence Number
发生了环回,我们上文的结论也就不成立了。所以这种特例是存在陷阱的。
通常在一个高速通道上Sequence Number非常容易发生环回,造成的问题不仅仅是我们提到TIME_WAIT,中间超时重传的包也可能会让对端造成错误的理解。
使用窗口扩大选项的TCP连接,最大的窗口接近2 ^ 30!这意味着按照最大窗口发送,第五个数据报Seq
就会发生环回。举一个简单的例子,假设我们需要传输6G的数据。
序号 | 方向 | 数据 |
---|---|---|
1. | A —> B |
Seq 0G : 1G |
2. | A —> B |
Seq 1G : 2G |
3. | A —> B |
Seq 2G : 3G |
4. | A —> B |
Seq 3G : 4G |
5. | A —> B |
Seq 0G : 1G |
6. | A —> B |
Seq 1G : 2G |
假如第二个数据报发生丢失,在发送第六个数据报发生重传,那么接收端就会发生混淆,这个时候单单依靠Seq
是没有办法判断数据报的先后顺序的。为此TCP引入了时间戳选项来解决这个问题,作为32比特的Seq
的一个拓展。
需要强调的有两点
一是Seq数值的增长是和数据的传输速度有关的,而ISN是根据定时器线性增长的。二是实际发生这种情况的条件非常苛刻。因为如果发生环回的时间大于MSL,那么我们上文提到的第二个数据报在第六个数据报发送时,一定消失在网络当中了。所以发生这种情况一般是在高速通道上。在RFC 1185 TCP Extension for High-Speed Paths中做了详细的讨论。
ISN是三次握手需要协商约定的一个重要选项。
除此之外SYN
包的TCP首部中,选项里最常见的一个字段就是MSS(Maximum Segment Size)。双方在建立连接的时候会互相通告对方己端能够接收的最大报文长度,目的是为了避免发生分片。需要注意的是MSS的值是不包括IP首部和TCP首部的,例如在MTU位1500的外出接口上,通告的MSS应该是1460。但是这个选项的局限在于它仅仅只在SYN
包出现,这也就意味着如果通讯建立的过程当中MSS的数值发生了变化,对端是无法感知的。另外,MSS仅仅只声明了自己的约束,如果中间网络的MTU小于两端通告的MSS,那么分片依然是无法避免的。
IPv6是期待以1280打天下的。它要求硬件提供的最小MTU是1280
有关三次握手的讨论,暂时告一段落。为了与之呼应,我们再来看一看TCP断开连接的四次挥手。TCP作为全双工的通信,在连接建立完成之后实际存在了两条虚拟信道:客户端 —> 服务器
和 服务器 —> 客户端
。因此我们在关闭的时候也要逐一的拆除。
但和连接建立不同的是,双方的传输任务无法保证在同一时间结束。这意味着在某一端发起关闭的时候,我们必须要保证,在拆除其中一条信道的同时,不影响另一条信道的通讯。这也就是半关闭的由来。
在socket编程中,关闭连接的方式通常是
close()
函数。每次调用close()
函数时会把对应的描述符sockfd引用计数减一,在计数为0时同时关闭读和写也就是完全关闭。为了应对半关闭的情况,我们会使用shutdown()
函数,指定第二个参数为SHUT_WR
来实现半关闭
交互数据流和成块数据流
在书中的十九和二十章,讨论了交互数据流和成块数据流两种情况的传输策略。但是书中并未就这两种数据流给出明确的定义。在了解它们各自策略是如何实现之前,明确它们的特点和定义还是十分有必要的。
什么是交互数据流和成块数据流
顾名思义,交互数据流的特点表现在交互上,这也就是说数据流流动的方向是双向的,本质是通信两端的信息交换。通常情况下客户端向服务器发出一条消息,服务器除了会返回ACK对消息进行确认之外,还会针对客户端的请求反馈相关的信息。交互数据流的每一个报文通常都会比较小。
我们采用
客户端-服务器模型
,并且规定主动发起的一方为客户端。之后的例子在没有特殊说明的情况下默认都是这样约定
在书中有过这样一段描述
一些有关TCP通信量的研究发现,如果按照分组数量计算,约有一半的TCP报文段包含成块数据(如FTP、电子邮件和Usenet新闻),另一半则包含交互数据(如Telnet和Rlogin)。如果按字节计算,则成块数据与交互数据比例约为 90%和10%。这是因为成块数据的报文段基本上都是满长渡的,而交互数据则小的多。
成块数据流的特点与交互数据流相反,它的侧重在于单向的传输,所承担的是要把一个较大的数据尽快的送抵到对端的任务。
我们可以做一个简单的比喻来帮助理解:交互数据流类似QQ上两人的聊天;而成块数据流则是在传输文件。
交互数据流
交互数据流的传输策略有两个重点
1. 经受时延的确认
通常TCP在接收到数据时并不立即发送ACK,而是推迟发送等待一段时间,之后如果有相同方向的数据需要传递,会捎带ack一起发送。
绝大多数的实现里是以200ms作为最大的时延等待。这里需要说明的是,时延并不是以数据到达目的端开始计算的,而是以TCP的实现当中一个200ms的定时器为准。如果有ack需要发送那么会在定时器下一次的溢出时执行。考虑到数据到达的时间是随机的,那么ack的发送时机也就不固定,范围在0 - 200ms。
与之类似的是TCP超时定时器
使用ack捎带的一个好处在于它提高了TCP的性能,原因在于它提高了有效载荷的比例。
当我们尝试通过TCP发送数据的时候,无论是1个字节或是MSS
大小的数据报,每一份报文都是以固定的格式发出的(这里忽略各类首部的额外选项)
IP首部 + TCP首部 + 有效载荷
假设我们需要传输一份大小为N的数据,需要分拆成m个包来完成,那么传输的数据总量就是
m * (20 + 20) + N
每一个报文尽可能多的装载数据,或者说使用尽可能少的分包来完成数据的传递,有效载荷占传输总量的比例也就越高。
ack捎带的处理可以压缩我们需要传输的数据,节省了原先ack首部的字节传输;在等待的同时,如果有多份数据抵达,那么这些数据的确认可以合并成一个ack报文,减少了报文的数量。考虑到交互数据流每一个报文数据量相对较小,ack报文的压缩带来的效率提升会更加明显。
其次ack捎带可以有效避免糊涂窗口综合征。
糊涂窗口综合征指的是当发送端应用进程产生数据很慢、或者接收端应用进程处理接收缓冲区数据很慢,或者两个情况同时存在;使得通信两端传输的报文段很小(特别是有效载荷很小)的情况。
极端情况下,有效载荷可能只有1个字节;而传输开销有40字节(20字节的IP头+20字节的TCP头)
为了避免这种情况的发生,我们可以从发送端和接收端两边入手解决。这个问题我们留在讲解完滑动窗口之后再讨论,现在需要明确的是对于接收端而言,ack捎带是一个可行的解决方案。
2. Nagle算法
Nagle算法要求在一个TCP连接上最多只能有一个未被确认的分组,在该分组被确认之前不能够发送其他的分组。如果在等待期间有需要发送的分组。会被收集起来在收到确认以后用同一个分组发出。
该算法的优越之处在于是自适应的:数据被对端确认的越快,发送端数据发送的也就越快;在相对低速的环境下可以有效减少微小分组的数据,提高TCP的传输效率。
在上文介绍ack捎带的时候我们提到压缩报文数量可以提高有效载荷在总体传输数据当中的比例,一定程度上提高了TCP传输的效率。另一点需要强调的是,TCP提供的传输服务是有序的。考虑到传输过程中数据报可能会丢失、乱序,接收端必须要对数据报进行处理。即使是微小分组,也是一个独立的数据报,过多的微小分组很显然会给接收端带来相应的处理压力,这不是我们所期望的。
微小分组指的是有效载荷非常小的报文
LAN上的通信相对简单,一般不会出现拥塞,传输速率相对WAN也比较高。我们做的大部分处理更多是要为低速的WAN考虑。这里可以简单做一个例子说明。
参照上图可以看出,LAN内一个字节从被发送到收到确认和回显的平均往返时间约为t = 16ms。如果我们的输入速度小于60个字节每秒,那么Nagle算法并不会对我们的传输造成任何影响,因为每次我们准备好下一个输出字节的时候,上一个字节已经送抵对端并且收到确认和回显了。
1s = 1000ms。 1000/16 = 62.5 ≈ 60
当平均往返时间 t 增加时(比如在WAN内)情况会发生变化。很可能我们键入新的字节但是之前数据还未被确认,这时我们键入的数据都会被收集等待确认一起发送。在某些应用程序上可能会感受到卡顿和反馈不及时。
比如X窗口系统服务器,用以标示鼠标移动的微小分组必须无时延地发送,以便提供实时的反馈消息。
Nagle算法在某些情况下甚至可能成为我们网络通信的瓶颈。假设这样一种情况:客户端发送了一条报文给服务端,之后等待服务端的确认;而服务器在收到报文之后并不立即返回ack而是等待。等待的原因可以是ack捎带,也可以是认为服务器认为客户端提供的信息不足够重复等待期望更多数据,无论是哪一种情况都势必陷入一个死锁的状态:双方都在等待对方的消息。通常情况超时才能打破这种僵局,但这显然是一种无意义的消耗。
在
socket
编程中,可以设置TCP_NODELAY来关闭Nagle算法
之前看到这样一个问题:如果客户端发送了一条消息之后,因为某些原因没有收到确认发生了超时,在这期间如果客户端收集了新的数据,超时之后发送的这个数据报应该如果发送?
TCP有一个实际存在的缓冲区,客户端发送的数据会留有备份,在接收到对应ack之后才会移除。如果发生超时客户端只需要把缓冲区的数据发送出去即可(我们假设所有的数据可以放在一个报文里发出)。
为什么说是一个实际存在的缓冲区呢。因为udp虽然有缓冲区这个概念,但是并不存在,所谓缓冲区的大小只是一个最大udp报文长度的限定。
Nagle算法也可以避免糊涂窗口综合征。这是从发送端入手的一种解决方式。
总结
两种传输策略实质是分别从发送端(Nagle算法)和接收端(ACK捎带)两端入手,通过压缩报文的数量来提高交互数据流整体传输的速率。
成块数据流
在上文中我们讨论了交互数据流的Nagle算法,在低速的WAN上经常会造成时延。这对于成块数据流的传输是不太能够接受的。我们必须要考虑到既然是交互,那么每一条消息的发出除了对端的确认,额外的反馈消息也是非常重要的,因为这很可能会影响到后续交互的逻辑。但是在成块数据流上则没有这个麻烦,在大部分的情况下它的目的非常明确:尽快地将数据搬运到对端。出于效率的考虑,TCP使用另一种传输策略,允许发送方在停止并等待确认前可以连续发送多个分组。
举一个例子:假设登录过程就是一次交互,那么客户端传递了用户名和密码之后,必须要等待服务器的反馈:这对用户名密码是否正确。之后客户端必须根据判定的结果来继续下一步的操作。
书中二十章的内容全部在围绕一个关键词展开:窗口。要理解成块数据流的传输策略,明确窗口的定义和它设计的意义,是非常有必要的。
首先我们来看一下数据传递的过程。
数据在被送达对端之后,存储在TCP的接收缓冲区,这是一个有限的空间。上层的应用进程会从接收缓冲区读取数据,之后相应的数据会从TCP的接收缓冲区移除,用以腾出空间接收新的数据。通告窗口(advertise window)就是用以描述自身接收缓冲区中当前可用的空间量的,在通告发送端之后可以确保它发送的数据不会使接收缓冲区溢出。
这是TCP提供的一种流量控制。我们必须要思考成块数据流的传输策略为什么要引入窗口这个概念?因为TCP无法保证发送端传输的速率和接收端处理数据的速率保持一致!
如果发送端的传输速率相对接收端的处理速率较慢,那么每次数据报送抵接收端都可以确保缓冲区有足够的空间去接收。但是情况反过来,发送端尽可能快的将数据抛出,接收端会因为缓冲区空间不足而丢弃分组。为了避免这样一种情况,TCP采用了滑动窗口协议。
在交互数据流中,情况相对简单很多。因为Nagle只允许网络中存在最多一个未被确认的分组,一来一回的传输策略逻辑比较简单;而且交互数据流报文相对较短,接收端压力不会太大。
注意图中红框标示的报文7和8。在这之前svr主机连续发送了三个报文(4 - 6),在第7个报文的ack只确认了4 - 5两个报文的内容。我们可以合理推断,在bsdi主机处理第4个报文时,执行了ack捎带的操作,在这期间bsdi处理完报文 5
,之后时延定时器发生溢出发出ack确认4 - 5。下一个时延定时器溢出bsdi处理完报文 6
,发出报文 8
确认了报文 6
。注意win参数从3072 = 4096 - 1024!这说明报文 6
还留在bsdi的TCP缓冲区里,可用空间减少了相应的大小。
用另一种可视化的方式来展示这个过程
绿色部分代表已经被确认的报文;黄色部分是通告窗口大小,表示接收端缓冲区可以同时容纳报文4 5 6 7 8 9;红色部分标示的是后续待发送的数据,但因为超出了通告窗口大小的限制当前不能发送。
但是这部分有一个需要强调的点是,发送端并非是从黄色部分的左侧边沿开始(图中的报文 4)选择报文发送。因为上图我们漏掉了一种可能:已经发送但尚未被确认的报文。
继续以上图为例,假设在接收端通告窗口的时候,虽然只确认了报文 1- 3,但是发送端实际已经发送了报文段 4 - 6,那么实际可用的窗口大小实际是报文 7 8 9这个范围。因为那些未被确认的报文(inflight)我们假设它们尚在路上,会在之后得到确认。
通告窗口的大小并不是一成不变的,受各种条件的影响通告窗口两端的边界会滑动使得通告窗口缩小或者扩大。这也是为什么我们称之为滑动窗口协议的原因。
通告窗口左边会随着报文被接收端确认而向右移动,我们称之为窗口合拢。因为确认过的报文不会被取消确认,所以窗口左边不可能出现向左移动的情况。
接收端的应用进程从TCP缓冲区读取数据之后,会腾出相应的空间来接收新的数据。这个时候通告窗口右边会向右移动,我们称之为窗口张开。
通告窗口右边在极少数情况下会向左移动,我们称之为窗口收缩。虽然TCP被要求必须能够在对端出现这种情况时进行处理,但这是极不推荐的一种方式。
如果通告窗口的左边沿和右边沿发生合拢,那么此时我们称之为零窗口。发送端无法继续发送数据,必须等待接收端处理。
图中红框标示的报文 14就是我们提到的零窗口。之后接收端重新发送了一条ack(报文 15),但并没有确认新的数据,只是更新了win告诉发送端可以继续发送。这种情况我们称之为窗口更新。
拥塞窗口
上文所示的例子有一个局限:它们测试在LAN内,在传输的一开始就发出多个报文段直到接收端通告了窗口并且达到了窗口的限制。在LAN内当然没有问题,因为我们不需要考虑发送端和接收端之间可能存在的多个路由和链路,但是如果情况放在WAN内这显然就不够稳妥了。多个分组的发出在经过一些中间路由的时候可能需要被缓存,发送端不受限制的发送很可能会耗尽存储器的空间。
最理想的情况应当是发送端发送数据的速率和接收到确认的速率保持一致(更快只会因为接收端或者中间路由无法处理而丢包)。为了探测出未知网络环境下合理的发送速率,TCP引入了慢启动算法和拥塞窗口(congestion window)概念。
所谓慢启动,指的是发送端首先会发送一个分组,等待接收端的确认。在收到确认之后拥塞窗口会从初始的1个分组大小增加到2个。再次收到确认之后拥塞窗口会拓展为4个分组大小。以此类推,在出现避让之前拥塞窗口是以指数级别增长的。
这里需要强调的是两点:
TCP发送的分组同时受通告窗口和拥塞窗口限制,两者取较小的一个值。
虽然拥塞窗口和通告窗口一样是以字节为单位,但拥塞窗口通常是分组大小的整倍数。我们在描述拥塞窗口时,会以单个分组大小作为单位1,这样方便我们描述它的增长过程。
慢启动算法实质模拟的是一个试探的过程。它在每次发送数据被确认之后都会拓展拥塞窗口来试探传输速率的极限,指数增长的方式让拥塞窗口虽然初始数值很小,但增长确是爆炸式的,拥塞窗口会很快突破网络的极限,导致中间路由丢弃分组。当丢包发生时,发送方会被通知拥塞窗口开的过大,需要作出修改。
为什么考虑的是中间路由丢弃分组而不是接收方缓冲区空间不足? 因为发送的分组的大小同时受通告窗口和拥塞窗口限制。
TCP的流量控制依赖于丢包这个条件,TCP需要根据是否丢包来决定扩大发送分组还是减少。但问题在于TCP对丢包的实际情况了解的并不全面,实际TCP是不知道丢包的真正原因的!TCP认为丢包就是网络传输出现了拥堵,所以慢启动算法里出现丢包会让拥塞窗口做指数级的避让。也许这个假设在TCP发明的当时是成立的,但现在很多情况比如无限网络中这个假设成了TCP的一个缺陷。例如信号干扰或者乱序误判都可能让发送方认为丢包,但这种情况下避让是完全没有必要的。
有关超时和重传的部分,受限于本文的篇幅会在下一篇展开。这里就不做赘述。现在针对TCP慢启动算法的缺陷也提出了解决方案(Google BBR算法),这个内容我们会留在后续介绍TCP改进的文章里细说。
带宽时延乘积
日常生活里,有时候会听到朋友抱怨网速太慢
" 这个小水管滴答滴答受不了了"
这确实是一个非常有趣的比喻。我们说TCP是一个无边界的字节流传输协议,通信两端存在一个虚拟的管道,数据在里面静静的流淌。但是这个虚拟的管道要如何去描述它?理解了这个问题之后相信会对你TCP的学习有很大帮助。
假设我们用一个矩形来描述时间t内传输的数据S
如果t无限小,那么S就会收缩成一条线,我们可以认为这条线的高度就是管道瞬时的传输速率,我们称之为带宽
之前我们提到过一个概念往返时延RTT(round-trip time) ,用以描述数据发出到被确认的时间。那么在带宽为v的管道上经过时间RTT传输的数据就是整个管道的容量
我们称之为带宽时延乘积,公式如下
带宽时延乘积(capacity)[bit] = 带宽(bandwidth)[b/s] x 往返时延RTT(round-trip time) [s]
现在让我们考虑下图的这种情况
这种情况非常普遍,局域网内的主机将数据发往处于另一个局域网的主机,需要经过相对低速的广域网。我们可以明显看到瓶颈出现在路由器R1这里:R1左侧的带宽明显大于右侧,当输入速率大于输出速率时,就会拥塞。路由器R2则没有问题,因为它是从低速的WAN内向LAN传输数据。
需要注意的是经过路由器R 2的分组,之间的间隔和在WAN内保持一致的。如图所示t1 = t2。虽然我们图中看到的是WAN内带宽打满,但是每个分组左侧边沿所在的位置标示的才是该分组实际发出的时间。两个标示分组的矩形,它们左侧边沿之间的距离就是发出时间的间隔。
这可能有点比较难以理解,因为我们将实际数据报的传递抽象成了流。