深蓝黑背景封面:右侧大标题「这连接,到底断没断」,副标题「TCP 半打开与半关闭的状态语义」;左侧原创示意——两台主机之间一条双向管道,上半段紫色虚线表示已关闭方向、下半段青色实线表示仍在传输方向,旁注 FIN 只关一个方向;左上标注「面试经典 · 网络」,右上标注「Day 04 · 10-22」 图 1:本文封面,FIN 只关一个方向是理解全文的钥匙。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「什么是连接的半打开、半关闭状态」看起来像概念背诵题,实际上是长连接编程的生死线:搞不清这两个状态,就会在流式传输里截断数据,或者对着一个对端早已崩溃的连接空等。

第一步,半关闭:协议设计的正常状态。

TCP 连接是双向的,而关闭是按方向独立进行的——这是理解半关闭的钥匙。RFC 9293 明确描述:连接可以只关闭一个方向,处于「半关闭」状态,主机仍然被允许在打开的那个方向上继续发送数据。典型场景:A 调 shutdown 关闭自己的写方向,协议栈向 B 发出 FIN,含义是「我说完了」;B 收到 FIN 后读操作返回结束标记,知道 A 不会再发数据;但 B 的写方向不受影响,还可以继续向 A 发送,A 也读得到——直到 B 也发 FIN,两个方向都关,连接才真正终结。半关闭不是故障,是「我说完了,你继续说」的协议能力。

这里也是 shutdown 与 close 的分水岭。shutdown 是精细操作:可以只关读或只关写,对端会收到 FIN,连接本身还活着。close 是粗操作:两个方向一起关并释放描述符——更隐蔽的一点是,当多个进程共享同一连接(fork 出的子进程各持一份)时,close 只是把引用计数减一,真正发 FIN 要等到最后一个引用释放。把 close 当 shutdown 用,会直接截断对端还在收的数据。

第二步,半打开:一方消失留下的「僵尸连接」。

半关闭是设计,半打开是事故。A 的主机崩溃或断电,根本来不及发 FIN,B 对此一无所知——B 视角里这条连接「看起来还在」,这就是半打开。B 如果从此只读不写,这条僵尸连接可以挂到天荒地老;B 一旦写数据,数据石沉大海,经过重传超时后可能等来 RST,也可能只等来超时。协议层没有「对方还在吗」的自动通知,发现半打开只有两条路:TCP keepalive(协议允许实现提供保活探测,但规范要求默认关闭、应用可开关,且单个探测不应答不能判定连接已死),或者应用层心跳——长连接服务的标准答案几乎都是后者:定时发应用层 ping,几次不应答就判定断开、重建连接。

第三步,用代码亲眼看看半关闭。

下面这份代码在本机起一对 TCP 连接:A 发完两条消息后调 end() 发 FIN 只关写方向,B 收到 'end' 事件后继续向 A 写两条回复,A 都正常读到。用 Node.js 22.15.0 直接运行(node half-close.js)即可:

'use strict';

// TCP 半关闭实测:A 调 end() 发出 FIN 只关自己的写方向,读方向仍在;
// B 收到 'end' 知道 A 说完了,但 B 还能继续向 A 写,A 读得到。
import net from 'node:net';

const server = net.createServer((b) => {
  const received = [];
  b.on('data', (chunk) => received.push(chunk.toString()));
  b.on('end', () => {
    // B 收到 FIN:A 的写通道关闭,但连接没有死,B 的写通道照常工作
    console.log(`B 视角:收到 A 的 FIN('end' 事件),已收到:${received.join(' / ')}`);
    b.write('B 的回复一:收到你的 FIN,我这头还能继续发\n');
    b.write('B 的回复二:半关闭状态下 B->A 方向完全正常\n');
    setTimeout(() => b.end(), 100); // B 说完也发 FIN,双方向都关,连接才真正结束
  });
});

server.listen(0, '127.0.0.1', () => {
  const port = server.address().port;
  const a = net.connect(port, '127.0.0.1', () => {
    a.write('A 的消息一:先说两句\n');
    a.write('A 的消息二:我说完了\n');
    a.end(); // 只 shutdown 写方向:发出 FIN,但 A 的读方向仍然开着
    console.log('A 视角:调用 end() 发出 FIN,只关闭写方向,继续监听读方向');
  });
  a.on('data', (chunk) => process.stdout.write(`A 视角:FIN 之后仍读到数据:${chunk}`));
  a.on('end', () => {
    console.log("A 视角:收到 B 的 FIN,连接完全关闭");
    server.close();
  });
});

实测输出:

A 视角:调用 end() 发出 FIN,只关闭写方向,继续监听读方向
B 视角:收到 A 的 FIN('end' 事件),已收到:A 的消息一:先说两句
A 的消息二:我说完了

A 视角:FIN 之后仍读到数据:B 的回复一:收到你的 FIN,我这头还能继续发
B 的回复二:半关闭状态下 B->A 方向完全正常
A 视角:收到 B 的 FIN,连接完全关闭

输出把半关闭的两个关键点都打在了屏幕上:A 发出 FIN 之后,依然完整读到了 B 的两条回复——FIN 只关闭了 A 到 B 的方向;连接的真正终结发生在两个方向的 FIN 都走完之后。

状态对照图:上半区讲半关闭——A 调 shutdown 写方向后发出 FIN,自己的写通道关闭但读通道仍在,B 收到 FIN 后读返回 0(知道对方说完了),B 还可以继续向 A 发数据,直到 B 也关才完全断开;这是协议设计的正常状态。下半区讲半打开——A 崩溃或断电,没来得及发 FIN,B 视角里连接「看起来还在」,写数据可能触发 RST 或石沉大海,只能靠应用层心跳或 TCP keepalive 探测发现。右侧 API 对照:shutdown 只关一个方向、对端能收到 FIN、连接还没死;close 两个方向都关、释放描述符,多个进程共享同一连接时 close 只是减引用计数。底部提示:长连接服务里「对端还在不在」必须靠心跳回答,不能靠连接状态猜 图 2:半关闭与半打开的状态对照,以及 shutdown 与 close 的行为差异。

可能的追问与易错点。

「四次挥手和半关闭什么关系?」常规的四次挥手就是两次半关闭的接力:先 FIN 的一方关自己的写,另一方确认后还可以继续发,发完再 FIN 自己的方向——挥手后状态堆积的排查治理是另一个话题,本文不展开。易错点三个:以为 FIN 就是连接死了,误关还在收数据的读方向;把 close 当温和关闭,共享连接场景下引用计数的坑尤其隐蔽;用「连接状态看起来正常」判断对端存活,半打开的连接必须靠心跳探测。

回到工程现场:长连接推理流里「对端还在不在」只能靠心跳回答;说完数据用 shutdown 收尾而不是 close,能保住对端还在传输的内容。你的服务里长连接的心跳间隔是怎么定的?欢迎聊聊。