Skip to content
Go back

为什么四次挥手中间两次不能合并

一句话结论(30s)

四次挥手中间两次(被动关闭方的 ACK 和 FIN)绝大多数情况下不能合并,因为 TCP 是全双工的:ACK 是内核收到 FIN 后立即回复的(确认”你不发了”),FIN 却是应用层调用 close()/shutdown() 后才触发的(表达”我也不发了”),二者由不同事件触发、时间点可能差很远——服务端可能还在发剩余数据。只有被动方恰好也没数据可发、应用已 close 时,内核才可通过延迟 ACK 把 ACK+FIN 合并成”三次挥手”,但那是特例。

核心原理(2min)

底层深入(5-10min)

面试中有一道经典题:“TCP三次握手为什么是三次?“几乎人人能回答。但追问”四次挥手的中间两次能不能合并成三次”时,很多人就开始含糊了。答案是:绝大多数情况下不能,这是由TCP全双工通信的本质决定的。

先搞清楚谁发什么

四次挥手的过程如下:

  1. 主动关闭方 → 被动关闭方:FIN(我不发数据了)
  2. 被动关闭方 → 主动关闭方:ACK(收到,我知道了)
  3. 被动关闭方 → 主动关闭方:FIN(我也不发数据了)
  4. 主动关闭方 → 被动关闭方:ACK(收到,我知道了)

问题集中在第2步和第3步:都是被动关闭方发给主动关闭方,能不能一起发?一个带ACK+FIN的包不行吗?

根本原因:全双工通信的半关闭(Half-Close)

TCP连接是全双工的——数据可以在两个方向上独立流动。四次挥手做的事情本质上是分别关闭两个方向,而不是”关闭连接”这一个操作。

当你调用close()shutdown()时:

关键时间差在这里:ACK是内核秒回的,FIN要等应用层决定。这两件事的触发者和时间点完全不同。

思考:为什么 ACK 和 FIN 的「触发者」不同,就注定了不能合并?因为 ACK 是内核收到 FIN 后「秒回」的协议栈行为,跟应用无关;FIN 却是应用层调用 close()/shutdown() 后才触发。一个在你收到「我不发了」的瞬间就必须回应,一个要等应用自己发完数据才表态——时间点可能差很远,强行合并就等于逼服务端在数据还没生成完时就决定要不要关。

一个具体场景

假设客户端是主动关闭方,服务端是接收方:

  1. 客户端通过HTTP请求获取一个报表,请求已经发送完毕,客户端调用close发FIN。
  2. 服务端内核立即ACK客户端的FIN。
  3. 但是!服务端应用正在查询数据库生成报表数据,还没调用close。
  4. 服务端在生成完报表后,通过同一个TCP连接把数据发给客户端——这个方向还能用,还没关!
  5. 客户端虽然关闭了发送方向,但接收方向仍开着,还能收到服务端发来的报表数据。
  6. 等服务端发完所有数据,应用层调用close,才发FIN给客户端。

如果把ACK和FIN合并,服务端在第2步就必须立即决定”要不要一起发FIN”——但因为报表还没生成,服务端还不能发FIN。这就产生了矛盾。

💭 思考:回到报表场景——服务端在第 2 步回 ACK 时,为什么不能“顺手”把 FIN 也一起带上?——因为那一刻“服务端发完数据了”这个事实还不成立:报表还在查库、还没生成,FIN 的语义(我也发完了)在此时是假的。协议不能撒谎:FIN 必须等到数据真的发完、应用真的 close 之后才能发。所以第 2 步只能先回“知道了”,把“我也发完了”推迟到第 3 步。

技术上能不能合并?有一种特殊情况

当被动关闭方收到FIN时,如果恰好没有数据要发了,应用已经调了close,那么TCP允许将ACK和FIN放在同一个报文段中发送。这就是所谓的三次挥手(FIN + ACK/FIN + ACK):

  1. 主动方 → 被动方:FIN
  2. 被动方 → 主动方:ACK + FIN(合并)
  3. 主动方 → 被动方:ACK

Linux内核中,这种情况是通过**延迟ACK(Delayed ACK)**机制实现的:内核收到FIN后不立即回ACK,而是等一小段时间(通常40ms),如果应用在此期间调用了close,ACK和FIN就可以合并到一个报文段。

但这是特例,不是常态。绝大多数实际场景中,被动方收到FIN到应用层决定关闭之间都有一段”数据处理时间”,ACK和FIN自然被拆成了两步。

思考:既然「绝大多数不能合并」,那「恰好能合并」靠的是什么?靠的是时间窗口——被动方收到 FIN 时恰好也没数据可发、应用已 close,Linux 用 Delayed ACK 把 ACK 拖 40ms,如果应用在这期间调了 close,ACK 和 FIN 就能塞进同一个报文段,变「三次挥手」。可见能否合并,取决于「应用是否恰好已经就绪」,而不是协议允不允许。

深挖:shutdown vs close

不少开发者不知道TCP有”半关闭”的语义,因为常见的socket.close()会同时关闭读和写两个方向。而shutdown(int how)可以精确控制:

当你理解半关闭后,四次挥手就不再是”为什么要分四次”的疑问,而变成一种自然的设计:两个方向的关闭操作各自需要一对”请求-确认”的握手。只是因为被动方通常先回ACK再发FIN,看起来是四步,本质上每个方向都是两次握手。

💭 思考:为什么 TCP 要单独提供 shutdown(SHUT_WR) 这种“只关一半”的 API,close() 不就行了吗?——因为全双工意味着“两个方向”是独立的,很多场景只想关一个方向:比如客户端发完请求后“我不再发了,但还想继续收响应”,就该用 shutdown(SHUT_WR) 只关写方向。如果只能 close(),就会把“还能读”的方向也一并关掉,白白放弃后续数据。半关闭不是冷门特性,它就是“全双工”落到 API 层的自然表达。

总结

四次挥手中间两次不能合并的根本原因不是”协议规定”,而是全双工通信的半关闭语义 + 内核ACK与应用FIN的异步时序。ACK代表”我听你的,你不发了”,FIN代表”我也不发了”——一个是确认对方的关闭意图,一个是表达自己的关闭意图,它们由不同的事件触发,不存在合并的必然性。

💭 思考:说到底,“四次挥手”这个叫法其实有点误导,它真的是“四次”吗?——拆开看,它只是“两个方向各自的一对请求-确认”:主动方 FIN + 被动方 ACK 关一个方向,被动方 FIN + 主动方 ACK 关另一个方向,每个方向各两次。之所以看起来是“四步”,只是因为被动方的 ACK 和 FIN 恰好都发往同一个方向、时间上又一前一后。理解成“两次挥手 × 两个方向”,比死记“四次”更接近本质。

章末提问

Q1:四次挥手的中间两次(ACK 和 FIN)为什么不能合并?

结论先行:因为 TCP 是全双工的,ACK 和 FIN 语义不同、由不同事件触发。因为 ACK 是内核收到 FIN 后立即回复的「确认你不发了」,FIN 是应用层调用 close()/shutdown() 后才发出的「我也不发了」,两者时间点可能差很远——服务端可能还在发剩余数据,所以绝大多数情况下不能合并。

Q2:什么情况下 ACK 和 FIN 能合并成三次挥手?

结论先行:当被动关闭方恰好也没数据可发、应用已 close 时,内核可借 Delayed ACK 把 ACK 和 FIN 塞进同一个报文段。因为延迟 ACK 等约 40ms,若应用在这期间调了 close,两者就能合并;但这依赖「应用恰好就绪」,是特例而非常态。

Q3:shutdown 和 close 有什么区别?

结论先行:close 同时关闭读和写两个方向,shutdown 可精确控制只关一方。因为 TCP 支持半关闭,shutdown(SHUT_WR) 只关写方向、发 FIN 但还能继续读,shutdown(SHUT_RD) 只关读方向;理解了半关闭,四次挥手就自然被理解为「两个方向各自的一对请求-确认握手」。


Share this post on:

Previous Post
为什么四次挥手要等2MSL
Next Post
TCP粘包——三种应用层解决方案与Netty的实现