姜蜀黍讲TCP协议之第一回

TCP协议作为整个OSI协议栈中最重要的协议之一,其复杂度也是数一数二的。由于TCP协议需要在不可靠的IP协议基础上的实现可靠的有顺序的传输,自然就决定了TCP提供了很多复杂的特性来实现这一目标。笔者从大学开始陆陆续续接触了TCP相关知识,但是始终没能形成一个知识体系,所以想借助这篇文章对整个TCP协议有一个更加全面的认识。

TCP提供了数据块切分、数据报文接收成功确认、超时重传、首部和数据的校验、报文片段的重排序、拥塞控制等机制。关于TCP连接的细节以及可靠性保障机制会在后文中做详细介绍。

TCP首部

TCP协议是基于IP协议的,所以最终数据传送还是以IP数据包为单位进行的。如果一个文本字节流很长,TCP将整个文本字节流装入IP数据包中,则有可能会超过MTU。所以,TCP会按照自己规定长度的对需要传送的文本字节流进行拆分,然后再装入IP数据包中,被拆分后的一个个TCP报文被称为片段(segment)。TCP片段会分为头部(header)和数据(playload)两个部分组成。这样,整个文本流会被拆分很多个TCP片段,TCP为会每个片段分配一个序号,这样就能保证在接收端收到的乱序片段重新按照序列号进行排序组装。

2

下图显示的是TCP首部的详细内容,如果不算Options字段,TCP首部为20个字节:

1

对TCP首部格式中几个重要的字段做说明:

  1. 源端口(source port)和目的端口(Destination port),用于指明应用程序访问的目的端口和源端口;
  2. 序列号(sequence number),用来标识在TCP传输过程中的文本字节流的序号
  3. 确认序列号(Acknowledgement number),确认序列号指的是接收端希望接收到的下一个片段里面的字节编号,由于序列号会对每个传输的字节编号,所以确认序列号就是上一个被成功接收到数据序列号加1。只有在TCP FLAG字段里面的ACK标志位被置为1的情况下才有效,表示接收端希望接收到下一个片段的序列号。
  4. window size字段主要是用于进行TCP流量控制,后面会做详细叙述;
  5. ACK、SYN、FIN等字段称之为TCP Flag,主要是用于标注TCP片段的类型和所处状态;

TCP连接

TCP是个面向连接的协议,所谓“面向连接”就是指使用TCP的两个应用之间想要进行数据传递首先要建立一个TCP连接,下图展示了TCP的建立连接、传送数据、断开连接的三个部分:

10173293-9f31f19a07c2e821

建立连接

TCP建立连接的过程其实就是让双方交换彼此的初始序列号(ISN,Initial Sequence Number)。为什么是交换彼此的序列号?因为在TCP是全双工通信,两边都可以是数据的发送方和接收方。初始序列号必须是双方随机生成的,不能每次在建立连接后使用硬编码的方式(比如初始值都为1)来作为ISN。不能使用硬编码有两个原因,第一个是从安全上考虑,避免攻击者可以很简单的就能猜测出接下来要发送片段的序列号,第二个是假如一个连接断开后,重新建立连接后的ISN又变成1,这样很容易和之前那次连接已经成功发送到接收方的segment搞混。所在在贵方规范RFC973中规定的ISN生成方法是:数据发送方会利用一个假的时钟来进行ISN生成,这个时钟会每4对ISN进行+1操作,由于Sequence number最大值可以达到2^32,所以ISN达到2^32之后,又从0开始计数,以此可以推算出:要想得到一个重复的ISN,需要等待的时间是4.55小时。所以只要一个片段的最长存活时间(MSL,Maximum Segment Lifetime)不超过4.55小时,就不会出现在在发送方和接收方间两个不同segment的ISN相同的情况。

建立连接的过过程就是经典的TCP三次握手(three-way handshaking)。假设客户端和服务端的ISN我们这里分别用ISN(c)和ISN(s)代替来描述下三次握手的过程:

  1. 首先客户端发送一个ISN(c)和SYN的TCP片段到服务端。
  2. 其次服务端接收到该片段后将发送将ACK置为1以及带有ISN(s)的TCP片段给客户端,在TCP首部格式中我们有介绍,一旦ACK标志位被置为1,那么acknowledge number将被启用,那服务端回复的这个片段里面的acknowledge number的值就等于ISN(c)+1,也就是服务端希望接收到的客户端的下一个文本字节的编号。
  3. 最后,客户端收到服务端片段后,获取到服务端的ISN(s),再次回复一个ACK被置为1的TCP片段给服务端,该片段由于占用了一个序列号,所以其seq的值便是ISN(c)+1,然后ACK的值就是ISN(s)+1。表明自己已接受到自己希望接收到服务端下一个文本字节片段的ACK。这里需要注意一下,一个纯粹的SCK回复片段不传送文本流,所以不消耗序列号。

连接建立后,双方就可以进行TCP的传输方式进行通信了

连接终止

TCP断开连接的过程需要进行四次报文片段交互。其实仔细看交互过程,你会发现,其实断开连接的第二步(服务端发送ACK)第三步(服务端发送断开连接的片段,也就是FIN被置为1的片段)在建立连接时,是被放到一个片段里面进行传输的,那为什么断开连接需要分成两个独立的步骤呢?这是因为TCP连接支持半关闭状态(half-close)。也就是说TCP连接只关闭了一个方向的传输,成为一个单向链接。所以当断开连接发起方发起断开连接的请求后,另外一方可以只发送ACK而不发送己方连接断开的请求,这样导致第二步和第三步必须要分开进行。

TCP状态机

当客户端和服务端建立和断开连接时需要分别经历三次握手和四次握手协议才能完成整个交互,所以客户端和服务端在这个过程中需要经历多个状态的转换,TCP状态机就是用来对每个阶段的状态进行表示的,请看下图:

4

大家通过netstat命令查看端口的网络连接状态时,经常应该看到上面的某几个状态值。这里需要重点说一下在断开连接的最后一步,客户端接收到了服务端发过来的FIN报文片段后,客户端随即回复一个ACK报文片段。但是为什么客户端回复发送完成后其状态不直接变为closed状态而是先变成Time wait状态,然后经过一个2MSL的超时时间后再变成closed状态呢?

这里我们先解释一下什么是MSL,MSL就是maximum segment lifetime缩写,其指的事一个TCP报文片段在网络中最大的存活时间,2MSL就是一方发送一个报文片段然后再收到接收方对接收到这个报文片段后的恢复所需要的最大时间。这里我们回到上面那个问题,当客户端发出最后一个确认的报文时,这个报文有可能因为某些原因丢失掉,但,Server端没有收到ACK,将不断重复发送FIN片段。所以,客户端发送ACK片段以后并不能马上关闭,若在2MSL的时间内没有再次收到服务端的FIN片段,那我们就可以认为服务端已经成功接收,这个时候在进入关闭状态。

TCP滑窗

TCP的数据发送需要经历发送片段->接收ACK这样的过程,但如果每次都处理一个片段,那效率会低得令人发指。所以TCP在真实环境的发送方和接收方可以同时发送和接收多个报文片段。为了实现这一目标,TCP提出滑窗的概念,想要解释滑窗,先要说一下什么是TCP窗口:所谓TCP窗口其实就是接收方和发送方的一段缓存,这段缓存可以处理一定大小连续的TCP片段。由于TCP报文最终要封装到IP协议中进行发送,但是IP报文不无序和不可靠的。这样发送方发送的多个片段可能会按照乱序的方式被接收方所接收,有了TCP窗口,接收方就可以将这些乱序片段接收下来然后按照其序列号重新组装后放入缓存。

再说滑窗,滑窗就是TCP窗口可以随着接收或者发送的TCP片段,不断的进行接收或者发送片段区域的调整,如果把文本字节流看成一条线,那么TCP窗口就会在这条线上从左至右的滑动,为了方便说明,下面以图例来进行讲解:

4

在场景1中,接收方和发送方的TCP窗口大小为5,所以可以同时进行5个片段的发送和接收。我们可以看到此刻正在发送和接收的片段序号是[6,7,8,9,10]。其中片段[8,9,10]都已经成功被接收方接收,发送方也收到了这3个片段的ACK,但是片段6虽然已被接收方接收,但是还没被发送方确认,而片段7则是还未并接收。所以滑窗开始地方仍然保持在片段6。这里需要注意下,如果接收方在此事收到片段11,是会被丢弃的。因为该片段并在滑窗期待接收的片段范围内,属于乱序片段。

5

在场景2里面,片段也成功被接收和确认,则发送方的滑窗向右滑动,以7作为起点,而接收方由于已经成功的完成[6,7,8,9,10]的接收,滑窗向右滑动至片段11作为起点,开始等待[11,12,13,14,15]片段的接收。

6

在场景3中,片段7的ACK也被发送方接收,则发送方的滑窗也向右滑动至片段11作为开始,开始进行[11,12,13,14,15]片段的发送。

最后需要说明下,在真实的场景中,滑窗的大小并不是恒定不变的,而是会根据整个网络的情况做动态调整,这个就是TCP拥塞控制,后面我们会仔细讲解。

累计ACK

在上面滑窗的讲解里面,我们是假设的事接收方需要收到所有片段的ACK,但是TCP为了更加提高性能,并不是所有的片段都会回复ACK:当接收方接收到多个片段后,则只回复最后一个片段的ACK表示这个片段以前的所有片段都已成功被接收。例如下图:

6

当片段7被接收成功后,接收方将会服务一个ACK片段,并且ACK=11,发送方收到ACK回复后,就会意识到片段10以及以前的所有片段都已经被成功接收。这个时候发送方的滑窗开始向右滑动至11作为左边缘起点片段。

这里需要说明下,以上只是为了解释清楚滑窗的具体原理,但是滑窗在TCP实际的环境中的发送方滑窗结构还要更加复杂一些,并且实际环境中是以字节为单位来划定滑窗,而不是上面例子里面的以片段为单位。我们看下真实的发送方滑窗的数据结构:

8

流量控制

TCP协议会根据情况自动改变滑窗大小,以实现流量控制。流量控制(flow control)是指接收方将advertised window的大小通知给发送方,从而指导发送方修改offered window的大小。接收方将该信息放在TCP头部的window size区域,下面来看下我从TCP/IP guide网站上找到的一个发送方和接收方之间不断的变换滑窗大小,最后直接将客户端滑窗置为0的例子:

9

零窗口

当接收方处理速度比发送方发送速度慢的时候,就可能出现消息堆积的情况,那么接收方会通知将发送方的滑窗大小置为0,这个时候发送方就暂停发送片段。那么发送方怎么知道接收方重新恢复可以接受新的片段呢。TCP采用零窗口探测(Zero Window Probe)技术,所谓零窗口探测指的就是,发送方在被窗口置为0后会持续发送一个带有1byte文本流的TCP片段,然后通过服务端的ACK将最新的window size获取到,一般实现的时候都会探测三次,超过三次若ACK回复的window size仍然为0,则部分实现会直接将连接断开。

所以我们可以利用动态调整窗口大小来实现对TCP发送的快慢控制,也就是流量控制。

白痴窗口综合症

白痴窗口综合症(silly window Syndrome)是指在接收方接收速度较慢的情况,发送方对的窗口会逐渐减小。当接收方腾出较小的一个空间时,比如几个字节。则会重新通知发送方进行片段发送,发送方会按照接收方同步过来的窗口大小发送一个含有少量数据的报文。这其实只非常浪费网络带宽资源的一种表现,因为TCP头部+IP报文固定大小都有40个字节,但是我们真正需要传输的报文才几个字节。所以,为了避免出现白痴窗口综合症,TCP提供了两种解决办法:

  1. 当发送方会送过来的片段会导致接收方的窗口小于某个值的时候,直接通知将发送方的窗口置为0,这样将发送方窗口暂时关闭,然后直到接收方窗口达到某个最值值,才会通知发送方打开窗口重新进行数据发送。
  2. TCP有个提供了注明的Nagle算法,将发送方的小片段进行粘包成一个大一点的片段后再发送到接收端,但是这会对数据的实时性造成影响,如果是时延要求较低的场景要将nagle算法手动关闭(该算法模块开启)。

TCP重传

TCP的一大特点就是可靠性,但是其建立在一个不可靠的IP协议之上,所以当TCP报文丢失时就只有通过重传来保证可靠了,这里假设要接收方接收到1,2,3,5四个片段,但是4片段却未成功接收,这个时候接收端就会一直等待片段4。但是发送端又如何知道片段4未收到呢?

超时重传

TCP提供超时重传机制来对缺失的片段进行重传:发送方在发送一个片段后会开启一个计时器,但是由于接收端一直未接收到片段4,所以片段4的ACK一直未收到,直到计时器超时,这个时候发送方就会认为片段4已经丢失,重新发送一个片段4,并重新开启计时器,直到收到ACK。

但是这里的核心问题是,发送方的超时时间(RTO)设置为多长时间比较合适?我们知道发送一个片段的完整过程需要经历两端时间:1. 发送方发送片段直到被接收方接收, 2. 接收方发送ACK片段直到被发送方接收。整个过程的耗时TCP称之为往返时间(RTT, round trip time),RTT并不是一个硬编码,在不同的网络情况下RTT时间有很大区别,TCP会收集各个数据片段RTT的时间作为样本,叫做采样RTT(SRTT, sampling round trip time),并且进行统计分析后l来作为RTO。具体算法实现有些不同,主要是利用每次数据传送后收集到RTT样本来计算平均值和标准差,然后RTO等于SRTTd的平均值+四倍SRTT标准差。

快速重传

超时重传是以时间为维度来进行重传,但是TCP还提供了一种以事件为驱动的重传机制,也就是可以在超时时间内就进行重传,还是以前面的场景为例:接收方接收到1,2,3,5四个片段,但是4片段却未成功接收。这个时候接收方虽然受到了片段5,但是回复的ACK的片段里面的回复号仍然是3+1。此后,如果接收方收到的仍然不是4,则依然ACK依然回复4,当发送方收到3个ACK=4的回复时,发送方就会认为片段4始终没有被成功接收,此时虽然片段4的计时器仍然还没有超时,但仍然会立即重新发送片段4,这就是所谓的TCP快速重传。

-------------本文到此结束,感谢您的阅读-------------