NioEventLoop可以说是Netty的发动机,一个Netty服务端往往只用几个EventLoop线程就撑起了成千上万的连接。这个线程要做的事情其实不少:一方面它要不停地轮询Selector,处理OP_READ、OP_WRITE、OP_ACCEPT这些IO事件;另一方面,业务代码通过eventLoop().execute()提交过来的普通任务、以及通过schedule()提交的定时任务,也得靠它来执行。那么问题来了,IO事件和任务队列的执行时间到底怎么分配?如果某一边占用了太多时间,另一边岂不是要被饿死?Netty给出的答案是一个叫ioRatio的参数,下面我们结合源码把这套调度机制彻底讲透。

一、NioEventLoop的运行骨架:run方法的三步循环
先看整体结构。每个NioEventLoop在被线程启动后,就会进入run()方法的死循环,直到线程被关闭。这个循环每转一圈,都固定做三件事:选择就绪的IO事件、处理IO事件、执行任务队列中的任务。核心代码简化后如下:
protected void run() {
for (;;) {
try {
// 第一步:轮询Selector,得到就绪的IO事件数量
int strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());
switch (strategy) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
// 没有任务时才阻塞select,避免空轮询浪费CPU
select(wakenUp.get() == 1);
strategy = SelectStrategy.SELECT;
}
// 第二步:处理IO事件,受ioRatio控制
processSelectedKeys();
// 第三步:执行任务,同样受ioRatio控制
runAllTasks(getTaskIoRatio(currentTimeNanos()));
} catch (Throwable t) {
handleLoopException(t);
}
// 防止JDK epoll空轮询bug导致CPU飙到100%
rebuildSelector0();
}
}
这里有个很精妙的细节:轮询之前会先判断任务队列里有没有任务。如果有任务待执行,就调用selectNow()做一次非阻塞的select,拿到当前就绪的事件马上往下走,绝不阻塞;如果队列是空的,才执行带超时的select()阻塞等待。这样做的好处是,任务永远不会因为线程阻塞在select上而得不到执行,这是一种典型的权衡设计。
另外补充一点,老版本Netty中防JDK空轮询bug的逻辑是单独写的:如果一次select操作的阻塞时间远小于设定的超时时间(说明select提前返回且没有事件),就累计计数,超过512次就重建Selector。新版本把这个逻辑内聚到了Selector优化里,但思路是一样的——保证循环不会空转烧CPU。
二、ioRatio如何控制IO事件与任务的执行时间分配
真正的时间分配发生在processSelectedKeys和runAllTasks之间,而中间的裁判就是ioRatio。这个值可以通过NioEventLoopGroup的构造参数设置,取值范围是1到100的整数,含义是处理IO事件的时间占总时间的比例:
private static final int DEFAULT_IO_RATIO = 50; // 默认各占一半
private long processSelectedKeys() {
if (selectedKeys != null) {
return processSelectedKeysOptimized();
} else {
return processSelectedKeysPlain(selector);
}
}
protected void runAllTasks(long timeoutNanos) {
// 把定时任务队列中到期的任务搬到普通任务队列
fetchFromScheduledTaskQueue();
Runnable task = pollTask();
if (task == null) {
return;
}
// 计算本次执行任务的截止时间,超时就停止执行
final long deadline = ScheduledFutureTask.deadlineNanos() + timeoutNanos;
runTasks++;
long runAllTasksStartTime = System.nanoTime();
runAllTasks0(task, deadline);
// 记录任务执行耗时,供下一轮计算IO分配时间
lastExecutionTime = System.nanoTime();
return lastExecutionTime - runAllTasksStartTime;
}
@Override
protected int calculateIoRatio(int defaultIoRatio) {
return defaultIoRatio;
}
当ioRatio等于50(默认值)时,逻辑是:先处理完所有就绪的IO事件并记录耗时ioTime,然后执行任务队列时最多分配相同的时间,也就是runAllTasks(ioTime)。比如处理IO花了3毫秒,那么接下来执行任务最多也花3毫秒,时间一到,剩余任务留到下一轮循环再执行。当ioRatio被设置成100时,处理IO的时间会被忽略,runAllTasks会一口气把任务队列全部清空,不设时间上限。
那ioRatio不是50也不是100的时候呢?比如设成80,Netty会按比例换算:先算出IO处理耗时,再推算出总时间(ioTime除以ioRatio再乘以100),任务执行的时间上限就是总时间减去ioTime。用公式表达就是allowedTaskTime = ioTime * (100 - ioRatio) / ioRatio。所以ioRatio越大,IO事件越受优待,任务能分到的时间片越小;反之任务得到的时间越多。如果你的应用场景里业务任务很轻、网络吞吐是瓶颈,把ioRatio调大是合理的;如果任务里有较重的计算逻辑,就要谨慎调大,避免任务排队越来越长。
还需要注意一点,这个时间控制只是针对runAllTasks(timeoutNanos)这个带超时参数的版本。定时任务调度产生的任务会先被搬运到普通队列,同样受deadline约束。也就是说,任何单个任务如果执行时间超过了deadline,Netty并不会强行中断它,只能等它跑完再判断是否超时。所以千万不要在EventLoop线程里放重计算任务,时间片机制防的是任务数量堆积,防不住单个慢任务。
三、任务队列的内部结构:为什么用mpscQueue
NioEventLoop内部有两个任务队列:一个是普通的任务队列,一个是定时任务队列ScheduledTaskQueue。而普通任务队列用的是JCTools提供的MPSC(Multi Producer Single Consumer)无锁队列。这个选择不是随便做的。
生产者端的情况是:任何一个业务线程都可能调用channel.eventLoop().execute(task)往队列里塞任务,生产者是多个;而消费者只有一个,就是EventLoop绑定的那个线程自己。这正好是MPSC队列最擅长的场景——多生产者无锁写入,单消费者独占读取。相比直接用JDK的LinkedBlockingQueue,MPSC队列在写入端通过CAS操作完成入队,不需要ReentrantLock那样的重量级锁,吞吐量高出不少,而读取端因为只有一个线程访问,连CAS都不需要,直接volatile读即可。
// AbstractEventExecutor中的提交逻辑
@Override
public void execute(Runnable task) {
if (task == null) {
throw new NullPointerException("task");
}
// 判断当前线程是否就是EventLoop线程
boolean inEventLoop = inEventLoop(task);
if (inEventLoop) {
// 自己直接入队,下一轮循环就能执行
addTask(task);
} else {
// 外部线程入队后,尝试唤醒可能阻塞在select上的EventLoop
startThread();
addTask(task);
if (isShutdown() && removeTask(task)) {
reject();
}
}
}
// 外部线程唤醒阻塞中的selector
protected void wakeup(boolean inEventLoop) {
if (!inEventLoop && wakenUp.compareAndSet(false, true)) {
selector.wakeup();
}
}
上面代码里还有一个关键点:外部线程提交任务时,如果EventLoop线程正阻塞在select上等待IO事件,任务岂不是要等到select超时才能执行?Netty的处理是调用selector.wakeup()立刻唤醒阻塞的select,让循环快速走到runAllTasks这一步。并且用一个wakenUp的AtomicBoolean做标记,配合CAS保证并发的多个提交线程里只有一个会真正执行代价不小的wakeup系统调用。
再说说定时任务。ScheduledTaskQueue其实是一个基于deadlineNanos排序的优先级队列(PriorityQueue)。每轮循环执行任务前,EventLoop会把已经到期的定时任务从定时队列搬到普通队列里统一执行。定时任务的到期时间精度也受这个调度结构影响:如果一轮循环里IO事件特别多,下一次select的超时时间会被设置为最近的定时任务到期时间,保证任务不会被过度延迟。
四、实践建议:如何调整ioRatio与任务拆分
理解了原理,落到实践上有几条经验值得参考。第一,默认的ioRatio=50对绝大多数网络应用是够用的,不要盲目调参。只有当你通过监控发现任务队列堆积(比如队列长度持续增长、任务执行延迟变高)或者IO吞吐上不去时,才需要针对性地调整。
第二,重任务必须从EventLoop中剥离。正确的姿势是单独定义一个业务线程池,在ChannelHandler里把耗时的操作丢给业务线程池,处理完再通过channel.eventLoop().execute()或者ctx.writeAndFlush()把结果切回EventLoop线程。直接在handler里写for循环做重计算,等于亲手卡死了这条线上所有连接的IO。
public class BusinessHandler extends SimpleChannelInboundHandler<ByteBuf> {
// 独立的业务线程池,与EventLoop隔离
private static final ExecutorService BIZ_POOL =
Executors.newFixedThreadPool(16);
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
byte[] data = new byte[msg.readableBytes()];
msg.readBytes(data);
BIZ_POOL.execute(() -> {
String result = heavyProcess(data); // 耗时逻辑在业务线程执行
// 结果回到EventLoop线程再写回,保证线程安全
ctx.writeAndFlush(Unpooled.wrappedBuffer(result.getBytes()));
});
}
}
第三,监控方面可以借助Netty自带的统计手段。NioEventLoop记录了ioRatio的执行情况,通过EventLoopGroup的isShuttingDown、任务队列的pendingTasks()等方法可以观察队列堆积情况。如果pendingTasks持续增长,说明任务生产速度超过了EventLoop的消费能力,这时候要么调低ioRatio给任务更多时间片,要么横向扩容worker线程数,要么从架构上把任务拆到外部线程池。
总结一下,NioEventLoop通过一个三步循环加ioRatio时间片机制,优雅地平衡了IO事件处理和任务执行这两类职责;配合MPSC无锁队列和selector wakeup机制,既保证了外部线程提交任务的高并发安全,又保证了任务能被及时执行。理解这套调度模型,不仅能帮你读懂Netty源码,更是排查线上Netty应用卡顿、延迟问题的基本功。
NettyNioEventLoop任务队列修改时间:2026-09-15 05:30:48