资讯动态

NIO Selector 空轮询让 CPU 100%:Netty 的 rebuildSelector 不是多此一举

发布时间:2026/10/1 15:33:38 来源:尧图企业网站定制
title: NIO Selector 空轮询让 CPU 100%Netty 的 rebuildSelector 不是多此一举date: 2026-09-25tags: [Java, NIO, Netty, Selector, 空轮询, 线程模型]2022 年维护一个自研的 NIO 网关线上突然出现某台机器 CPU 100%但 QPS 并不高。top 一看是 Java 进程但线程 dump 里没有死锁也没有大量 RUNNABLE 在做业务计算。最后定位到Selector.select()返回了 0但代码里没做空轮询保护陷入死循环。这篇文章把 NIO Selector 的空轮询 bug 和 Netty 的rebuildSelector机制拆开聊清楚为什么空轮询会吃满 CPU以及 Netty 是怎么解决的。一、事故现场CPU 100% 但业务没卡网关主循环大致如下while (running) { int ready selector.select(); if (ready 0) { IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); if (key.isAcceptable()) { accept(key); } else if (key.isReadable()) { read(key); } else if (key.isWritable()) { write(key); } } } }问题出现时selector.select()不断返回 0但selectedKeys()里偶尔有未处理的 key。因为没有空轮询保护循环疯狂执行CPU 被打满。二、Selector 空轮询 bug这是 JDK 在 Linux epoll 实现上的一个经典 bugint ready selector.select();正常情况下如果没有事件 readyselect 会阻塞。但在某些情况下比如文件描述符被意外关闭、内核 epoll 状态不一致select 会立即返回 0即使没有新事件。Sun/Oracle 官方其实知道这个 bug但一直没完全修复。Netty 选择自己处理。这个 bug 的触发条件通常和文件描述符的并发关闭有关。在 Linux 下sun.nio.ch.EPollSelectorImpl使用 epoll 来监听事件。当某个 Channel 被关闭其对应的文件描述符会从 epoll 集合里移除但内核状态和用户态状态可能短暂不一致。下一次epoll_wait可能返回但对应的selectedKeys里找不到有效事件于是 select 返回 0。对于普通业务代码select()返回 0 应该意味着没有事件线程可以安全地继续调用。但如果每次都返回 0线程就会进入 busy-waitCPU 被打满。三、Netty 的解决方案空轮询计数 rebuildSelectorNetty 的NioEventLoop里有一段经典代码int selectCnt 0; long currentTimeNanos System.nanoTime(); for (;;) { int selectedKeys selector.select(timeoutMillis); selectCnt; if (selectedKeys ! 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) { break; } long currentTimeNanos System.nanoTime(); if (currentTimeNanos - prevDeadlineNanos timeoutNanos) { selectCnt 1; } else if (SELECTOR_AUTO_REBUILD_THRESHOLD 0 selectCnt SELECTOR_AUTO_REBUILD_THRESHOLD) { selector selectRebuildSelector(selectCnt); selectCnt 1; } }核心逻辑1. 每次 select 返回 0计数器selectCnt加 1。2. 如果连续很多次默认 512 次都没有事件也没有任务要执行说明可能遇到了空轮询 bug。3. 调用selectRebuildSelector重建 Selector新建一个 Selector把所有 Channel 的 SelectionKey 注册到新的 Selector 上然后关闭旧的。selectRebuildSelector的简化逻辑private Selector selectRebuildSelector(int selectCnt) throws IOException { logger.warn(Selector.select() returned prematurely {} times in a row; rebuilding Selector, selectCnt); rebuildSelector(); Selector selector this.selector; // Select again to populate selectedKeys. selector.selectNow(); return selector; } public void rebuildSelector() throws IOException { final Selector oldSelector selector; final SelectorTuple newSelectorTuple openSelector(); int nChannels 0; for (SelectionKey key : oldSelector.keys()) { Object a key.attachment(); try { if (key.isValid() key.channel().keyFor(newSelectorTuple.unwrappedSelector) null) { int interestOps key.interestOps(); key.cancel(); SelectionKey newKey key.channel().register(newSelectorTuple.unwrappedSelector, interestOps, a); replaceInvoked(newKey); nChannels; } } catch (Exception e) { logger.warn(Failed to re-register a Channel to the new Selector., e); } } selector newSelectorTuple.selector; unwrappedSelector newSelectorTuple.unwrappedSelector; oldSelector.close(); }通过重建 SelectorNetty 绕过了 JDK 的空轮询 bug。rebuildSelector的另一个细节是它会先调用selector.selectNow()清空旧的 selectedKeys避免重建过程中漏掉已经 ready 的事件。然后循环里用新的 Selector 继续 select。三、EventLoop 不只是 Selector还要处理任务队列Netty 的NioEventLoop是一个单线程但它不只是做 I/O。它还维护了一个任务队列MpscUnboundedArrayQueue用来处理用户提交的任务public final class NioEventLoop extends SingleThreadEventLoop { private final Selector selector; private final Selector unwrappedSelector; private final SelectStrategy selectStrategy; private final IntSupplier selectNowSupplier; Override protected void run() { for (;;) { try { int strategy selectStrategy.calculateStrategy(selectNowSupplier, hasTasks()); switch (strategy) { case SelectStrategy.CONTINUE: continue; case SelectStrategy.BUSY_WAITED: case SelectStrategy.SELECT: strategy select(strategy); if (strategy -2) { return; } default: } processSelectedKeys(); runAllTasks(); } catch (...) { // handle error } } } }runAllTasks()会执行任务队列里的所有任务。这里的关键是runAllTasks默认有一个时间限制ioRatio控制避免业务任务执行太久导致 I/O 事件得不到处理。如果业务逻辑很重应该这样处理channel.eventLoop().execute(() - { // 把这个耗时任务放到 EventLoop 的任务队列 businessThreadPool.submit(() - { // 真正耗时的业务逻辑 Object result doHeavyWork(msg); // 结果写回时仍然要交给 EventLoop channel.eventLoop().execute(() - { ctx.writeAndFlush(result); }); }); });核心原则Inbound 事件在 EventLoop 线程处理耗时业务丢给业务线程池Outbound 写回再交给 EventLoop。四、Netty 的 ByteBuf 与零拷贝Netty 自己实现了ByteBuf来替代 JDK 的ByteBuffer。ByteBuffer有两个限制长度固定扩容需要创建新的 buffer 并拷贝。flip()切换读写模式容易写错。ByteBuf用readerIndex和writerIndex分开管理读写指针不需要 flipByteBuf buf Unpooled.buffer(1024); buf.writeBytes(hello.getBytes(StandardCharsets.UTF_8)); int readable buf.readableBytes(); byte[] dst new byte[readable]; buf.readBytes(dst);Netty 的CompositeByteBuf可以把多个 ByteBuf 组合成一个逻辑 buffer避免数据拷贝CompositeByteBuf composite Unpooled.compositeBuffer(); composite.addComponent(true, headerBuf); composite.addComponent(true, bodyBuf);这在协议编码时非常有用比如 HTTP 响应头、body、尾部分开构造发送时组合成一个 buffer。五、Netty 的线程模型一个 EventLoop 绑一个线程Netty 的 Reactor 模型BossGroup: 1 个 EventLoop负责 accept 连接 WorkerGroup: N 个 EventLoop每个负责多个 Channel 的读写每个 EventLoop 是一个单线程。一个 Channel 注册到 EventLoop 后它的所有 I/O 事件都由这个线程处理。这种设计的好处是1. 没有锁竞争。2. 业务逻辑顺序执行不需要额外同步。3. 避免了线程上下文切换。但坏处也很明显1. 如果一个 Channel 的处理耗时很长会阻塞同一个 EventLoop 上的其他 Channel。2. 业务逻辑不能阻塞 EventLoop 线程。六、Netty 与原生 NIO 的对比维度原生 NIONetty空轮询处理需要自己实现 rebuildSelector内置超过阈值自动重建内存管理ByteBuffer功能简单ByteBuf引用计数、池化、零拷贝线程模型需要自己设计Reactor EventLoop开箱即用编解码需要自己实现提供 Codec 框架学习成本低但容易踩坑高但踩坑少适用场景简单原型、学习生产级网络服务我的判断是生产环境不要用原生 NIO 写网关或 RPC。Netty 已经处理了 90% 的坑剩下的 10% 是业务问题。如果团队真的想自研也建议先熟读 Netty 源码把它的防御性设计都搬过来。五、我的取舍判断自己写 NIO 网关时必须做空轮询保护。参考 Netty 的做法连续空轮询达到一定阈值就重建 Selector。不要直接在 EventLoop 里做耗时业务用业务线程池处理。Netty 的线程模型适合 I/O 密集型不适合 CPU 密集型。如果团队没有深厚的 NIO 功底不要自研网关直接用 Netty 或成熟的框架。六、最小复现代码下面这段代码可以复现空轮询的效果某些 JDK 版本public class SelectorBug { public static void main(String[] args) throws Exception { Selector selector Selector.open(); ServerSocketChannel server ServerSocketChannel.open(); server.bind(new InetSocketAddress(8080)); server.configureBlocking(false); server.register(selector, SelectionKey.OP_ACCEPT); int count 0; while (true) { int ready selector.select(); count; if (ready 0) { System.out.println(empty select, count count); } if (count 1000000) { System.out.println(might be empty spin bug); break; } } } }七、复盘真实数字故障影响单台机器 CPU 100%网关响应变慢排查时间1.5 小时修复时间30 分钟加空轮询计数和重建逻辑后续逐步迁移到 Netty八、思考题你项目里用原生 NIO 还是 Netty如果你用原生 NIO有没有处理空轮询 bug

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑