NIO事件循环与背压:就绪之后,谁还有容量
前置知识是 TCP 分帧和部分读写、进程与 CPU 预算。本页关注选择器、缓冲所有权和接纳,不用“用了 NIO”代替容量论证。契约依据 JDK 17 Selector,实现固定 openjdk/jdk17u、jdk-17.0.12+7。
就绪、完成与平台 provider
OP_READ 就绪意味着相应操作可以推进或得知 EOF/错误,不保证读取一整帧;OP_WRITE 就绪不保证整个待发缓冲都写完,更不表示对端应用已提交。事件发生后到实际调用之间状态还可能变化,应以 read/write 的返回值为准。
JDK 通用 SelectorImpl 管理注册、取消与就绪分发;Linux 默认通常走 EPollSelectorImpl,macOS 通常走 KQueueSelectorImpl。可插拔 provider 和具体 JDK 构建仍要打印确认,macOS Selector 实验不是 Linux epoll 实测。
就绪多路复用不等于内核替应用执行异步业务,也不等于零拷贝。磁盘、数据库、锁或 CPU 密集解析若在 loop 里长时间占用,会阻塞该 loop 上其他连接;即使 select 很快,用户仍可能排长队。
普通 select 与 Consumer 路径不能混用规则
普通 select(timeout) 更新 selected-key set。键已经在集合里时更新其 readyOps;应用处理后用 iterator.remove 移除,移除的是本轮选择集合元素,不是取消注册。否则上一轮的就绪状态可能被误处理。key.cancel() 则安排撤销注册,由后续选择过程处理取消集合;两者不是一回事。
select(Consumer, timeout) 将就绪键交给 action,不按普通路径把这次键放进 selectedKeys。回调也可能针对不同操作多次出现,不能用“回调一次=消息一条”做业务去重。不要把普通 select 路径的手动清集合要求无条件套给 Consumer。
连续原码:SelectorImpl.java L288–L313,仅统一公共缩进。
protected final int processReadyEvents(int rOps,
SelectionKeyImpl ski,
Consumer<SelectionKey> action) {
if (action != null) {
ski.translateAndSetReadyOps(rOps);
if ((ski.nioReadyOps() & ski.nioInterestOps()) != 0) {
action.accept(ski);
ensureOpen();
return 1;
}
} else {
assert Thread.holdsLock(publicSelectedKeys);
if (selectedKeys.contains(ski)) {
if (ski.translateAndUpdateReadyOps(rOps)) {
return 1;
}
} else {
ski.translateAndSetReadyOps(rOps);
if ((ski.nioReadyOps() & ski.nioInterestOps()) != 0) {
selectedKeys.add(ski);
return 1;
}
}
}
return 0;
}共同点是将平台 rOps 翻译成 JDK readyOps,并检查与应用 interestOps 的交集;没有业务队列容量判断。Consumer 分支直接调用 action,之后确认 selector 仍打开;普通分支已入集合时用 Update,而新键用 Set 再入集合。删掉交集判断会把未订阅事件误交应用,忽略 Set/Update 差异则会误解为什么旧 readyOps 要靠消费集合来管理。
部分读写与 OP_WRITE 的开关条件
普通 loop:处理控制任务 → 有界 select → 遍历并移除 selected keys → 有预算地读/解码/接纳 → 推进待写缓冲 → 处理超时、关闭和下一轮。读到 0 返回 loop;-1 按协议处理半关闭/残帧。读 buffer 在交 worker 前应复制不可变载荷或转移明确所有权,不能 worker 还引用着它时又 compact 覆写。
发送队列保存 ByteBuffer 和 position,write 返回正数后扣减实际字节,0 时停止本轮写,不丢剩余。只有存在 pending 输出时订阅 OP_WRITE,队列清空立刻取消。socket 常常可写,无数据仍监听会让 select 连续返回;这属于无用唤醒,不是高吞吐。每轮读写也应限字节/帧数,单个大响应不能霸占 loop。
图中的队满“暂停读”不是清空已有缓冲;worker 结果回到发送队列也消耗预算。OP_READ 和 OP_WRITE 是两个方向的兴趣,暂停读不能顺手取消尚未完成的写,否则永远无法释放发送压力。
水位只是局部策略,还需要全局预算
设每连接 pending 高水位 H=64 KiB、低水位 L=32 KiB:达到 H 暂停 OP_READ,下降到 L 再恢复,留滞回避免每写一字节都反复开关。这只是策略,不是内核硬上限;应用应在分配/接纳前预留预算,限制单帧大小及跨水位 overshoot。只检查“已经超过”而不限制下一帧,巨大帧仍可瞬间击穿内存。
2 万连接 × 每连接 2 MiB 逻辑待发空间约 39 GiB,远超常见进程堆。因此还需全局字节预算、业务队列长度、worker 在途与连接总数限制;队列按任务条数有界但每条可以 100 MiB,仍然不是内存有界。达到全局上限时按协议拒绝、分页/截断响应或关闭慢连接,不能把队列转移到另一个无界 completion 队列。
暂停读取只能迟滞地收紧 TCP 接收窗口:在途字节、内核接收缓冲和已经解码的任务仍在。恢复要由 loop 的受控任务或定时检查触发;若只等新的 OP_READ 而它已被禁用,会出现无法恢复。选择丢弃/拒绝也要考虑协议语义:不能默默丢半个扣款请求还继续复用流。
架构师面对十倍慢客户端,比较限响应大小、分页、每连接速率/年龄、按租户公平预算与代理卸载。总吞吐正常也可能掩盖一个租户吃尽缓冲,需观测每连接/全局峰值、拒绝量与 deadline 到期量。
取消与代际:关闭之后仍可能有结果
通道关闭、key.cancel、业务 future.cancel 和远端业务撤销有不同完成时点。关闭连接不会让已经排进 worker 的代码自动消失。一次请求超时后迟到结果若被写进重用的连接上下文,可能泄漏给下一位请求者。
给连接上下文单调 generation 和请求 ID;投递工作时捕获它们,回 loop 时检查连接仍开、代际一致且请求仍有效,否则丢弃结果并释放其预算/缓冲。不是操作系统把同一 Java Channel 自动变成另一个连接,而是应用复用 attachment/对象/FD 数值时制造了身份混淆。
关闭路径应幂等:撤销兴趣/注册、关闭 channel、回收未发送缓冲、取消本地任务并处理迟到 completion。预算由唯一所有者释放一次;若取消与完成双方各释放一次,会把计数减成负数,若双方都等对方释放则泄漏。保留业务 UNKNOWN 状态与资源已关闭可以同时成立。
事件循环的公平性与跨线程唤醒
worker 不直接并发修改 loop 正在消费的可变 ByteBuffer/队列。用有界、线程安全的控制/完成队列,成功入队后 selector.wakeup();loop 取出后独占更新注册和输出。wakeup 使阻塞选择返回或使下一次选择不阻塞,不是“业务已经执行完成”的确认,多个唤醒还可能合并。
处理控制任务也要有预算,避免业务 completion 洪峰饿死 socket 读写;select 使用有限超时以检查 deadline。队满时不能静默漏唤醒/丢结果,要释放预算并传播失败。原码是就绪分派,不提供这些应用公平性政策;把阻塞业务移到 worker 只是换一个排队点,需用 profile 验证 CPU 分帧与序列化成本。
实验:有限队列、真实兴趣集合和最终清理
公开 runner 的 selector 模式用 loopback 两端和真实 Selector:普通 select 留下 selected key 直到移除,Consumer 路径直接回调且不新增 selected key;用有限待发队列 H=4/L=2 字节与总容量 4 演示拒绝超额、暂停读、排空后恢复;每轮只允许写 1 字节,断言顺序与剩余 position,清空时 OP_WRITE 已撤销。
再模拟上下文 generation 变化,迟到结果被拒绝且预算归零;cancel 后选择处理取消集合,finally 关闭所有 channel/selector。字节额度限制故意制造应用侧部分推进,不声称迫使本机内核 send buffer 满或测得慢读吞吐;高水位用小值保证确定性。这里 worker 回传/代际是教学策略,不是完整网络框架。多租户公平性、Linux epoll、真实内核拥塞本次未运行。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。