Netty 第九篇:NioEventLoop.run()方法的执行逻辑

📅 2026-05-27    ✍️ 原创    🏷️ Java / NIO / Netty / 源码分析 / NioEventLoop

在上一篇中,我们讲到 EventLoop 的线程在 doStartThread() 中被启动,随后调用了 SingleThreadEventExecutor.this.run() 方法。

对于 NioEventLoop 来说,这个 run() 方法就是一个死循环。它不仅要处理 IO 事件,还要执行提交过来的各种任务。今天我们就来把这个死循环拆开,看看它到底在忙什么。

一句话剧透:run() 方法的核心逻辑是:计算策略 → 阻塞 select → 按比例处理 IO 和任务。

一、selectStrategy.calculateStrategy()

run() 方法的第一步,是调用 selectStrategy.calculateStrategy():

protected void run() {
    for (;;) {
        int strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());
        switch (strategy) {
            case SelectStrategy.CONTINUE:
                continue;
            case SelectStrategy.BUSY_WAIT:
                // ...
            case SelectStrategy.SELECT:
                select(wakenUp.getAndSet(false));
                break;
            default:
                // ...
        }
        // ...
    }
}

calculateStrategy 的逻辑非常直观:

public int calculateStrategy(IntSupplier selectSupplier, boolean hasTasks) {
    if (hasTasks) {
        // 如果有任务,立即执行一次非阻塞 select
        return selectSupplier.get();
    } else {
        // 否则返回 SELECT,准备阻塞
        return SelectStrategy.SELECT;
    }
}

这里的 selectSupplier 中执行了 selector.selectNow()方法。

💡 关键点:如果当前 EventLoop 有任务需要执行,Netty 绝不会傻傻地阻塞在 select() 上,而是先执行一次 selectNow() 快速检查是否有 IO 事件。

二、selectNow() 与 switch 的跳过

如果执行了 selectNow(),它会返回一个数字(0 到 N),表示当前 Selector 上就绪的 IO 事件数量。

因为返回值不是 SelectStrategy.SELECT,所以 switch 语句会直接走 default 分支,跳过阻塞式的 select,继续执行后面的逻辑。

反之,如果没有任务,calculateStrategy 会返回 SelectStrategy.SELECT,这时才会进入真正的阻塞环节。

三、阻塞 select 的时间计算与无限循环

当进入 select(wakenUp.getAndSet(false)) 时,Netty 会计算一个阻塞超时时间:

long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);

delayNanos方法在当前EventLoop没有定时任务时,默认返回一秒,如果存在定时任务,返回(最近一个定时任务该执行的时间 - 当前时间)。这是因为EventLoop希望最多阻塞到“最近一个定时任务该执行的时间点”,不能一直阻塞下去,否则定时任务会饿死。

Netty 写了一个无限循环来处理 select,并监控 JDK 空轮询 Bug:

for (;;) {
    // 计算剩余的阻塞毫秒数
    long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
    
    // 如果已经超时,直接跳出循环
    if (timeoutMillis <= 0) {
        break;
    }

    // 执行 JDK 的阻塞 select
    selector.select(timeoutMillis);
    
    // 检查是否需要退出循环
    if (wakenUp.get() || hasTasks() || hasScheduledTasks()) {
        break;
    }
    
    // 检查是否发生空轮询 Bug
    if (++selectCnt > minSelectTimeoutCount) {
        rebuildSelector();
        break;
    }
}

这里为什么要 + 500000L(0.5ms)?

这是一个非常细节的优化点。System.nanoTime() 的精度和 selector.select(timeout) 的实现存在微小差异。

  • Netty 计算的是精确的纳秒级截止时间
  • JDK 的 select(timeout) 是毫秒级

在将纳秒转为毫秒时,直接除以1000000会导致小数部分被截断。加上 0.5ms(500000L 纳秒)是为了尽量补偿 JDK 计时精度的误差

为什么要有这个无限循环?
因为 JDK 的 NIO 存在一个著名的 Bug:空轮询(Epoll Bug)。在某些场景下,Selector 会立即返回,导致 CPU 100% 空转。

四、空轮询 Bug 与 Selector 重建

Netty 的解决方案非常暴力且有效:

  • 记录 select 循环的次数 selectCnt
  • 如果在一定时间内(默认 512 毫秒),循环次数超过了阈值(默认 512 次)
  • Netty 认为发生了空轮询 Bug
  • 调用 rebuildSelector() 重建 Selector
private void rebuildSelector() {
    final Selector oldSelector = selector;
    final Selector newSelector = openSelector();
    
    // 将旧 Selector 上的 Channel 重新注册到新的 Selector 上
    for (SelectionKey key : oldSelector.keys()) {
        key.channel().register(newSelector, key.interestOps(), key.attachment());
    }
    
    selector = newSelector;
    oldSelector.close();
}

五、ioRatio:控制 IO 与任务的时间比例

select 完成后,NioEventLoop就需要处理真正的业务。其中processSelectedKeys方法用于处理IO事件,runAllTasks方法用于处理当前EventLoop上的任务。Netty会需要根据 ioRatio 来决定执行逻辑,用于控制执行IO事件和任务的时间占比。

if (ioRatio == 100) {
    processSelectedKeys();
    runAllTasks();
} else {
    long ioStartTime = System.nanoTime();
    processSelectedKeys();
    long ioTime = System.nanoTime() - ioStartTime;
    runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
ioRatio 行为
100 先处理 IO,再执行所有任务
50(默认) IO 和任务各占一半时间
其他 根据 IO 耗时动态限制任务执行时间

六、processSelectedKeys 的执行逻辑)

这是 Netty 处理 IO 事件的核心。Netty 为了性能,没有直接使用 JDK 原生的 HashSet 来存储 SelectionKey,而是替换成了优化后的数组(SelectedSelectionKeySet)。

1. 入口:processSelectedKeys()

private void processSelectedKeys() {
    if (selectedKeys != null) {
        // 优化版:直接遍历数组(默认走这里)
        processSelectedKeysOptimized();
    } else {
        // 原生版:遍历 Set
        processSelectedKeysPlain(selector.selectedKeys());
    }
}

2. 遍历 SelectionKey 数组

processSelectedKeysOptimized() 会遍历这个数组,并在遍历过程中清空引用以便 GC。

private void processSelectedKeysOptimized() {
    for (int i = 0; i < selectedKeys.size; ++i) {
        SelectionKey k = selectedKeys.keys[i];
        selectedKeys.keys[i] = null; // 置空,帮助 GC

        Object a = k.attachment(); // 取出 attachment
        if (a instanceof AbstractNioChannel) {
            processSelectedKey(k, (AbstractNioChannel) a);
        }
    }
}
关键点:还记得我们在注册 Channel 时,把 Channel 自身(this)作为 attachment 传进去了吗?这里正是取出来的地方。Netty 拿到 Channel 后,才能知道是哪个 Channel 产生了 IO 事件。

3. 处理具体的 IO 事件(processSelectedKey)

这是真正的事件分发中心。Netty 会根据 SelectionKey 的 readyOps(就绪事件)进行分类处理:

private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) {
    int readyOps = k.readyOps();
    
    // 1. 连接事件
    if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
        ch.unsafe().finishConnect();
    }

    // 2. 写事件(之前被暂停,现在缓冲区有空间了)
    if ((readyOps & SelectionKey.OP_WRITE) != 0) {
        ch.unsafe().forceFlush();
    }

    // 3. 读事件 或 接受连接事件
    if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
        ch.unsafe().read();
    }
}
  • 如果是 NioServerSocketChannel,这里会触发 OP_ACCEPT,调用 unsafe.read() 去接收新连接。
  • 如果是 NioSocketChannel,这里会触发 OP_READ,调用 unsafe.read() 去读取数据。

七、runAllTasks 的执行逻辑

IO 事件处理完后,EventLoop 会开始执行积压的任务。这些任务主要分为三类:

任务类型 说明
普通任务 用户代码提交的任务(如 channel.write()、execute(Runnable))
定时任务 通过 schedule() 提交的延时任务
收尾任务 任务执行完后的清理工作

1. 任务队列的结构

Netty 使用 MpscQueue(多生产者单消费者队列) 来保证线程安全。这意味着多个外部线程可以安全地往队列里扔任务,而 EventLoop 线程自己是唯一的消费者。

2. 核心执行逻辑

protected boolean runAllTasks(long timeoutNanos) {
    fetchFromScheduledTaskQueue(); // 1. 把到期的定时任务移到普通队列
    Runnable task = pollTask();    // 2. 从队列取任务
    
    long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
    long runTasks = 0;
    
    while (task != null) {
        task.run(); // 3. 执行任务
        runTasks++;
        
        // 4. 每执行 64 个任务检查一次时间
        if ((runTasks & 0x3F) == 0) {
            if (deadline <= ScheduledFutureTask.nanoTime()) {
                break; // 超时了,停止执行
            }
        }
        task = pollTask(); // 5. 取下一个
    }
    return runTasks > 0;
}
💡 非常重要的细节:64 任务保护机制
Netty 不会一次性把所有任务执行完。因为如果有海量的普通任务,会导致 IO 事件迟迟得不到处理(饿死)。所以 Netty 规定:每执行 64 个任务,就检查一次是否超时。如果超时了,哪怕队列里还有任务,也必须停下来,先回去处理 IO 事件。

八、总结

NioEventLoop.run() 的完整闭环如下:

步骤 核心动作
1. 策略计算 有任务 → selectNow;无任务 → 阻塞 select
2. 防空轮询 循环过快 → rebuildSelector()
3. 处理 IO 遍历 SelectionKey → 根据 readyOps 调用 unsafe.read() / write()
4. 执行任务 从 MpscQueue 取任务,每 64 个检查一次时间
下篇预告:在 processSelectedKeys 中,服务端会触发 OP_ACCEPT 事件。那么,Netty 是如何接收这个新连接,并将其注册到 ChildGroup 中的?我们下一篇揭晓。

— 原创文章,转载请注明出处 —

posted @ 2026-05-29 14:59  小金虫  阅读(18)  评论(0)    收藏  举报