本节目标:用四个枚举状态约束连接的生命周期,用单调时钟判断空闲,并把状态迁移、PING 和关闭放到正确的事件循环线程上。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、Selector、ScheduledExecutorService。
承接的问题
7.2 解决了「一条消息到哪里结束」,但没有回答「一个连接什么时候结束」。linkd 的连接对象上有好几个布尔值:closed、closing、reading、halfClosed。它们组合出十六种状态,其中大多数是非法的:已经 closed 又 reading、closing 之后又入队、halfClosed 之后又注册 OP_WRITE。
这些非法组合在测试里不常出现,在生产里会以两种形式暴露:
- CLOSE_WAIT 堆积。 对端发了 FIN,
read返回-1,但本端没有走关闭流程,socket 一直停在 CLOSE_WAIT,文件描述符和缓冲区都不释放。 - 空闲连接占内存。 客户端断网、进程被杀、NAT 表项过期,TCP 层没有收到 FIN,连接在服务端看来仍然「活着」。每个连接持有累积缓冲和写队列,几千个僵尸连接就是几百 MB。
这两种问题的共同点是:连接没有明确的状态,代码只能靠零散的 if 猜。本节把状态收成四个枚举值,把迁移规则写成一张表,再用单调时钟和定时扫描把「空闲」变成可执行的判断。
核心机制一:四个状态与合法迁移
ConnectionStateMachine 只定义 CONNECTED、READING、CLOSING、CLOSED 四个状态。迁移表是状态机的核心,非法迁移必须立刻暴露,而不是静默接受:
| 当前 | 事件 | 下一个 | 动作 |
|---|---|---|---|
CONNECTED | 可读事件到达 | READING | 更新 lastReadNanos |
READING | 本次数据处理完 | CONNECTED | 无 |
CONNECTED / READING | 主动关闭、超时、EOF | CLOSING | 停止接受新数据 |
CLOSING | 写队列排空、通道关闭 | CLOSED | 释放缓冲区 |
| 任意 | 异常、RST | CLOSED | 直接释放 |
注意 CLOSING 不是「立刻 close」。它表示「不再接受新的入队,但已有的写队列还要排空」。7.1 的 Connection.close() 契约是「停止接收新数据、等待写队列排空、关闭通道」,CLOSING 就是这条契约的显式状态。
完整实现:
package com.plume.linkd.core;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import java.util.function.LongSupplier;
public final class ConnectionStateMachine {
public enum State {
CONNECTED, READING, CLOSING, CLOSED
}
private static final Map<State, Set<State>> ALLOWED = Map.of(
State.CONNECTED, EnumSet.of(State.READING, State.CLOSING, State.CLOSED),
State.READING, EnumSet.of(State.CONNECTED, State.CLOSING, State.CLOSED),
State.CLOSING, EnumSet.of(State.CLOSED),
State.CLOSED, EnumSet.noneOf(State.class));
private final LongSupplier clock;
private volatile State state = State.CONNECTED;
private volatile long lastReadNanos;
private volatile long lastWriteNanos;
public ConnectionStateMachine() {
this(System::nanoTime);
}
public ConnectionStateMachine(LongSupplier clock) {
this.clock = clock;
long now = clock.getAsLong();
this.lastReadNanos = now;
this.lastWriteNanos = now;
}
public State state() {
return state;
}
public void onReadable() {
State current = state;
if (current == State.CONNECTED) {
transition(State.READING);
} else if (current != State.READING) {
return;
}
lastReadNanos = clock.getAsLong();
}
public void onDrained() {
if (state == State.READING) {
transition(State.CONNECTED);
}
}
public void touchWrite() {
lastWriteNanos = clock.getAsLong();
}
public void beginClose() {
if (state == State.CLOSED || state == State.CLOSING) {
return;
}
transition(State.CLOSING);
}
public void closed() {
if (state != State.CLOSED) {
transition(State.CLOSED);
}
}
public long idleNanos(long nowNanos) {
long last = Math.max(lastReadNanos, lastWriteNanos);
return Math.max(0, nowNanos - last);
}
public long idleMillis(long nowNanos) {
return idleNanos(nowNanos) / 1_000_000L;
}
private void transition(State next) {
State current = state;
if (!ALLOWED.get(current).contains(next)) {
throw new IllegalStateException("illegal transition " + current + " -> " + next);
}
state = next;
}
}
三个设计点:
ALLOWED是显式的白名单。 新增状态时必须同步改这张表,漏改会在第一次非法迁移时抛IllegalStateException,而不是悄悄放行。LongSupplier让时钟可替换。 生产用System::nanoTime,测试注入假时钟,就能在毫秒内验证「60 秒 PING、120 秒关闭」,不用真的等两分钟。- 字段是
volatile,但迁移只在事件循环线程发生。volatile保证定时扫描线程看到最新状态和纳秒值;真正的写操作仍然要投递回事件循环,不能让扫描线程直接改状态。
beginClose() 和 closed() 故意做成幂等:重复调用不抛异常。连接关闭的路径很多(超时、EOF、异常、进程停机),每条路径都可能重复触发关闭,幂等比「保证只调用一次」更可靠。
核心机制二:lastReadNanos 与空闲判定
空闲时间是「距离最后一次有效活动过了多久」。这里有两个时间戳:lastReadNanos 和 lastWriteNanos,取两者最大值:
long last = Math.max(lastReadNanos, lastWriteNanos);
long idle = Math.max(0, nowNanos - last);
只记读会误杀「服务端主动推送」的连接:WebSocket 广播、SSE、消息推送都是只写不读,lastReadNanos 一直停在建立连接那一刻。只记写会误杀「只订阅不发言」的客户端:它周期性发心跳,但服务端没有下行数据。取最大值才表示「两个方向都安静了」。
为什么用 System.nanoTime() 而不是 System.currentTimeMillis():
nanoTime是单调时钟,不受系统时间调整影响;currentTimeMillis会被 NTP 回拨或手动改时间影响,可能让idle变成负数,或者永远到不了阈值。nanoTime只适合算差值,不能当日期用;日志里要打印墙钟时间,需要另外记录。
Math.max(0, ...) 是对「时钟源被替换」的防御。正常情况下 now 不会小于记录值,但注入假时钟的测试、或者未来换成别的时钟源时,负数会让「空闲超过阈值」永远不成立。
判定阈值有两个:pingAfter 和 closeAfter。linkd 的默认值是 60 秒发 PING、120 秒关闭,约束是 0 < pingAfter < closeAfter。构造函数直接校验,避免配置成「先关闭再 ping」这种不可能的顺序。
核心机制三:定时扫描与 IdleTimeoutHandler
空闲检测不能用 Selector.select(timeout) 代替。select(1000) 的超时只说明「这一秒内没有事件」,不代表连接空闲;一个每 10 秒发一次心跳的连接,在两次心跳之间 select 也会超时。空闲是按连接算的,必须遍历连接。
扫描有两种实现:
- 在事件循环里每次
select返回后检查一次。事件密集时扫描过频,事件稀疏时又依赖超时,节拍不稳定。 - 独立的
ScheduledExecutorService每秒触发一次,把检查任务投递到连接所属的事件循环。节拍稳定,且状态读写仍然发生在事件循环线程。
linkd 用第二种。扫描线程只做两件事:读取连接列表、投递任务;它不碰 SocketChannel,也不改状态机。这样多 Reactor 下每个连接的状态只由自己的循环线程修改,不需要锁。
IdleTimeoutHandler 是包在业务 Handler 外面的装饰器,负责在正确的时机更新时间和执行动作:
package com.plume.linkd.core;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public final class IdleTimeoutHandler implements Handler {
public enum Action {
NONE, PING, CLOSE
}
private static final byte[] PING_FRAME = "PING\n".getBytes(StandardCharsets.US_ASCII);
private final Handler next;
private final ConnectionStateMachine state;
private final long pingAfterNanos;
private final long closeAfterNanos;
private boolean pingSent;
public IdleTimeoutHandler(Handler next, ConnectionStateMachine state,
long pingAfterMillis, long closeAfterMillis) {
if (pingAfterMillis <= 0 || closeAfterMillis <= pingAfterMillis) {
throw new IllegalArgumentException("expect 0 < pingAfter < closeAfter");
}
this.next = next;
this.state = state;
this.pingAfterNanos = pingAfterMillis * 1_000_000L;
this.closeAfterNanos = closeAfterMillis * 1_000_000L;
}
@Override
public void onReadable(Connection c) {
state.onReadable();
pingSent = false;
next.onReadable(c);
}
@Override
public void onWritable(Connection c) {
state.touchWrite();
next.onWritable(c);
}
@Override
public void onClosed(Connection c) {
state.closed();
next.onClosed(c);
}
public Action checkIdle(Connection c, long nowNanos) {
ConnectionStateMachine.State current = state.state();
if (current == ConnectionStateMachine.State.CLOSED
|| current == ConnectionStateMachine.State.CLOSING) {
return Action.NONE;
}
long idle = state.idleNanos(nowNanos);
if (idle >= closeAfterNanos) {
state.beginClose();
c.close();
return Action.CLOSE;
}
if (idle >= pingAfterNanos && !pingSent) {
pingSent = true;
c.enqueue(ByteBuffer.wrap(PING_FRAME.clone()));
state.touchWrite();
return Action.PING;
}
return Action.NONE;
}
public void halfClose(Connection c) {
if (state.state() == ConnectionStateMachine.State.CLOSED
|| state.state() == ConnectionStateMachine.State.CLOSING) {
return;
}
state.beginClose();
try {
c.channel().shutdownOutput();
} catch (IOException ignored) {
// 对端已复位或通道已关闭,交给 close() 收尾
}
}
}
四个关键行为:
onReadable同时更新状态和lastReadNanos,并重置pingSent。 收到任何字节都算「对端还活着」,包括 PONG、业务数据、甚至一个空帧的头部。checkIdle在 PING 之后调用touchWrite()。 这一行很容易漏。PING 本身是一次写操作,如果不更新时间,下一次扫描会认为「空闲时间还在增长」,刚发完 PING 就触发关闭。PING 之后重新计时,对端有完整的closeAfter窗口来响应。pingSent保证一个空闲周期只发一次 PING。 如果对端不响应,不会每秒刷一个 PING;到closeAfter直接关闭。收到数据后重置,下一个空闲周期可以再发。PING_FRAME.clone()每次新建数组。ByteBuffer.wrap共享同一个byte[]没问题,但ByteBuffer自身的position会被写操作推进;如果把同一个ByteBuffer实例反复enqueue,第二次写出去的是空缓冲区。每次wrap一个新实例,或者clone数组,都是为了避免这个坑。
用假时钟验证一遍,pingAfter=60s、closeAfter=120s。下面是本机输出,时间轴是注入的假时钟,不是真实等待:
t=0s state=CONNECTED idle=0ms
t=10s after read state=READING idle=0ms
t=20s after drain state=CONNECTED
t=81s action=PING state=CONNECTED events=[enqueue PING]
t=141s action=NONE state=CONNECTED
t=202s action=CLOSE state=CLOSING events=[enqueue PING, close]
after onClosed state=CLOSED action=NONE
- 第 10 秒收到数据,状态进入
READING并更新lastReadNanos;第 20 秒处理完,回到CONNECTED。 - 第 81 秒距离最后一次读已 71 秒,超过 60 秒阈值,动作是
PING,状态仍是CONNECTED。 - 第 141 秒距离 PING 只过了 60 秒,
pingSent仍为 true,动作是NONE。 - 第 202 秒距离 PING 已 121 秒,超过 120 秒阈值,状态转
CLOSING并调用close();onClosed随后推到CLOSED,再扫描返回NONE。
在 linkd 里落地:扫描任务、半关闭与资源释放
扫描任务在 LinkdMain 里装配,按「事件循环 → 连接 → 装饰器」两层组织,保证检查任务回到连接所属的循环:
Map<EventLoop, Map<Connection, IdleTimeoutHandler>> tracked = new ConcurrentHashMap<>();
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(
Thread.ofPlatform().name("linkd-idle").daemon().factory());
timer.scheduleAtFixedRate(() -> tracked.forEach((loop, perLoop) ->
perLoop.forEach((c, handler) ->
loop.execute(() -> handler.checkIdle(c, System.nanoTime())))),
1, 1, TimeUnit.SECONDS);
这段装配有两个约束:
- 扫描线程不直接调
checkIdle。checkIdle会改状态机、enqueuePING、甚至close(),这些都必须在事件循环线程执行。扫描线程只负责投递,投递本身通过EventLoop.execute(Runnable)。 System.nanoTime()在任务内部取。 如果在扫描线程取好再传进去,任务排队 200 ms 后执行,用的就是旧时间;虽然偏差不大,但没必要。tracked也要在连接关闭时移除,否则扫描列表会随历史连接数增长。
关闭顺序比扫描更敏感。一次完整的关闭分五步,顺序不能乱:
beginClose()。 状态进入CLOSING,拒绝新的enqueue,后续到达的数据可以选择丢弃或读完。- 排空写队列。 调用
WriteQueue.writeTo直到返回 true;如果对端不读,最多等一个宽限期,超时转强制关闭。 - 半关闭(可选)。 只有在写队列已空、且协议用 EOF 表示结束时,才调用
halfClose(c)发 FIN。HTTP/1.1 的 keep-alive 连接不能这么做,否则连接无法复用;WebSocket 关闭应该先发 Close 帧,再等对端回 Close。 - 释放缓冲区。
WriteQueue.clear()逐个pool.release,AccumulatingReader.onClosed释放累积缓冲区。已经交给内核的字节不归这些缓冲区管。 - 关闭通道并摘除登记。
key.cancel()、channel.close()、从tracked移除。close()要幂等,因为超时扫描和异常路径可能同时走到这里。
半关闭用 channel().shutdownOutput() 发 FIN,本端仍然可读,适合「请求已发完、等响应」的协议;Connection.close() 是完整关闭,内部先排空写队列再关通道,适合服务端主动断开。两者之后状态必须是 CLOSING 或 CLOSED,再 enqueue 应被拒绝并记录。
CLOSE_WAIT 是这套状态机最直接的验收指标。对端关闭后,如果本端没有走到第 5 步,netstat -anv -p tcp 里会看到连接停在 CLOSE_WAIT;数量持续上涨,说明有代码路径收到了 -1 却没有关闭连接。macOS 上可以用下面的命令统计:
netstat -anv -p tcp | awk '$6 == "CLOSE_WAIT" {count++} END {print count + 0}'
这里不贴本机输出,因为是否出现 CLOSE_WAIT 取决于当前是否有连接走到该状态;命令本身可以直接复用。
常见错误与边界
错误一:用 currentTimeMillis 算空闲。
// 反例:墙钟被 NTP 回拨后 idle 变成负数
long idle = System.currentTimeMillis() - lastReadMillis;
系统时间向前跳会导致空闲时间突然变大,误杀连接;向后跳会导致空闲时间永远不到阈值,僵尸连接不释放。用 System.nanoTime() 算差值,墙钟只用于日志。
错误二:扫描线程直接改状态。
// 反例:在定时线程里直接写通道
if (idle > timeout) {
connection.channel().close();
}
SocketChannel 和状态机都不是线程安全的。定时线程关闭通道的同时,事件循环可能正在 read 或 write,结果是 ClosedChannelException、状态错乱,或者写队列里的缓冲区被双重释放。所有操作都要 loop.execute 回事件循环。
错误三:只记录 lastReadNanos。 推送型连接只写不读,60 秒后被判空闲,PING 发不出去就被关闭。反过来只记录 lastWriteNanos,只发心跳不收数据的客户端会被误杀。取两个方向的最大值。
错误四:发完 PING 不更新 lastWriteNanos。 下一次扫描看到的仍是「距离上次业务活动 61 秒」,如果阈值刚好跨过,就会立刻关闭,对端连 PING 都来不及回。PING 是一次写,必须计入活动时间。
错误五:收到 -1 只把状态改成 CLOSED,不关通道。 状态是应用层的,文件描述符不会因为枚举变了就释放。read 返回 -1 后必须走完整关闭流程,否则 socket 停在 CLOSE_WAIT。同理,CLOSING 状态下再 enqueue 会拿到 ClosedChannelException,实现必须拒绝新入队。
边界:TCP keepalive 不能替代应用层空闲检测。 系统级 keepalive 默认通常是 2 小时,且受中间设备、NAT 超时影响,时间不可控。应用层 PING/PONG 的间隔和关闭阈值由自己决定,才能和业务的心跳预算对齐。
小结
- 连接生命周期收成
CONNECTED、READING、CLOSING、CLOSED四态,非法迁移用白名单直接抛异常。 CLOSING表示「停止接收新数据、排空写队列」,不是立刻close。- 空闲时间取
lastReadNanos和lastWriteNanos的最大值,用System.nanoTime()算差值。 IdleTimeoutHandler在 60 秒无活动时发 PING,PING 后 120 秒仍无响应则关闭;PING 后必须touchWrite,并重置pingSent。- 定时扫描线程只投递任务,状态和通道操作全部回到连接所属的事件循环;关闭顺序是
beginClose、排空写队列、可选半关闭、释放缓冲区、关闭通道并摘除登记。
到这一章结束,非阻塞读写、半包处理、连接生命周期都有了可运行的实现。下一章换一个方向:把这些字节按 HTTP/1.1 的请求行、头部和消息体解析出来,让 linkd 真正听懂浏览器发来的内容。
阅读导航:上一节:7.2 半包、粘包与缓冲区增长 · 下一节:8.1 请求行、头部与消息体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。