图 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 都走完之后。
图 2:半关闭与半打开的状态对照,以及 shutdown 与 close 的行为差异。
可能的追问与易错点。
「四次挥手和半关闭什么关系?」常规的四次挥手就是两次半关闭的接力:先 FIN 的一方关自己的写,另一方确认后还可以继续发,发完再 FIN 自己的方向——挥手后状态堆积的排查治理是另一个话题,本文不展开。易错点三个:以为 FIN 就是连接死了,误关还在收数据的读方向;把 close 当温和关闭,共享连接场景下引用计数的坑尤其隐蔽;用「连接状态看起来正常」判断对端存活,半打开的连接必须靠心跳探测。
回到工程现场:长连接推理流里「对端还在不在」只能靠心跳回答;说完数据用 shutdown 收尾而不是 close,能保住对端还在传输的内容。你的服务里长连接的心跳间隔是怎么定的?欢迎聊聊。