NioEventLoop不是只会调用Selector.select()的 I/O 线程,而是一个带任务队列、定时调度、唤醒协议和时间预算的单线程执行器。Netty 的低锁与有序性来自“Channel 固定绑定 EventLoop”,而吞吐抖动往往来自 I/O、普通任务和定时任务对同一条线程的竞争。
Netty 最常见的描述是“基于 Reactor 模型,一个线程可以处理很多连接”。这句话没有错,但只解释了 Selector 复用,没有解释 Netty 为什么还要让业务通过 eventLoop().execute() 提交任务,也没有解释心跳、Promise Listener、跨线程 write 为什么最终都能回到同一连接线程。
答案在 NioEventLoop.run():它每轮循环都要在三类工作之间做协调。
1. 等待或轮询 I/O 就绪事件。 2. 处理 SelectionKey 对应的 Channel 操作。 3. 运行普通任务和到期的定时任务。
因此 EventLoop 卡顿不一定是 Selector 慢,也可能是任务队列太长;定时任务延迟不一定是调度器有 Bug,也可能是某次 I/O 回调执行太久。把它当成一个“单线程小型操作系统”比把它叫作“线程池里的线程”更接近真实。
源码基线:
• Netty netty-4.1.135.Final• Commit: f05f765d81460799c53123a207f665bf3b465171• 文件: transport/src/main/java/io/netty/channel/nio/NioEventLoop.java• 方法: run()、processSelectedKeys()、select(...)• 链接:https://github.com/netty/netty/blob/netty-4.1.135.Final/transport/src/main/java/io/netty/channel/nio/NioEventLoop.java

这张流程图可以按一个循环理解:先决定是否阻塞在 Selector,再处理就绪 SelectionKey,最后按 ioRatio 给普通任务和定时任务分配时间。它说明 EventLoop 不是“只做网络 I/O 的线程”,而是 I/O、用户任务、Promise 回调、定时任务共享的单线程调度器;任何一个环节阻塞,都会影响同一 EventLoop 上的全部 Channel。
1. EventLoop 同时实现执行器和事件循环
从类型体系看,EventLoop 继承 EventExecutor,又继承 EventLoopGroup。它既能注册 Channel,也能执行 Runnable、调度定时任务和返回 Future。
这不是接口堆叠,而是统一并发模型的关键。任何影响某条 Channel 的状态变更,只要提交到它的 EventLoop,就会与该 Channel 的 I/O 回调串行执行:
channel.eventLoop().execute(() -> { // 与该 Channel 的 channelRead/write/close 等事件串行 updateConnectionState();});如果当前就在 EventLoop 线程,许多操作可以直接执行;如果在外部业务线程,Netty 会把操作封装成任务放入队列,并唤醒可能阻塞在 select 的 EventLoop。
这种“线程封闭”降低了锁需求,但边界只在单个 EventLoop 内成立。一个可共享 Handler 被多个 Channel 使用,而这些 Channel 可能属于不同 EventLoop,此时 Handler 自己的共享状态仍然需要线程安全。
2. run 循环的骨架是无限循环加异常恢复
NioEventLoop.run() 外层是一个长期循环。简化结构如下:
protected void run() { int selectCnt = 0; for (;;) { try { int strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks()); switch (strategy) { case SelectStrategy.CONTINUE: continue; case SelectStrategy.BUSY_WAIT: case SelectStrategy.SELECT: long curDeadlineNanos = nextScheduledTaskDeadlineNanos(); strategy = select(curDeadlineNanos); break; default: } selectCnt++; cancelledKeys = 0; needsToSelectAgain = false; final int ioRatio = this.ioRatio; boolean ranTasks; if (ioRatio == 100) { try { if (strategy > 0) { processSelectedKeys(); } } finally { ranTasks = runAllTasks(); } } else { long ioStartTime = System.nanoTime(); try { if (strategy > 0) { processSelectedKeys(); } } finally { long ioTime = System.nanoTime() - ioStartTime; ranTasks = runAllTasks(ioTime * (100 - ioRatio) / ioRatio); } } } catch (Throwable t) { handleLoopException(t); } finally { // shutdown 确认与资源清理 } }}这段骨架体现四个设计目标:
• 没有任务时阻塞 select,避免空转浪费 CPU。 • 有任务时减少阻塞,及时处理任务。 • I/O 与任务之间按时间预算平衡。 • 单次异常不能让 EventLoop 线程退出。
EventLoop 是大量连接的共享基础设施。如果某个 Channel 回调异常就让 run 循环结束,整个线程管理的连接都会失去响应。因此最外层必须捕获 Throwable,记录并继续下一轮。具体 Channel 异常仍会沿 Pipeline 传播并按连接维度处理。
3. SelectStrategy 决定阻塞、非阻塞还是继续循环
直接每轮调用 selector.select() 会产生一个问题:任务队列已经有任务时,EventLoop 仍可能睡在 Selector 里。直接每轮 selectNow() 又会导致空闲时 CPU 空转。
SelectStrategy 在二者之间做决策。默认策略会根据 hasTasks() 决定先执行非阻塞 select,还是进入可能阻塞的 SELECT 分支。
典型语义是:
• CONTINUE:不做 select,继续下一轮。• SELECT:根据最近定时任务期限计算阻塞时间。• 非负整数:表示 selectNow()得到的就绪 Key 数。
定时任务期限也会参与 select 超时计算。假设最近心跳任务 100ms 后到期,Selector 就不能无限期阻塞,而应最多睡到该期限。这样 Netty 不需要单独的定时线程唤醒 EventLoop。
4. wakeup 解决跨线程任务与阻塞 select 的竞态
外部线程调用 eventLoop.execute(task) 时,EventLoop 可能正在:
1. 处理 I/O。 2. 运行任务。 3. 阻塞在 selector.select()。
前两种情况下任务稍后自然会被取出。第三种情况下,如果不唤醒 Selector,任务可能一直等到网络事件或 select 超时。
因此任务入队后,必要时调用 selector.wakeup()。但 wakeup 不是越多越好,它是系统调用并可能制造额外循环。Netty 使用原子状态协调“是否正在 select”和“是否已经请求唤醒”,尽量合并重复 wakeup。
这类代码看起来复杂,是因为要处理经典竞态:
EventLoop 检查队列为空外部线程提交任务EventLoop 刚好进入 select如果提交线程认为 EventLoop 还没 select 而不唤醒,任务会延迟;如果 EventLoop 对每个任务都无条件 wakeup,又会影响吞吐。Netty 的状态位正是在缩小这个窗口。
5. SelectedKey 最终被还原成 Channel 操作
Selector 返回 SelectionKey 后,processSelectedKeys() 遍历就绪集合。Netty会尝试用优化的数组结构替代 JDK 原始 Set,以减少迭代和删除开销;无法注入优化实现时,再走普通 Set。
对 Netty Channel,SelectionKey attachment 是 AbstractNioChannel。处理逻辑根据 readyOps 分派:
int readyOps = k.readyOps();if ((readyOps & SelectionKey.OP_CONNECT) != 0) { int ops = k.interestOps(); ops &= ~SelectionKey.OP_CONNECT; k.interestOps(ops); unsafe.finishConnect();}if ((readyOps & SelectionKey.OP_WRITE) != 0) { ch.unsafe().forceFlush();}if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) { unsafe.read();}三个分支对应三条重要主链:
• OP_ACCEPT/OP_READ:接入连接或读取数据。• OP_WRITE:Socket 曾写不完,现在继续冲刷待发送队列。• OP_CONNECT:客户端非阻塞连接完成。
readyOps == 0 仍尝试 read,是对某些 JDK/平台行为的防御性处理。网络框架不能假定所有 Selector 实现完全理想,很多看似多余的分支都来自长期跨平台实践。
6. ioRatio 分配的是时间,不是事件数量
ioRatio 默认通常为 50。它不是“处理 50 个 I/O 再处理 50 个任务”,而是根据本轮 I/O 花费时间计算任务预算:
long ioTime = System.nanoTime() - ioStartTime;runAllTasks(ioTime * (100 - ioRatio) / ioRatio);当 ioRatio=50,如果 I/O 处理花了 4ms,任务最多得到大约 4ms。当 ioRatio=80,4ms I/O 对应约 1ms 任务预算。当 ioRatio=100,会处理完所有已选 I/O,再无时间限制地运行当轮任务。
这带来明确取舍:
7. 任务队列里装的不只是用户 Runnable
EventLoop 的 taskQueue 可能包含:
• 用户调用 eventLoop.execute()提交的任务。• 跨线程 write()封装的 WriteTask。• Pipeline 动态添加/删除 Handler 的回调。 • Promise Listener 的通知任务。 • Channel 注册、绑定、关闭等生命周期任务。 • 某些延迟到稍后执行的事件。
定时任务则维护在调度结构中,到期后会被转移或执行。心跳、重连、超时检测都可能依赖它。
因此任务队列积压的影响远比“用户任务执行慢”更广。它会造成 writeAndFlush 延迟、注册延迟、连接关闭延迟、定时器漂移和 Promise 回调迟到。监控 EventLoop 时,应同时关注:
• 每轮任务执行时间。 • pendingTasks 数量。 • scheduled task 延迟。 • 单个 Handler 耗时。 • Selector 空转和 wakeup 频率。
8. 为什么同一 Channel 上通常不需要加锁
假设业务 Handler 保存一个连接级序号:
private long sequence;@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) { sequence++; handle(sequence, msg);}只要该 Handler 实例不被多条 Channel 共享,且所有修改都发生在当前 Channel 的 EventLoop,这个字段通常不需要锁。因为 channelRead、用户事件、出站回调都会按该 EventLoop 串行执行。
若异步业务线程完成后要更新连接状态,正确方式是回到 EventLoop:
businessPool.submit(() -> { Result result = query(); ctx.executor().execute(() -> { connectionState.apply(result); ctx.writeAndFlush(result); });});这样重 CPU 或阻塞操作在业务池完成,连接状态与写操作回到 EventLoop 串行。需要警惕业务线程直接修改 Handler 字段,同时 EventLoop 也在访问,线程封闭就被打破了。

这张时序图说明跨线程操作为什么不会直接改 Channel 状态。外部线程只负责把任务放入队列并在必要时 wakeup,真正的状态修改回到 EventLoop 串行执行。写业务代码时,可以利用这个模型把阻塞计算放到业务线程池,但连接状态、写回响应、关闭连接等动作最好再切回 ctx.executor()。
9. Selector 空轮询与自动重建
历史上 JDK NIO 在部分平台和版本中可能出现 Selector 没有事件却立即返回,导致 run 循环高速空转,CPU 接近 100%。Netty 在 select 逻辑中统计异常提前返回次数,超过阈值后重建 Selector,并把旧 Selector 上注册的 Channel 迁移到新 Selector。
这不是正常业务路径,却是生产稳定性的重要兜底。重建过程中需要:
1. 创建新 Selector。 2. 遍历旧 Selector 的 SelectionKey。 3. 取消旧 Key。 4. 用原 interestOps 和 attachment 注册新 Selector。 5. 更新每个 Channel 保存的 SelectionKey。 6. 关闭旧 Selector。
这也说明为什么应用不应绕过 Netty 私自修改底层 SelectionKey。Netty 可能在恢复流程中替换它,外部保存的引用会失效。
10. EventLoop 被阻塞时会出现“成组故障”
假设一个 Worker EventLoop 管理 5000 条连接,其中一条连接的 Handler 执行了 2 秒同步调用。这 2 秒内:
• 其他 4999 条连接的读事件不能处理。 • 已排队的 write task 不能执行。 • OP_WRITE 不能继续冲刷。 • 心跳和超时任务不能准时运行。 • close 和 Promise Listener 也会延迟。
这就是 Netty 线上故障常呈现“某一批连接一起抖动”的原因。连接按 EventLoop 分组,故障也按 EventLoop 扩散。
诊断时可以抓取线程栈,寻找命名类似 nioEventLoopGroup-x-y 的线程。如果线程停在业务数据库、HTTP 客户端、锁或文件 I/O,而不是 Selector 与 Netty 方法,就说明 EventLoop 被业务阻塞。也可在 Handler 前后记录耗时,超过阈值打印事件类型和 Channel。
11. 业务卸载也可能破坏顺序和背压
把所有 Handler 都扔到独立线程池并非自动正确。pipeline.addLast(executorGroup, handler) 可以让 Handler 在指定 EventExecutorGroup 上运行,但要考虑:
• 同一 Channel 的事件是否仍由同一 Executor 串行。 • 任务队列是否有界。 • 异步结果返回时是否保持消息顺序。 • ByteBuf 在跨线程期间是否 retain。 • 业务池积压是否能反馈到网络读取。
无界业务队列只是把压力从 EventLoop 转移到堆内存。更完整的方案是有界队列、拒绝策略、关闭 AUTO_READ 或根据业务容量控制 read,并把结果写回 EventLoop。
12. 总结:EventLoop 是 Netty 并发模型的最小单元
理解 NioEventLoop.run() 后,Netty 很多设计会连起来:
• Channel 固定注册 EventLoop,所以事件有序。 • 外部线程通过任务队列影响 Channel,所以需要 wakeup。 • I/O 与任务共享线程,所以需要 ioRatio 和时间预算。 • 定时任务参与 select 超时,所以心跳不需要独立线程。 • EventLoop 管理多条 Channel,所以阻塞会成组扩散。 • Selector 可能异常,所以 Netty提供重建兜底。
下一篇将沿 OP_READ -> unsafe.read() 展开,查看 EventLoop 得到读就绪后,Netty如何选择 ByteBuf 大小、执行读循环、传播 channelRead 与 channelReadComplete,以及 Buffer 的所有权从哪里开始转移。
参考资料
1. NioEventLoop.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/transport/src/main/java/io/netty/channel/nio/NioEventLoop.java2. SingleThreadEventExecutor.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/common/src/main/java/io/netty/util/concurrent/SingleThreadEventExecutor.java3. SelectStrategy.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/transport/src/main/java/io/netty/channel/SelectStrategy.java4. Netty Thread Model:https://netty.io/wiki/thread-model.html
夜雨聆风