TCP/IP(六):TCP 连接管理——握手、挥手与状态机

写在前面

上一篇我们认识了传输层的两位主角 TCP 与 UDP,也拆解了 TCP 报文段头部里的那些字段——序号、确认号、标志位(SYN/ACK/FIN/RST)、窗口大小……当时说"这些字段怎么用,留到后面讲",这一篇就来还债。

TCP 把自己叫作"面向连接"的协议,可这个"连接"到底是什么?它不是一条物理通路,而是通信两端各自维护的一组状态(各自的序号、窗口、计时器)。连接管理要回答的核心问题就两个:怎么把这组状态建起来,又怎么干净地拆掉 。建起来的过程叫握手(三次),拆掉的过程叫挥手(四次),中间还牵出一堆状态和一个让无数后端工程师踩过坑的 TIME_WAIT

这一篇就把这些一次讲透:握手为什么是三次、挥手为什么是四次、完整的状态机长什么样、TIME_WAIT 为什么存在,以及一个高频实战问题——客户端短连接打太快把临时端口耗光。


一、先说清:TCP 的"连接"是什么

TCP 的连接不是物理的,而是逻辑上的状态同步。两台主机上的 TCP 实体各自记住一组变量,主要包括:

  • 对端 IP 与端口(连同自己的,构成四元组);
  • 自己的发送序号、对端要确认的下一个序号;
  • 发送窗口、接收窗口(下一篇讲);
  • 一堆计时器(重传、保活、持久……)。

一条 TCP 连接由四元组唯一标识(源 IP, 源端口, 目的 IP, 目的端口)。理论上同一台机器上只要四元组之一不同,就能并存多条连接——这也是后面理解"端口耗尽"的关键。

UDP 没有这些建连过程,发完就走,所以叫"无连接"。TCP 要先握手把这些状态协商好(尤其是双方的初始序号),然后才能可靠地收发字节流。


二、三次握手:为什么是三次

建立一条 TCP 连接,客户端和服务端要互相确认两件事:我能听见你,你也能听见我;还要协商好各自的初始序号(ISN)。整个过程三个报文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
        客户端                                     服务端
        CLOSED                                    LISTEN
          │                                          │
          │ ─── SYN, seq=x ─────────────────────────▶│   "我想建连,我的 ISN 是 x"
          │ SYN-SENT                                 │
          │                                          ▼
          │                                    SYN-RECEIVED
          │ ◀──────────── SYN+ACK, seq=y, ack=x+1 ────│   "同意;我的 ISN 是 y,期待 x+1"
          │                                          │
          │ ─── ACK, seq=x+1, ack=y+1 ──────────────▶│   "收到,期待 y+1"
          │ ESTABLISHED                              │
          │                                    ESTABLISHED

要点(后面要考):

  • SYN 和 FIN 各消耗一个序号,所以对 SYN 的确认是 ack = x + 1,而不是 x
  • 中间那个报文是 SYN+ACK(标志位同时置位),把"服务端的 SYN"和"对客户端 SYN 的 ACK"合二为一——这正是省下一次往返的原因;
  • 握手完成后,双方都进入 ESTABLISHED,可以双向传数据。

为什么不是两次

这是面试高频题,也是真正理解 TCP 的分水岭。两次握手的意思是:服务端一发完自己的 SYN+ACK 就直接 ESTABLISHED,开始等数据。问题有两个:

  1. 服务端的初始序号没有得到确认。三次握手中,客户端的第三个 ACK 确认了服务端的 SYN 和初始序号。两次握手缺这一步,服务端若直接认定连接建立,就无法确认双方是否已经完成双向序号同步。“确认客户端的接收能力”可以帮助直觉理解,但规范层面的核心是同步并确认双方的初始序号

  2. 挡不住历史重复的 SYN。网络里会残留延迟报文。设想一个旧的、早已失效的 SYN 晚到服务端,两次握手会让服务端立刻开一条连接干等,直到超时才释放——这就是经典的资源浪费。三次握手里,客户端收到服务端对这个旧 SYN 的回应时,能发现序号对不上(这是给一个早已不存在的连接的确认),于是回一个 RST 拆掉它。这第三步本质上是一次"双向确认 + 旧报文过滤"

为什么不是四次

因为中间的“服务端 SYN”和“服务端对客户端 SYN 的 ACK”本来就能合并成一个 SYN+ACK 报文,没必要拆成两个独立包。三次已经足够让双方的 SYN 和初始序号都得到确认,再拆成四次不会增加必要的协议信息。

一句话:三次握手 = 双方各自发一次 SYN + 各自收到一次对 SYN 的 ACK,其中一方的 SYN 与对另一方的 ACK 合并成了一个报文


三、四次挥手:为什么 ACK 和 FIN 要分开

TCP 是全双工的——双方都能独立地"发完数据"。所以关闭一条连接不能像握手那样一步到位,而要各自单独关闭自己的发送通道。这就是四次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
        主动关闭方 (A)                             被动关闭方 (B)
        ESTABLISHED                               ESTABLISHED
          │ 应用调用 close()                          │
          │ ─── FIN, seq=u, ack=v ──────────────────▶│   "我没数据要发了"
          │ FIN-WAIT-1                               │
          │                                    CLOSE-WAIT
          │ ◀──────────────────── ACK, ack=u+1 ───────│   "收到你的 FIN"
          │ FIN-WAIT-2                               │
          │             ...B 还可以继续发剩余数据...    │
          │ ◀──────────────────── FIN, seq=w, ack=u+1─│   "我也发完了"
          │                                    LAST-ACK
          │ ─── ACK, ack=w+1 ──────────────────────▶│
          │ TIME-WAIT  (等 2×MSL)                    │
          │                                          CLOSED
          │ ──(2×MSL 后)──▶ CLOSED

为什么中间的 ACK 和 FIN 不合并

因为它们触发于不同时刻。A 发 FIN 表示"A 发完了";B 收到后立刻回 ACK。但 B 此刻可能自己还有数据没发完——它处于 CLOSE-WAIT,发送通道还开着。等 B 也把数据发完、自己的应用也调了 close,B 才发自己的 FIN。

握手时服务端的 SYN 和 ACK 是"同时"要发的(都在握手那一瞬间),可以合并;挥手时 B 的 ACK 是"立刻"发的,而 FIN 要等到 B 应用层决定关闭,两者通常隔得很远,所以分开发。只有当 B 恰好也没有数据要发时,ACK 和 FIN 才可能挤进同一个包——但那是优化,不是常态。

全双工的"半关闭"

FIN-WAIT-2 状态下,A 已经关闭了发送方向,但接收方向还开着——B 仍可以继续给 A 推数据,直到 B 也发 FIN。这叫"半关闭"(half-close)。虽然 TCP 协议支持,但应用层很少主动用它,多数实现里一关就双向关。

三个值得记住的状态

  • CLOSE_WAIT被动关闭方收到 FIN、回了 ACK 之后进入。它表示"对端不再发了,但我可能还要把缓冲区里剩下的数据发完,然后等我的应用层调 close"。如果应用层迟迟不关 socket,连接就会长期停在 CLOSE_WAIT——这是排查应用层 bug(忘了关连接、句柄泄漏)时的常见信号。
  • LAST_ACK:被动关闭方发完自己的 FIN 后进入,等最后一个 ACK。ACK 一到就 CLOSED
  • CLOSING:少见。当 A 发完 FIN 进入 FIN-WAIT-1没等到对端的 ACK,却先等到了对端的 FIN(两边几乎同时主动关闭)时,A 跳过 FIN-WAIT-2 直接进 CLOSING,再收到 ACK 后进 TIME-WAIT

四、完整 TCP 状态机:11 个状态

把握手和挥手的状态串起来,就是那张著名的 TCP 状态转换图。RFC 793(现已被 RFC 9293 取代)里画的就是它,一共 11 个状态(含概念上的初始 / 终态 CLOSED):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
                  被动打开                       主动打开 (connect)
                      │                               │
                      ▼                               ▼
                 ┌─────────┐   收 SYN / 发 SYN+ACK  ┌─────────┐
          ┌─────│  LISTEN  │◀──────────────────────│ CLOSED  │
          │      └────┬────┘                       └────┬────┘
          │ 收 SYN         │ 发 SYN                      │ 发 SYN
          │ 发 SYN+ACK     │                             ▼
          ▼                ▼                        ┌──────────┐
   ┌──────────────┐                          ┌─────│ SYN-SENT │
   │ SYN-RECEIVED │ ◀─── 收 SYN / 发 SYN+ACK ─│     └────┬─────┘
   └──────┬───────┘    收 SYN+ACK / 发 ACK    │          │ 收 SYN+ACK
          │ 收 ACK                              │          │ 发 ACK
          │                                     │          ▼
          └──────────────▶ ESTABLISHED ◀────────┘
            主动关闭 / 发 FIN    │   收 FIN / 发 ACK
                  ▼              ▼
           ┌───────────┐   ┌───────────┐
           │ FIN-WAIT-1│   │ CLOSE-WAIT│
           └─────┬─────┘   └─────┬─────┘
        收 ACK    │               │ close() / 发 FIN
        ▼         │ 收 FIN+ACK    ▼
 ┌──────────┐    │           ┌──────────┐
 │FIN-WAIT-2│    │           │ LAST-ACK │
 └────┬─────┘    │           └────┬─────┘
 收 FIN│ / 发 ACK │                │ 收 ACK
      ▼          │                ▼
 ┌──────────┐    │           ┌────────┐
 │TIME-WAIT │    │           │ CLOSED │
 └────┬─────┘    │           └────────┘
 2×MSL│          │
      ▼          │
 ┌────────┐      │
 │ CLOSED │◀─────┘  (FIN-WAIT-1 收到对端 FIN/ACK → CLOSING → 收 ACK → TIME-WAIT)
 └────────┘

各状态一览(按出现顺序):

状态谁会进入含义
CLOSED起点 / 终点虚构状态,连接不存在
LISTEN服务端等待连接请求
SYN-SENT客户端发了 SYN,等 SYN+ACK
SYN-RECEIVED服务端收到 SYN 并回了 SYN+ACK,等最后 ACK
ESTABLISHED双方数据传输中
FIN-WAIT-1主动关闭方发了 FIN,等 ACK 或对端 FIN
FIN-WAIT-2主动关闭方收到对端 ACK,等对端 FIN(半关闭)
CLOSE-WAIT被动关闭方收到对端 FIN,等本端应用 close
CLOSING主动关闭方两边同时关闭的特例
LAST-ACK被动关闭方发了 FIN,等最后一个 ACK
TIME-WAIT主动关闭方等待 2×MSL,确保旧报文消亡

排查线上连接问题,netstat -anss -tan 看到的就是这些状态名。下一篇讲可靠性时还会回到这张图——建立连接是序号协商的起点,拆连接是收尾的边界


五、TIME_WAIT 详解:那个让运维失眠的状态

正常关闭时,TIME_WAIT 通常由主动关闭连接的一方承担,持续时间 2×MSL(Maximum Segment Lifetime,报文最大生存时间);如果双方同时关闭,两端都可能进入 TIME_WAIT。RFC 9293 沿用 2 分钟的规范 MSL 取值,因此规范示例中的 2×MSL 是 4 分钟;Linux 实现通常约 60 秒,不能直接通过普通 sysctl 调整。

为什么必须是主动关闭方、必须等 2×MSL?有两个独立的原因,缺一不可。

原因一:让本连接的旧报文彻底消亡

网络里会残留延迟或重复的报文。如果 TIME_WAIT 一结束、四元组立刻被复用到一条新的、碰巧同四元组的连接,上一个连接里"还在路上"的旧报文就可能被新连接错误接收,造成数据错乱。2×MSL 是一个保守上界:一个报文最多在网络里活 1 个 MSL,加上可能的、由对端重发的方向再 1 个 MSL,合起来 2×MSL 之后,这条四元组相关的旧报文就一定已经消失。

原因二:保证最后一个 ACK 可达

主动关闭方发的最后那个 ACK(对被动方的 FIN 的确认)可能丢。如果丢了,被动方在 LAST-ACK 等不到确认,会重发 FIN;这时主动方还活着(在 TIME_WAIT),就能再回一个 ACK,让被动方正常进入 CLOSED

如果没有 TIME_WAIT,主动方一发完最后一个 ACK 就直接 CLOSED,被动方一旦没收到这个 ACK、重发 FIN 过来,会收到一个 RST——被动方只能以"异常方式"关闭,连接收尾不可靠。

这两个原因合起来就是为什么 RFC 反复强调 TIME_WAIT 是 TCP 健壮性的一部分,不是可以随便关掉的"包袱" 。它由主动关闭方承担,这是协议天然的设计代价。


六、实战:客户端高频短连接 → TIME_WAIT 堆积 → 端口耗尽

理论说完了,来看一个真实坑。场景:一个 .NET / Java 服务作为客户端,高频地对下游发起短连接——典型的就是轮询、或每次请求都 new HttpClient() 而不复用。每条连接用完就关,下游一般也会被动关,但很多情况下客户端是先关的那一方(请求方发完数据就 close)。

于是:每条连接可能在客户端留下一个 TIME_WAIT,暂时保留对应的连接四元组,其中包含一个临时端口(ephemeral port)。连接虽然关闭了,但同一四元组不能立即无条件复用。

数字一算就明白了

  • Linux 临时端口范围默认 32768–60999,约 28000 个
  • TIME_WAIT 约 60 秒;
  • 如果只有一个本地源地址,并持续连接同一个目的 IP:端口28000 / 60 ≈ 470 次/秒 可以作为非常粗的最坏情况量级估算。

这不是客户端的总建连上限:TCP 由四元组区分,同一临时端口可以用于不同目的端点,多本地地址、IPv6 和内核的安全复用策略也会改变结果。在上述受限场景中,如果建连速率持续超过可用四元组的回收速度,新连接可能报:

1
connect: Cannot assign requested address   (EADDRNOTAVAIL)

更阴的是:即便流量回落,之前堆积的 TIME_WAIT 也得等满 60 秒才陆续释放,这期间新连接仍然连不上——这就是"恢复后也连不上"的现象。

怎么治

按"治本 → 治标"排序:

  1. 连接复用(治本)。短连接的本质问题是"用一次就建一次、关一次"。改成长连接 + 连接池,让一条 TCP 连接承载多次请求,TIME_WAIT 自然不再堆积。.NET 里就是用单例 HttpClientIHttpClientFactory 管理的 HttpClient——这个问题在《HttpClient 的前世今生》里讲得很透,连"socket 耗尽"现象都是同一个根因的两种表现。

  2. 合理设计关闭方,但别只为转移状态而改协议。正常关闭时谁主动关,谁通常承担 TIME_WAIT;HTTP keep-alive 连接也常由服务端在空闲超时后关闭。不过,刻意把关闭责任推给下游只是转移资源压力,还可能破坏连接复用和协议语义,必须结合两端容量与业务行为设计。

  3. 谨慎评估 net.ipv4.tcp_tw_reuse(治标)。当前 Linux 文档定义 0 为禁用、1 为全局启用、2 为仅 loopback 流量启用,默认值是 2。它允许内核在协议判定安全时为新连接复用 TIME-WAIT socket,但官方明确提示不要在缺少专家评估时修改。不要把它当成通用推荐,更不能代替连接池和长连接。

  4. 扩大临时端口范围net.ipv4.ip_local_port_range = 10000 65535 之类,把池子做大,单纯把阈值推高,没解决根因。

  5. 别用 tcp_tw_recycle。这个开关在 Linux 4.12 已经被彻底移除——它在 NAT / 负载均衡环境下会基于时间戳丢弃合法连接,害处远大于收益。网上老文章还在推荐,别信。

经验法则:看到 TIME_WAIT 多先别慌,它是 TCP 的正常行为,量级到几万也不一定有问题。真正要看的是是否伴随着端口耗尽 / 建连失败——有才治,没有就让协议自己跑。


七、SYN flood 与 SYN cookies

握手的状态机里有个软肋:服务端收到 SYN 就要分配资源、进入 SYN-RECEIVED,等第三个 ACK。攻击者只要发海量伪造源 IP 的 SYN、但永远不发第三个 ACK,服务端的半连接队列(SYN backlog)就会被占满,正常用户反而连不进来——这就是 SYN flood。

SYN cookies:无状态地扛过去

防御思路是 Bernstein 提出的 SYN cookies:收到 SYN 时先不分配任何状态,而是把"这条连接的身份信息"用一个服务端秘钥做 MAC,编码进 SYN+ACK 的初始序号里发回去。服务端什么也不记,直接忘掉这条连接。

当(真正的)客户端回第三个 ACK 时,它的确认号 = 之前的序号 + 1,服务端从里面反解并校验 MAC:合法就认、重建连接状态;伪造源 IP 的攻击者根本收不到 SYN+ACK,自然也回不出合法 ACK。

代价是 Cookie 可编码的状态空间有限,一些 TCP 选项可能无法完整保留;现代 Linux 可借助时间戳等机制保存更多协商信息,具体限制取决于内核实现。Linux 通常默认 tcp_syncookies=1,在 SYN 队列溢出时启用。

SYN flood 是典型的"利用协议状态机的资源占用做放大"的攻击。同理,让主动关闭方堆积 TIME_WAIT 也可以是一种"被动 DoS"——这也是上一节强调复用连接的另一个理由。


小结

  • 三次握手让双方同步并确认初始序号,也能正确处理历史重复 SYN;不是两次(服务端 SYN 尚未被确认)也不是四次(中间 SYN 和 ACK 可合并)。
  • 四次挥手源于 TCP 的全双工:每一方都要单独关闭自己的发送方向,中间的 ACK 与 FIN 因时刻不同通常无法合并。
  • TCP 有 11 个状态,串成那张经典状态机;ESTABLISHED 是中点,两侧分别是建立与拆除路径。
  • 正常关闭时 TIME_WAIT 通常由主动关闭方承担,同时关闭时两端都可能进入;它持续 2×MSL,目的是隔离旧报文并允许重发最后的 ACK——是 TCP 健壮性的代价,不是包袱。
  • 客户端高频短连接会把 TIME_WAIT 堆到临时端口耗尽(EADDRNOTAVAIL),治本是连接复用,治标是 tcp_tw_reuse
  • SYN flood 借握手占用半连接资源,SYN cookies 用"序号里编码 MAC"实现无状态握手来扛。

下一篇:TCP/IP(七):TCP 可靠性——序号、重传与滑动窗口 —— 连接建好之后,TCP 到底怎么在会丢包、乱序、重复的 IP 之上,拼出一条不出错的字节流。


参考:

Licensed under CC BY-NC-SA 4.0