“三次握手”和“四次挥手”是 TCP 进行传输连接所涉及到的关键步骤,即建立 TCP 连接和释放 TCP 连接。当然,在这两者之间还有“数据传输”这一环节。本文主要讨论的是 TCP 建立连接以及释放连接的过程,并讨论为什么要这么设计等相关问题。

概述

由于需要设计到许多基础的内容,例如网络体系结构、各个层次的作用、每个层次涉及到哪些协议等等,所以这里仅对 TCP 的部分进行说明。

网络层可以利用 IP 协议实现两台主机之间的通信,但真正通信的实体是在各自主机中的应用进程,即一台主机中的应用进程与另一台主机中的应用进程交换数据。IP 虽然能够把数据报文送到目的主机,但关键在于并没有交付给主机的具体应用进程。而端到端的通信才是应用进程之间的通信,才能够真正的实现数据的交换。

传输层使用的协议有 UDP 协议和 TCP 协议。下面主要介绍 TCP 协议的特点:

  • TCP 提供可靠的、面向连接的字节流服务,因此在传送数据之前需要建立连接,数据传送完之后需要释放连接;
  • 在一个 TCP 连接中,仅有通信的双方进行通信,因此不涉及广播和多播;
  • 由于实现了可靠传输,不可避免的增加了许多开销,例如确认、流量控制;
  • TCP 使用校验和、确认以及重传机制来保证可靠传输;
  • TCP 使用滑动窗口来实现流量的控制,通过动态改变窗口的大小进行拥塞控制。

下图给出应用层常用常用协议所使用的运输层熟知端口号:

image.png

TCP 报文首部格式

为了实现可靠传输,TCP 采用了面向字节流的方式。需要注意的是,TCP 在发送数据时,它是从发送缓存中取出一部分或全部字节并给其添加一个首部,使之成为TCP报文段后进行发送。因此,一个 TCP 报文段由首部数据载荷两部分构成,而 TCP 的全部功能都体现在它首部中的各个字段中。

下图给出了 TCP 的首部格式:

image.png

下图给出了详细版本:

image.png

源端口和目的端口:各占 2 个字节,源端口用于标识发送该 TCP 报文段的应用进程,目的端口用于标识接收该 TCP 报文段的应用进程。

序号:占 4 字节,由于 TCP 所传输的字节流中的每个字节都是按顺序进行编号的,因此该序列用于指出当前 TCP 报文段的数据载荷部分的第一个字节的序号。该序号的取值范围为 $[0, 2 ^ {32} - 1]$,当序号增加到最后一个以后,那么下一个序号将会从 0 开始。如下图所示:

image.png

例如,一段报文的序号字段值是 301 ,而携带的数据共有 100 字段,显然下一个报文段(如果还有的话)的数据序号应该从 401 开始。

确认号:占 4 字节,用于指出期望收到对方的下一个 TCP 报文段数据载荷部分的第一个字节的序号,同时也是对之前收到的所有数据的确认。例如,确认号为 $ n $,则表明从序号开始到序号 $n-1$ 为止的所有数据都已经正确接收,期望下一步接收序号为 $ n $ 的数据。该确认号的取值范围为 $[0, 2 ^ {32} - 1]$,当确认号增加到最后一个以后,那么下一个序号将会从 0 开始。

确认标志位 ACK:取值为 1 时确认号字段才有效,取值为 0 时确认号字段无效。TCP 规定,在建立连接以后,所有传送的 TCP 报文段都必须把 ACK 置为 1。

同步标志位 SYN:在 TCP 连接建立的时候,用于同步序号。当 SYN = 1,ACK = 0 时,表示连接请求报文,如果同意连接,则响应报文中应设置 SYN = 1,ACK = 1。

终止标志位 FIN:用于释放 TCP 连接。当 FIN = 1 时,表示此报文的发送方的数据已经发送完毕,并且要求释放。

复位标志位 RST:用来复位 TCP 连接。当 RST = 1 时,表示 TCP 连接出现了错误,必须释放连接,然后再重新建立连接。此外, RST = 1 还可以用来拒绝一个非法的报文段或拒绝打开一个 TCP 连接。

推送标志位 PSH:接收方的 TCP 收到该标志位为 1 的报文段后会尽快上交应用进程,而不必等到接收缓存都填满后再向上交付。

紧急标志位 URG 和紧急指针字段

  • 紧急标志位 URG:取值为 1 时,紧急指针字段有效;取值为 0 时,紧急指针字段无效。
  • 紧急指针:占 2 字节,用来表示紧急数据的长度。

也就是说,当发送方有紧急数据时,可将紧急数据插队到发送缓存的最前面,并立即封装到一个 TCP 报文段中进行发送。紧急指针会指出本报文段数据载荷部分包含了多长的紧急数据,紧急数据之后是普通数据。

数据偏移:占 4 位,用于指出 TCP 报文段的数据载荷部分的起始处距离 TCP 报文段的起始处有多远。这个字段实际上指出了 TCP 报文段的首部长度。

  • 如果首部固定长度为 20 字节,则数据偏移字段的最小值为 $ (0101)_{2} $;
  • 如果首部固定长度为 60 字节,则数据偏移字段的最大值为 $ (1111)_{2} $。

如下图所示:

image.png

保留字段:占 6 位,保留为今后使用,但目前应置为 0。

窗口:占 2 字节,用于指出发送本报文段的一方的接收窗口。接收方通过窗口值让发送方设置其发送窗口大小。也就是说,以接收方的接收能力来控制发送方的发送能力,即流量控制。

校验和:占 2 字节,检查范围包括 TCP 报文段的首部和数据载荷两部分。在计算校验和时,要在 TCP 报文段的前面加上 12 字节的伪首部。

选项部分:长度可变,定义了如下可选参数:

  • 最大报文段长度 MSS 选项:TCP 报文段数据载荷部分的最大长度;
  • 窗口扩大选项:用于扩大窗口,提高吞吐率;
  • 时间戳选项:一方面用于计算往返时间 RTT,另一方面用于处理序号超范围的情况,又称为防止序号绕回 PAWS;
  • 选择确认选项:用来实现选择确认功能。

填充:由于选项部分的长度是可变的,因此使用填充来确保报文段的首部能被 4 整除。因为数据偏移字段(也就是首部长度字段)是以 4 字节为单位的。

三次握手(建立连接)

TCP 的建立连接需要解决以下三个问题:

  • 使 TCP 双方能够确认并知道对方的存在;
  • 使 TCP 双方能够协商一些参数,如最大窗口值、是否使用窗口扩大选项和时间戳选项以及服务质量等;
  • 使 TCP 双方能够对运输的实体资源(如缓存大小、连接表中的项目等)进行分配。

这里需要理解的是:什么是连接

连接即用于保证可靠性和流控制机制的信息,包括 Socket、序列号以及窗口大小。TCP 把连接作为最基本的对象,每一条 TCP 连接都有两个端点,被称为套接字(Socket),它是由 IP 地址与端口号组成的,例如套接字 192.168.4.16:80。窗口大小用于进行流量控制,序列号用于追踪发送方的数据包序列,接收方可以通过序列号向发送方确认某个数据包的成功接收。

下面详细地介绍建立连接的流程:

1. 假设有两台主机,其中一台主机中的某个应用程序进程主动发起 TCP 连接建立,称为 TCP 客户端。另一台主机中被动等待 TCP 连接建立的应用进程,称为 TCP 服务器。“握手”需要在 TCP 客户端和服务器之间交换三个 TCP 报文段。如下所示:

image.png

2. 最初,两端的 TCP 进程都处于关闭状态。一开始,TCP 服务器进程首先创建传输控制块(TCB),用来存储 TCP 连接中的一些重要信息,例如 TCP 连接表、指向发送和接收缓存的指针、指向重传队列的指针、当前的发送序号和接收序号等等。之后,就准备接受 TCP 客户端进程的连接请求。此时,TCP 服务器进程就进入了监听状态,等待 TCP 客户端进程的连接请求。如下所示:

image.png

3. TCP 服务器进程是被动等待来自 TCP 客户端进程的连接请求,而不是主动发起,因此称为被动打开连接。除此之外,TCP 客户端进程也首先需要创建传输控制块(TCB),用于提供连接过程中的一些信息。如下所示:

image.png

4. 在建立 TCP 连接时,客户端向 TCP 服务器进程主动发送 TCP 连接请求报文段,TCP 连接请求报文段首部中的同步位 SYN 被置为 1,表明这是一个 TCP 连接请求报文段,序号字段 seq 被置为一个初始值 x,作为 TCP 客户端进程所选择的初始序号,然后客户端进入同步已发送状态。如下所示:

注意:TCP 规定 SYN 被置为 1 的报文段不能携带数据,但要消耗掉一个序号。由于 TCP 连接建立是由 TCP 客户端进程主动发起的,因此称为主动打开连接

image.png

5. TCP 服务器进程收到 TCP 连接请求报文段后,如果同意建立连接,则向 TCP 客户端进程发送 TCP 连接请求确认报文段,并进入同步已接收状态。其中,同步位 SYN 和确认位 ACK 都置为 1,表明这是一个 TCP 连接请求确认报文段。序号字段 seq 被置为一个初始值 y 作为 TCP 服务器进程所选择的初始序号,确认号字段 ack 被置为 x+1,这是对 TCP 客户端进程所选择的初始序号的确认。如下所示:

注意:这个报文段也不能携带数据,因为它是 SYN 被置为 1 的报文段,但同样需要消耗一个序号。

  • 小写的 ack 代表 TCP 首部的确认号 Acknowledge number,缩写 ack,这是对上一个包的序号进行确认的号,ack = seq + 1;
  • 大写的 ACK 代表 TCP 首部的标志位,用于标志 TCP 包是否对上一个包进行了确认操作。如果确认了,则把 ACK 标志位设置成 1。

image.png

6. TCP 客户端进程收到 TCP 连接请求确认报文段后,还要向 TCP 服务器进程发送一个普通的 TCP 确认报文段,并进入连接已建立状态。该报文段首部中的确认位 ACK 被置为 1,表明这是一个普通的 TCP 确认报文段。序号字段 seq 被置为 x+1,这是因为 TCP 客户端进程发送的第一个 TCP 报文段的序号为 x,并且不携带数据,因此第二个报文段的序号为 x+1。如下所示:

注意:TCP 规定普通的 TCP 确认报文段可以携带数据,但如果不携带数据,则不消耗序列。在这种情况下,所发送的下一个数据报文段的序号仍是 x+1。确认号字段 ack 被置为 y+1, 这是对 TCP 服务器进程所选择的初始序号的确认。

image.png

7. TCP 服务器进程收到该确认报文段后也进入连接已建立状态,现在 TCP 双方都进入了连接已建立状态,它们可以基于已建立好的 TCP 连接进行可靠的数据传输了。如下所示:

image.png

问题:为什么 TCP 客户端进程最后还要发送一个普通的 TCP 确认报文段呢?这是否多余?或者说,能够使用“两报文握手”建立连接呢

不多余,也不能使用“两报文握手”。我们通过上图进行分析。

假设 TCP 客户端进程发出一个 TCP 连接请求报文段,但该报文段在某些网络节点长时间滞留了。这必然会造成该报文段的超时重传。假设超时重传的报文段被 TCP 服务器进程正常接收,TCP 服务器进程给 TCP 客户端进程发送一个 TCP 连接请求确认报文段,并进入连接已建立状态

注意:由于使用“两报文握手”,因此 TCP 服务器进程发送完 TCP 连接请求确认报文段后,进入的是连接已建立状态,而不像“三报文握手”那样进入同步已接收状态,然后等待 TCP 客户端进程发来针对 TCP 连接请求确认报文段的普通确认报文段

TCP 客户端进程在收到了 TCP 连接请求确认报文段后,进入连接已建立状态,但不会给 TCP 服务器进程发送针对该报文段的普通确认报文段(因为这是“三报文握手需要做的事”)。现在,TCP 双方都处于连接已建立状态,它们可以相互传输数据。之后可以通过“四报文挥手”来释放连接,TCP 双方都进入了关闭状态

接下来注意:在一段时间后,之前滞留在网络中的那个失效的 TCP 连接请求报文段此时到达了 TCP 服务器进程,则 TCP 服务器进程就会误以为这是 TCP 客户端进程又发起了一个新的 TCP 连接请求,于是给 TCP 客户端进程发送 TCP 连接请求确认报文段,并进入连接已建立状态

该报文段到达 TCP 客户端进程后,由于 TCP 客户端进程并没有发起新的 TCP 连接请求,并且当前处于关闭状态,因此不予理会该报文段。但 TCP 服务器进程已进入了连接已建立状态,它认为新的 TCP 连接已经建立好了,并一直等待 TCP 客户端进程发来数据,这将白白浪费 TCP 服务器进程所在主机的很多资源。

综上所述:发送针对 TCP 连接请求的确认的确认,也就是客户端最后还要发送一个普通的 TCP 确认报文段并不多余,这是为了防止已失效的连接请求报文段突然又回传到了 TCP 服务器,避免服务器一直等待而浪费资源

四次挥手(释放连接)

当客户端与服务器之间的数据传输完毕后,就需要进行释放连接,而需要通过四次操作才能释放之前建立的连接。流程如下:

1. 数据传输结束后,TCP 双方都可以释放连接。现在 TCP 客户端进程和 TCP 服务端进程都处于连接已建立状态。如下所示:

image.png

2. 假设使用 TCP 客户端进程的应用进程通知其主动关闭 TCP 连接,则 TCP 客户端进程会发送 TCP 连接释放报文段,并进入终止等待 1 状态。该报文段首部中的终止位 FIN 和确认位 ACK 的值都被置为 1,表明这是一个 TCP 连接释放报文段,同时也对之前收到的报文段进行确认。序号 seq 字段的值置为 u,它等于 TCP 客户端进程之前已传送过的、数据的最后一个字节的序号加 1。如下所示:

注意:TCP 规定终止位 FIN 等于 1 的报文的段即使不携带数据,也要消耗掉一个序号。确认号 ack 字段的值置为 v,它等于 TCP 客户端进程之前已收到的、数据的最后一个字节的序号加 1。

image.png

3. TCP 服务器进程收到 TCP 连接释放报文段后,会发送一个普通的 TCP 确认报文段并进入关闭等待状态。该报文段首部中的确认位 ACK 的值被置为 1,表明这是一个普通的 TCP 确认报文段。序号 seq 被置为 v,它等于 TCP 服务器进程之前已传送过的数据的最后一个字节的序号加 1。这也与之前收到的 TCP 连接释放报文段中的确认号匹配。确认号 ack 字段被置为 u+1,这是对 TCP 连接释放报文段的确认。如下所示:

image.png

4. TCP 服务器进程这时应通知高层应用进程:TCP 客户进程要断开自己的 TCP 连接了。此时,从 TCP 客户端进程到 TCP 服务端进程这个方向的连接就释放了。这时的 TCP 连接属于半关闭状态,也就是 TCP 客户端进程已经没有数据要发送了,但 TCP 服务器进程如果还有数据要发送,TCP 客户端进程仍要接收。也就是说,从 TCP 服务器进程到 TCP 客户端进程这个方向的连接并未关闭。 这个状态可能会持续一段时间。如下所示:

image.png

5. TCP 客户端进程收到 TCP 确认报文段后就进入终止等待 2 状态,等待 TCP 服务器进程发出的 TCP 连接释放报文段。如果使用 TCP 服务器进程的应用进程已经没有数据要发送了,应用进程就通知其 TCP 服务器进程释放连接。如下图所示:

由于 TCP 连接的释放是由 TCP 客户端进程主动发起的,因此 TCP 服务端进程对 TCP 连接的释放称为被动关闭连接

image.png

6. TCP 服务器进程发送 TCP 连接释放报文段并进入最后确认状态,其中终止位 FIN 和确认位 ACK 的值被置为 1,表明这是一个 TCP 连接释放报文段,同时也对之前收到的报文段进行确认。假定序号 seq 字段的值为 w,这是因为在半关闭状态下,TCP 服务器进程可能又发送了一些数据。确认号 ack 字段的值为 u+1,这是对之前收到的 TCP 连接释放报文段的重复确认。如下所示:

image.png

7. TCP 客户进程收到 TCP 连接释放报文段后,必须针对该报文段发送普通的 TCP 确认报文段,之后进入时间等待状态。该报文段首部中的确认位 ACK 的值被置为 1,表明这是一个普通的 TCP 确认报文段。

序号 seq 字段的值设置为 u+1,这是因为 TCP 客户端进程之前发送的 TCP 连接释放报文段虽然不能携带数据,但要消耗掉一个序号。确认号 ack 字段的值被置为 w+1,这是对所收到的 TCP 连接释放报文段的确认。

TCP 服务器进程在收到该报文段后就进入关闭状态。而 TCP 客户端进程还要经过 2MSL(两倍的 MSL)后才能进入关闭状态。

MSL:最长报文段寿命,建议为 2 分钟。也就是说 TCP 客户端进程进入时间等待状态后,还要经过 4 分钟才能进入关闭状态。这是从工程上来考虑的。对于现在的网络,MSL 取值为 2 分钟可能太长了,因此 TCP 允许不同的实现可根据具体情况而使用更小的 MSL 值。

问题:TCP 客户端进程在发送完最后一个确认报文段后, 为什么不直接进入关闭状态,而是要进入等待时间状态,2MSL 后才进入关闭状态,这是否有必要呢

假设以下场景,TCP 服务器发送 TCP 连接释放报文段后进入最后确认状态,如下所示:

image.png

TCP 客户端进程收到该报文段后,发送普通的 TCP 确认报文段,并进入关闭状态而不是进入时间等待状态。如果该 TCP 确认报文段丢失了,这必然会造成 TCP 服务器进程对之前发送的 TCP 连接释放报文段的超时重传,并仍处于最后确认状态

image.png

重点在于:重传的 TCP 连接释放报文段到达 TCP 客户端进程,由于 TCP 客户端进程处于关闭状态,因此不会理会该报文段,这必然会造成 TCP 服务器进程反复重传 TCP 连接释放报文段,并一直处于最后确认状态而无法进入关闭状态。

因此,时间等待状态以及处于该状态 2MSL 的时长,可以确保 TCP 服务器进程可以收到最后一个 TCP 确认报文段而进入关闭状态

image.png

综上所述:客户端 2MSL 以后才进入关闭状态,一是:保证客户端发送的最会一个 ACK 报文能够到达服务器,因为这个 ACK 报文可能丢失。站在服务器的角度来看,我(服务器)已经发送了 FIN+ACK 报文请求断开了,客户端还没有给我回应,应该是我发送的请求释放报文客户端没有收到,于是我再重新发送一次。而客户端能够在 2MSL 时间段内收到这个重传的报文,接着给出回应报文,并且会重启 2MSL 计时器。

二是:防止类似与“三次握手”中提到的“已经失效的连接请求报文段”出现在本连接中。TCP 客户端进程在发送完最后一个 TCP 确认报文段后,再经过 2MSL 时长,就可以使本次连接持续时间内所产生的所有报文段都从网络中消失,这样就可以在下一个新的 TCP 连接中,不会出现旧连接中的报文段。

问题:为什么建立连接是三次握手,关闭连接确是四次挥手呢

因为在建立连接时,服务器在监听(LISTEN)状态下,收到建立连接请求的 SYN 报文后,把 ACK 和 SYN 放在一个报文里发送给客户端。

而关闭连接时,服务器收到客户端的 FIN 报文时,仅仅表示客户端不再发送数据了,但是还能接受数据。而服务器也未必都将全部的数据发送给对方了,所以服务器可以立即关闭,也可以发送一些数据给客户端后,再发送 FIN 报文交给客户端表示同意现在关闭连接。因此,服务器的 ACK 和 FIN 一般都会分开发送,从而导致多了一次。

问题:如果已经建立了连接,但是客户端突然出现了故障怎么办

如果出现了此情况,那么应该采取措施使 TCP 服务器进程不再白白的等下去。TCP 还有一个保活计时器,TCP 服务器进程每收到一次 TCP 客户端进程的数据,就重新设置并启动保活计时器(2 小时定时)。

如果保活计时器定时周期内没有收到 TCP 客户端进程发来的数据,则当保活计时器到时后,TCP 服务器进程就向 TCP 客户端进程发送一个探测报文段,以后每隔 75 秒发送一次。如果连续发送 10 个探测报文段后仍无 TCP 客户端进程响应,则 TCP 服务器进程就认为 TCP 客户端进程所在主机出现了故障,紧接着就关闭这个连接。

参考