TCP 连接的一生从三次握手开始,以四次挥手落幕。握手要解决的从来不是”交换几个包”,而是同步双方的初始序列号、确认双向收发能力;挥手则是因为全双工连接的两个方向必须各自独立关闭。本文按报文级视角逐步拆解这两个过程。

它解决什么问题

IP 网络只承诺尽力投递:报文可能丢失、重复、乱序,也可能在网络里滞留很久才送达。TCP 要在这样的地基上提供可靠有序的字节流,建立连接前必须先解决两件事:

  1. 起点对齐:可靠性靠序列号给每个字节编号,双方必须对”从哪个序号开始计数”达成一致,否则接收方无法分辨收到的是新数据还是历史残留报文。
  2. 能力互验:连接是全双工的,建立前需要确认”我能发、对方能收”在两个方向上都成立,而不是只验通一个方向。

关闭连接时问题反过来:一个方向的”我发完了”不代表另一个方向也没话可说,所以两个方向要分别关闭、分别确认。握手三次、挥手四次,就是为这两组问题设计的最小动作集。

核心原理

三次握手:同步序列号,确认双向可达

Client                                     Server
| ---------- SYN seq=x ---------------> | 客户端选初始序列号 x,进入 SYN_SENT
| <------ SYN+ACK seq=y, ack=x+1 ------- | 服务端选初始序列号 y,进入 SYN_RCVD
| ---------- ACK seq=x+1, ack=y+1 ----> | 双方先后进入 ESTABLISHED

三个报文各有明确语义:

  1. SYN(seq=x):客户端随机生成初始序列号 ISN=x 请求同步。SYN 标志本身消耗一个序列号,所以后续确认号是 x+1。
  2. SYN+ACK(seq=y, ack=x+1):ack=x+1 表示”x 及之前的字节我都收到了”,确认客户端的 SYN;同时服务端也发出自己的 SYN,把自己的 ISN=y 交给客户端。两个方向各有独立的序号空间,所以服务端也必须同步一次。
  3. ACK(seq=x+1, ack=y+1):客户端确认服务端的 SYN。至此双方都知道了对方的起始序号,也都验证了双向收发闭环。

初始序列号之所以随机而非从 0 开始,一是防止攻击者猜测序号注入伪造报文,二是降低上一次连接滞留的旧报文恰好落进新连接接收窗口的概率。

四次挥手:两个方向分别关闭

主动关闭方                                 被动关闭方
| ---------- FIN seq=u ---------------> | "我不再发了",进入 FIN_WAIT_1
| <------- ACK ack=u+1 ---------------- | 内核立即确认,进入 CLOSE_WAIT
| (半关闭:对方仍可继续发数据)
| <------- FIN seq=v, ack=u+1 --------- | 应用收尾完毕,发出 FIN,进入 LAST_ACK
| ---------- ACK ack=v+1 -------------> | 主动方进入 TIME_WAIT,对方 CLOSED

FIN 与 SYN 一样消耗一个序列号,确认号同样是 +1。关键在第二、三步为什么通常分开发:内核收到 FIN 会立刻自动回 ACK,但被动方自己的 FIN 要等应用把剩余数据处理完、调用 close() 之后才发出。两件事在时间上解耦,于是关闭通常呈现为四个报文。

深入机制

为什么两次握手不行

教科书的经典论证是”历史重复连接”场景:客户端发出的第一个 SYN 因网络拥塞而滞留,客户端超时后重发 SYN,新连接建立、传完数据、正常释放。此时那个滞留的旧 SYN 才姗姗到达服务端。

  • 假如只有两次握手:服务端收到旧 SYN 后回了 SYN+ACK 就认为连接已建立,随即分配缓冲区和连接状态。而客户端根本没有这个连接意图,不会理睬,服务端只能抱着一个死连接空耗资源。
  • 三次握手之下:服务端发出 SYN+ACK 后停在 SYN_RCVD,还要等第三个 ACK。客户端发现这不是自己期待的报文,会回 RST 或直接丢弃;服务端等不到合法确认,超时后清理,错误的”已建立连接”根本不会诞生。

换一个角度:第二次握手完成时,只有客户端确认了双向可达,服务端并不知道自己的 SYN+ACK 是否被对方收到。第三次握手让服务端也拿到这个确认,闭环才完整。四次当然也行,但三次已经是最小充分解,多一次纯属浪费。

半关闭:全双工语义的直接体现

第二次挥手之后、第三次挥手之前,连接处于**半关闭(Half-Close)**状态:主动方到被动方的发送方向已关闭,但被动方仍可继续发送剩余数据,主动方也继续在收。这正是四次挥手存在的理由——如果收到 FIN 就意味着双方同时停发,那三个报文就够了。

注意区分一个近义词:**半开连接(Half-Open)**指一端崩溃或重启后,另一端仍以为连接存活的异常状态,和正常的半关闭完全是两回事。

抓包视角的连接生命周期

用 Wireshark 观察一次完整短连接,标志位序列通常是这样的指纹:

1. C -> S  [SYN]       Seq=0              握手开始(相对序列号)
2. S -> C [SYN, ACK] Seq=0, Ack=1
3. C -> S [ACK] Seq=1, Ack=1 握手完成,进入 ESTABLISHED
4. C -> S [PSH, ACK] 数据...
5. C -> S [FIN, ACK] 主动关闭
6. S -> C [ACK]
7. S -> C [FIN, ACK]
8. C -> S [ACK] 进入 TIME_WAIT

如果被动方收到 FIN 时已无数据可发、应用又立刻关闭,第 6、7 步会合并成一个 FIN+ACK,抓包里就只看到”三次挥手”。这并不违背全双工语义——两个方向照样各自关闭,只是确认和关闭装进了同一个报文。

工程实践

  • 看状态分布:ss -ant | awk 'NR>1 {c[$1]++} END {for (s in c) print s, c[s]}',一眼看清 SYN_RCVD、ESTABLISHED、TIME_WAIT、CLOSE_WAIT 各有多少。
  • CLOSE_WAIT 堆积先查代码:被动方收到 FIN 后停在 CLOSE_WAIT,是在等本端应用 close()。大量堆积几乎都是应用没释放连接——响应体没关、异常分支漏掉 close、连接池泄漏,调内核参数治标不治本。
  • 两个队列别混淆:服务端收到 SYN 后把连接放进半连接队列,收到第三个 ACK 再迁入全连接队列等待 accept()。ss -ltn 中 Recv-Q 长期逼近 Send-Q,说明 accept 队列堆积,要查应用消费速度而非握手本身。
  • 只看握手挥手:tcpdump -nn 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0' 过滤出 SYN/FIN 报文,可快速确认连接是谁发起、谁关闭的。

面试视角

为什么建立连接要三次握手,两次行不行?

不行。两次握手时服务端无法确认客户端是否收到了自己的 SYN+ACK;面对网络中滞留的旧 SYN,服务端会误建连接、白白占用资源。第三次 ACK 既补上了服务端缺失的确认,也把失效的历史请求挡在门外。

握手能三次,挥手为什么要四次?

握手时服务端收到 SYN 的瞬间就同时知道自己要确认、也要同步序号,SYN 和 ACK 可以合并发出。挥手时不同:ACK 由内核收到 FIN 后立即回复,本端 FIN 却要等应用处理完剩余数据调用 close() 才发,时机解耦,所以通常拆成两个报文。

收到 FIN 之后还能继续发数据吗?

能。FIN 只关闭对方到本端的发送方向,此后连接处于半关闭状态,本端仍可发送数据,直到本端也发出 FIN。

CLOSE_WAIT 和 TIME_WAIT 分别卡在哪一端?

CLOSE_WAIT 在被动关闭方,等本端应用调用 close();TIME_WAIT 在主动关闭方,发出最后一个 ACK 后等待 2MSL。大量 CLOSE_WAIT 查应用代码,大量 TIME_WAIT 查短连接模式。

小结

  • 三次握手完成两件事:同步双方随机初始序列号、确认双向收发闭环;两次握手挡不住网络中滞留的历史 SYN,服务端也无法确认自己的应答已送达。
  • SYN 和 FIN 各消耗一个序列号,所以确认号总是 +1。
  • 四次挥手源于全双工:ACK 由内核立即回复,FIN 由应用收尾触发,时机解耦故通常分开发;两个报文之间隔着半关闭状态,被动方仍可发完剩余数据。
  • 抓包中 SYN→SYN+ACK→ACK 与 FIN→ACK→FIN→ACK 是连接生命周期的标准指纹;CLOSE_WAIT 堆积指向应用未释放连接,而非协议本身的问题。