背景

每次我面试初中级开发者的时候,我都很喜欢在面试中问一个看似很基础的问题:请你说一说 TCP 的握手和挥手的过程。大多数开发者都能很顺利地把协议的「形式」给表达出来,但是极少人(包括一些资深工程师)能够清晰地说出协议设计和实际使用的细节:

  • 为什么是三次握手和四次挥手而不是其他数值 ?
  • SYN 包选择序号有什么需要注意的地方吗 ?
  • TCP 连接建立后的两个主机将会分别保存对方的什么信息 ?
  • TCP 握手过程中会有哪些可见的开销 ?
  • TCP 如何应对 SYN flood ?
  • Linux socket 接口中有一个 backlog 参数,这个参数有何用处 ?
  • 为什么四次挥手过程中的 TIME_WAIT 状态需要等待 2MSL 时间 ?一般 Linux 上这个时间是多长 ?
  • 什么是 TCP 的半关闭状态 ?

等等。

我的看法是:对基础问题理解越深刻的人,技术功底就越扎实,系统设计能力就相对越靠谱。之所以这么说,是因为很多计算机相关的问题,其实来来去去就那几个基础问题,而这些基础性问题在不同的场景下被以不同的手段解决过很多遍。与其自己动手设计系统,还不如先深入理解下前人的一些设计以及设计背后所考虑的问题,这样反而效率更高。

本文尝试阐述 TCP 握手和挥手的一些细节设计。

TCP 的三次握手

完整的握手过程

一个完整的 TCP 三次握手流程如下图所示:

其中:

  1. Server 处于 LISTEN 状态,即可被连接。此时 Client 发出一个 SYN 报文,并选择一个初始序号 ISN(c)。此时称 Client 为主动开启者。TCP 规定,SYN 报文不能携带数据,但必须消耗一个序列号。Client 发出 SYN 报文之后进入 SYN_SENT 状态;

  2. Server 收到 Client 的 SYN 包之后,如同意建立连接,则向 Client 发出确认报文并进入 SYN_RCVD 状态。确认报文中 SYN 和 ACK 都设置为 1,确认号是 ISN(c)+1(即表明已经收到 Client 序号为 ISN(c) 的包),并选择 Server 的初始序列号 ISN(s)。同样地,这个报文也不能承载数据,但是要消耗一个序列号;

  3. Client 收到 Server 的确认后,再给 Server 发送一个对此报文的确认,此时自己的序列号为 ISN(c)+1,确认号是 ISN(s)+1。发出确认包号,Client 便认为此次连接已经建立,进入 ESTABLISHED 状态。当 Server 收到 Client 的确认报文后,同样进入 ESTABLISHED 状态。至此,TCP 的三次握手过程完结。TCP 规定:ACK 报文段可以携带数据,如果不携带数据则不消耗序号。 在这种情况下,下一个数据报文段的序号仍是 ISN(c)+1

TCP 握手结束后,Client 和 Server 都会建立并维护 TCB(传输控制块),从而可以「记住」对方,这也就是为什么 TCP 是面向连接的协议。

为什么是三次握手

这里有一个很有意思的问题:为什么是三次握手 ?而不是两次、四次、五次握手

其中,三次握手过程中的步骤 1 和 2 是很容易理解的:Client 向 Server 发出连接请求,Server 响应这个连接请求。为什么还需要步骤 3 ?

让我们先假设只有两次握手(即保留步骤 1 和 2,省略步骤 3),试想一个场景:Client 向 Server 发出连接请求 S1,但 S1 由于网络问题未到达 Server,于是 Client 便在发送超时后重新发送连接请求 S2。这时,连接建立成功。假设此次连接结束后,Server 又收到之前滞留于网络的 S1,于是 Server 会以为 Client 又向其重新建立了新的连接,于是发出确认报文并进入 ESTABLISHED 状态。但是,Client 此时并不会响应 Server 对 S1 的确认报文,所以 Client 并未进入 ESTABLISHED 状态,而 Server 却一直等待 Client 发送数据。这样便对 Server 的资源造成了浪费。引入三次握手便可解决上述问题。

那为什么不是四次握手,五次握手,甚至 N 次握手呢 ?Client 难道不需要等待步骤 3 发出的报文的确认吗 ?其实这也很容易理解:建立一次可靠的连接,至少需要三次交互,而资源有限,不可能无休止地对上一个交互进行确认,所以便只需要三次握手即可

其实三次握手在其他场景下也以不同的形式呈现:

  • 注册新账号:用户发出注册请求 -> 服务端发送确认邮件或者短信确认码 -> 用户回复确认邮件或填写短信验证码;

  • 网络支付:用户发出付款请求 -> 银行向用户发送短信确认码 -> 用户填写确认码并提交支付;

等等。试想一下如果新账号注册只需要两次握手,那么将导致多少僵尸账户。

备注:上述说法不完全准确,后面有空在修订一下,谨慎参考

握手过程的 ISN 生成

TCP 三次握手过程中不传输任何数据,但是每次传输至少都需要 20 字节的 IP 头部和 20 个字节的 TCP 头部,而且每次传输都需要经过经过链路和 IP 层,频繁的创建 TCP 连接累积的开销还是很可观的。所以,在实际工程使用中,会尽量避免反复创建 TCP 连接,而是创建了多条连接后反复使用。

每次 TCP 三次握手,Client 和 Server 都必须分别生成自己的初始序列号(ISN,Initial Sequence Number)。如果有人猜测出 ISN,便可以假冒 Client 或者 Server ,从而建立起恶意的 TCP 连接。所以 ISN 生成非常重要。早先时候,ISN 是一个 32 位的计数器,该数值每 4 微妙加 1。这样,ISN 就不会出现重叠的情况,但缺陷也很明显:不够随机。

现代系统通常采用半随机的方法选择 ISN。Linux 采用一个相对复杂的过程来选择 ISN。它采用基于时钟的方案,并且针对每一个连接为时钟设置随机的偏移量。随机偏移量是在连接标识(即连接四元组:{ClientIP,ClientPort,ServerIP,ServerPort})的基础上利用加密散列函数得到的。散列函数的输入每 5 分钟就会改变 1 次。

backlog 参数

backlog 直译为「积压」,其实就是待处理请求队列大小

在 Linux 2.2 之后,区分出了两个待处理队列:

  • 未完成连接队列(incomplete connection queue),即处于 SYN_RCVD 状态,可由 /proc/sys/net/ipv4/tcp_max_syn_backlog 进行控制(默认为 1000);

  • 已完成连接队列(completed connection queue),已经完成三次握手,等待应用层的接受,即处于 ESTABLISHED 状态,,可通过 /proc/sys/net/core/somaxconnlisten() 控制(默认为 128);

典型地,用这个两个队列建立连接时所交换的分组如下图所示:

当来自 Client 的 SYN 到达时,TCP 在未完成队列中创建一个新项,然后响应三次握手的第二个报文。这一项一直保留在未完成连接队列中,直到三次握手成功或者该项超时为止。如果三次握手正常完成,该项就从未完成连接队列中移到已完成连接队列的队尾。当进程调用 accept() 时,已完成连接队列中的队头项将返回给进程,如果队列未空,进程继续投入睡眠。

如果三次握手正常,则未完成连接队列中的任何一项在其中的存留时间就是一个 RTT。

SYN flood 攻击

从 TCP 三次握手的过程中可以看出,TCP 对连接的建立并无任何身份检验机制,这就使得某些恶意攻击者可大量发起 SYN 请求到目标系统,使得系统在半连接状态下分配内存(即 TCB 结构),当系统内存耗尽时,将拒绝为后续合法连接请求服务。

有几种手段可以有效缓解这一问题,比较典型的有 SYN Cache 和 SYN Cookie

SYN Cache 指的是:当收到 SYN 报文的时候,先不急着分配 TCB,而是回应一个 SYN ACK ,并在一个哈希表(Cache)中保存这种半连接信息,直到收到正确的 ACK 后才分配 TCB。但是,某些 SYN flood 会智能地回复 ACK,从而使服务端真正建立 TCP 连接,此时 SYN Cache 就很难避免此类攻击。这时就可用上 SYN Cookie 算法。

SYN Cookie 指的是:当服务端收到 SYN 后会采用如下方法来设置 ISN:

  • 令 t 为一个缓慢递增的时间戳(通常是 time() >> 6,提供 64 秒的分辨率);
  • 令 m 为 MSS;
  • 令 s 是一个加密散列函数对连接标识与 t 值的散列值;

则此时 ISN (即 SYN Cookie)为:

  • 头 5 位:t mod 32;
  • 中 3 位:m 编码后的数值;
  • 末 24 位:s 本身;

由于 m 必须用 3 位编码,所以服务器在启用了 SYN Cookie 后只能发送 8 种不同的数值。

再接收到 ACK 后,服务器可对其以下检查:

  • 根据当前时间以及 t 来检查连接是否过期;

  • 重新计算 s 来确认这是不是一个有效的 SYN Cookie;

  • 从 3 位编码中解码 m 以重建队列;

采用 SYN Cookie,当前连接的大部分信息都被编码并保存在 SYN+ACK 报文段的 ISN 中,则 Server 不需要为进入的连接请求分配任何存储资源,只有当 SYN+ACK 报文段被确认后才会真正分配内存。

但该方法有两个缺陷:

  • 需要对 MSS 进行编码,这就导致无法使用任意大小的 MSS;

  • 计数器会回绕,连接建立周期会因周期非常长(大于 64 秒)而无法正常工作;

因此 SYN cookie 很多时候不是一种默认策略。

TCP 的四次挥手

完整的握手过程

与 TCP 三次握手相比,四次握手就显得相对要复杂不少。

一个完整的 TCP 四次挥手的过程如下图所示:

其中:

  1. TCP 连接的主动关闭者 Client 向 Server 发送一个 FIN 报文,其中还包含了一个 ACK 段用于确认对方最近一次发来的数据,标记为 L。Client 发送完 FIN 报文后进入 FIN_WAIT_1 状态;

  2. TCP 连接的被动关闭者 Server 将 K 的数值加 1 作为响应的 ACK,表明它已经成功接收到主动关闭者发送的 FIN 并进入 CLOSE_WAIT 状态。此时,上层的应用程序会被告知连接的另一端已经提出关闭的请求。这是 TCP 连接处于半关闭(half-close)状态,即 Client 不再会向 Server 发送数据,但是 Server 仍可向 Client 发送数据,而且 Client 也会对此数据做出确认;

  3. 当 Server 已经没有任何数据要发给 Client 后,Server 会向 Client 发出 FIN 报文,受半关闭状态发送数据的影响,该报文段的序列号为 M(如果半关闭状态没有发送数据,M == L),确认号为 K+1,此时 Server 进入 LAST_ACK 状态;

  4. Client 收到 Server 的 FIN 报文后,发出确认报文并进入 TIME_WAIT 状态。此时 Client 端的 TCP 连接还没有被释放,必须经过 TIME-WAIT timer 设置的时间 2MSL 后,才进入 CLOSED 状态。Server 一收到 Client 的确认报文就进入 CLOSED 状态,即 Server 端结束 TCP 连接要比 Client 要早一些。

为什么要等待 2MSL 时间

时间 MSL 称为最长报文段寿命(Maximum Segment Lifetime),它代表任何报文段在被丢弃前在网络中允许存在的最长时间,一般可以是 1 分钟或者 30 秒(起初 RFC793 建议是 2 分钟)。在 Linux 上,可以通过修改 /proc/sys/net/ipv4/tcp_fin_timeout 来配置 MSL。

之所以要让 Client 等待 2MSL 才进入 CLOSED 状态,有以下原因:

  • 为了保证 Client 发送的最后一个 ACK 报文能够到达 B。当该 ACK 报文丢失,将使得处于 LAST_ACK 状态的 Server 收不到已发送 FIN+ACK 报文的确认,从而触发 Server 超时重传这个报文,而 A 能在 2MSL 时间内收到这个重传的 FIN+ACK。接着 Client 重传一次确认,重置 2MSL 计时器。如果 A 在进入 TIME_WAIT 状态后不等待一段时间而是直接释放连接,那么就无法收到 Server 重传的 FIN+ACK 报文,因而也不会再发送一次确认报文段。这样 Server 就无法正常进入 CLOSED 状态;

  • 防止已失效的连接请求报文出现在本连接中。当处于 TIME_WAIT 状态时,该 TCP 连接标记为不可用,直到 TIME_WAIT 结束。假如没有 TIME_WAIT,试想一下重新启动了一条相同的 TCP 连接,则之前在网络中迷途的上一次连接的报文将有可能干扰现在的连接。为避免这种情况,等待 2MSL 将足够让这类报文在网络中被丢弃。

如果一个端口号处于 TIME_WAIT 状态,则该端口号在此期间将无法再次使用,但可以用 Linux Socket API 中的 SO_REUSEADDR 来绕开这一限制。

为什么要是四次挥手

建立一个连接需要三次握手,而终止一个连接需要四次挥手,这是由于 TCP 的半关闭造成的。

如前文所示,三次握手中的第三次握手其实是为了 Server 对 Client 的连接请求再做一次确认,而关闭动作则无需这一步交互,一来一回的挥手即可。由于 TCP 连接是全双工模式,因此每个方向都必须单独地进行关闭。这样便需要四次交互:主动关闭者关闭连接(FIN 和 ACK),被动关闭者关闭连接(FIN 和 ACK)。

收到一个 FIN 只意味着在这个方向上没有数据流动,但另一个方向仍可继续发送数据。

同时打开与同时关闭

备注:后面有空再补充一下。

TCP 状态变迁图

一个经典完整的 TCP 有限状态变迁图(旧版的《TCP/IP 详解卷 1》)如下所示:

一个实际的抓包例子

我们在一台机器上打开 23001 端口,并在该机器上连接 23001 端口并传输数据,其抓包如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 127.0.0.1:32836 主动对 127.0.0.1:23001 发起 TCP SYN 连接,其中序号为 4119858131
22:44:46.722450 IP 127.0.0.1.32836 > 127.0.0.1.23001: Flags [S], seq 4119858131, win 43690, options [mss 65495,sackOK,TS val 1680556268 ecr 0,nop,wscale 7], length 0

# 127.0.0.1:23001 向 127.0.0.1:32836 回 SYN-ACK,其中序号为 61943150,ack 为第一个包加 1(4119858131+1= 4119858132)
22:44:46.722463 IP 127.0.0.1.23001 > 127.0.0.1.32836: Flags [S.], seq 61943150, ack 4119858132, win 43690, options [mss 65495,sackOK,TS val 1680556268 ecr 1680556268,nop,wscale 7], length 0

# 127.0.0.1:23001 向 127.0.0.1:32836 回 ACK,其中序号为 61943150+1,此处序号应该为 4119858132,这里没有显示
22:44:46.722474 IP 127.0.0.1.32836 > 127.0.0.1.23001: Flags [.], ack 61943151, win 342, options [nop,nop,TS val 1680556268 ecr 1680556268], length 0

# 127.0.0.1.32836 向 127.0.0.1.23001 发送 13 bytes 数据,其中序号还是握手最后一个包的 4119858132
# ack 还是握手最后一个包的 ack
# 4119858132:4119858145 表示序号从 4119858132 开始,从 4119858145-1=4119858144 结束,即下一个包从 4119858145 开始
22:44:54.835602 IP 127.0.0.1.32836 > 127.0.0.1.23001: Flags [P.], seq 4119858132:4119858145, ack 61943151, win 342, options [nop,nop,TS val 1680564381 ecr 1680556268], length 13
# 接收端回 ack,即 4119858145,序号应该为 61943150+1=61943151,此处没有显示
22:44:54.835612 IP 127.0.0.1.23001 > 127.0.0.1.32836: Flags [.], ack 4119858145, win 342, options [nop,nop,TS val 1680564381 ecr 1680564381], length 0

# 127.0.0.1.32836 向 127.0.0.1.23001 发送 13 bytes 数据
22:44:59.485266 IP 127.0.0.1.32836 > 127.0.0.1.23001: Flags [P.], seq 4119858145:4119858158, ack 61943151, win 342, options [nop,nop,TS val 1680569031 ecr 1680564381], length 13
# 此处 seq 应该仍然为 61943151,因为 ack 不带数据,不消耗序号
22:44:59.485282 IP 127.0.0.1.23001 > 127.0.0.1.32836: Flags [.], ack 4119858158, win 342, options [nop,nop,TS val 1680569031 ecr 1680569031], length 0

参考资料