前言
上篇文章介绍了Netty线程模型的类继承关系和线程组的实例化过程,相信读过上篇文章的读者对Netty的线程模型应该有了一个大致的印象。本篇文章将介绍线程组是如何启动、轮询并处理IO事件和任务的。
NioEventLoop的启动入口
在前面服务端启动章节中,我们画了一个非常长的流程图,在流程图中,注册服务端Channel到Selector的过程中会经过以下逻辑。
less
AbstractUnsafe
public final void register(EventLoop eventLoop, final ChannelPromise promise) {
AbstractChannel.this.eventLoop = eventLoop;
if (eventLoop.inEventLoop()) {
register0(promise);
} else {
eventLoop.execute(new Runnable() {
@Override
public void run() {
register0(promise);
}
});
}
我们来看一下inEventLoop方法
arduino
AbstractEventExecutor.java
@Override
public boolean inEventLoop() {
return inEventLoop(Thread.currentThread());
}
@Override
public boolean inEventLoop(Thread thread) {
return thread == this.thread;
}
可以看到,在Netty调用bind过程中,当前线程是main方法对应的主线程,tihs.thread是NioEventLoop中的变量,此时并未赋值,因此会return false。
另外,我们在Netty的源码中,会在很多地方看到inEventLoop这样的判断,这个方法的本质含义就是判断当前线程是否是Netty的Reactor线程,也就是NioEventLoop对应的线程实体。后面我们会看到,创建一个线程之后,会将这个线程实体保存到thread这个成员变量中。
由于方法返回false,因此会走到eventLoop.execute的逻辑中。通过上一章分析的类图关系,最终会调用到SingleThreadEventExecutor类的doStartThread方法,我们看一下这个方法的核心代码。
csharp
executor.execute(new Runnable() {
@Override
public void run() {
thread = Thread.currentThread();
SingleThreadEventExecutor.this.run();
在执行doStartThread的时候,会调用内部成员变量executor的execute方法,而根据我们在上一章的分析,executor就是ThreadPerTaskExecutor,这个对象的作用就是每次执行runnable的时候,都会先创建一个线程再执行。
在这个runnable中,通过一个成员变量thread来保存ThreadPerTaskExecutor创建出来的线程,这个线程就是我们在上一章中分析的FastThreadLocalThread。至此,我们终于知道,一个NioEventLoop是如何与一个线程实体绑定的:NioEventLoop通过ThreadPerTaskExecutor创建一个FastThreadLocalThread,然后通过一个成员变量来指向这个线程。 NioEventLoop保存完线程的引用之后,随即调用run方法。这个run方法就是Netty Reactor的核心所在。
NioEventLoop的执行流程
读到这里,netty启动流程中boss线程组是如何创建第一个NioEventLoop以及NioEventLoop是如何与实体线程绑定的就已经清楚了,接下来分析一下netty是如何处理线程的Reactor的循环的。后面还会分析一下当新链接接入时,worker对应的NioEventLoop是如何创建对应线程和启动的。
我们来看一下NioEventLoop的run方法
scss
@Override
protected void run() {
for (;;) {
select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
selector.wakeup();}
processSelectedKeys();
runAllTasks();
}
}
为了便于理解,这里只保留核心代码。可以看到,run方法通过for循环,不断执行以下三个步骤:
- 执行一次时间轮询
- 处理产生IO事件的channel
- 执行任务
执行一次事件轮询
wakenUp是NioEventLoop中AtomicBoolean类型的变量,用来控制是否应该唤醒正在阻塞的select操作。我们来看一下select方法
ini
private void select(boolean oldWakenUp) throws IOException {
Selector selector = this.selector;
try {
int selectCnt = 0;
long currentTimeNanos = System.nanoTime();
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
for (;;) {
// 定时任务截止时间中断
long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
if (timeoutMillis <= 0) {
if (selectCnt == 0) {
selector.selectNow();
selectCnt = 1;
}
break;
}
// 有新任务加入中断
if (hasTasks() && wakenUp.compareAndSet(false, true)) {
selector.selectNow();
selectCnt = 1;
break;
}
// 阻塞式select操作
int selectedKeys = selector.select(timeoutMillis);
selectCnt ++;
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
break;
}
if (Thread.interrupted()) {
selectCnt = 1;
break;
}
// 规避JDK的NIO Bug
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
// timeoutMillis elapsed without anything selected.
selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
// The selector returned prematurely many times in a row.
// Rebuild the selector to work around the problem.
logger.warn(
"Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
selectCnt, selector);
rebuildSelector();
selector = this.selector;
// Select again to populate selectedKeys.
selector.selectNow();
selectCnt = 1;
break;
}
currentTimeNanos = time;
}
} catch (CancelledKeyException e) {
}
}
可以看到,这个方法大致分为四个步骤
- 定时任务截止时间中断
- 有新任务加入中断
- 阻塞式select操作
- 规避JDK的NIO Bug
-
定时任务截止时间中断
通过peek()方法取出定时任务队列中头部元素(按执行时间排序)的截止时间,在for循环中判断如果其距截止时间小于0.5ms,则会跳出循环。并在循环结束前至少执行一次selectNow方法。
这里说一下selectNow方法。这个方法调用的是WindowsSelectorImpl这个类的poll方法,而poll方法最终会调用操作系统层面的poll方法。如果读者对操作系统或者I/O多路复用不了解,可以自行搜索学习select、poll、epoll函数。而这里的selectNow方法会设置超时时间为0,即表示立即查看是否有就绪的channel,不阻塞等待。
-
有新任务加入中断
Netty为了保证任务队列里的任务能够及时执行,在进行阻塞select操作的时候会判断任务队列是否为空。如果不为空,就执行一次非阻塞select操作,跳出循环;否则,继续执行下面的操作。
-
阻塞式select操作
执行到这一步,说明Netty任务队列里的队列为空,并且所有定时任务的延迟时间还未到(大于0.5ms)。于是,进行一次阻塞select操作,截止到第一个定时任务的截止时间或者被外部任务唤醒,在外部线程添加任务的时候,会调用wakeup方法来唤醒selector.select(timeoutMillis)。
阻塞select操作结束之后,Netty又做了一系列状态判断来决定是否中断本次轮询。
-
规避JDK的NIO Bug
select操作的最后一步,就是解决JDK的NIO Bug。该Bug会导致Selector一直空轮询,最终导致CPU 100%,NIO Server不可用。
netty会判断每次阻塞select的轮询时间是否大于等于超时时间,如果不大于则有可能触发了JDK空轮询Bug。当空轮询的次数超过一定阈值(默认是512)的时候,就开始重建Selector。重建Selector的方法也很简单,创建一个新的Selector,将之前注册到老的Selector上的Channel重新转移到新的Selector上。感兴趣的读者可以自己去看看。
处理产生IO事件的channel
scss
NioEventLoop
private void processSelectedKeys() {
if (selectedKeys != null) {
processSelectedKeysOptimized(selectedKeys.flip());
} else {
processSelectedKeysPlain(selector.selectedKeys());
}
}
处理IO事件,Netty有两种选择,从名字上看,一种是处理优化过的SelectedKeys,一种是正常处理。 这里被优化处理过的selectedKeys是SelectedSelectionKeySet类的对象。这个对象在openSelector()
方法被调用时就通过反射将sun.nio.ch.SelectorImpl
对象中的两个成员变量publicKeys、publicSelectedKeys绑定。这两个成员变量是两个HashSet。
在SelectedSelectionKeySet中,netty使用数组来添加元素,每次添加时,将SelectionKey塞到该数组的尾部;更新该数组的逻辑长度+1;如果该数组的逻辑长度等于数组的物理长度,就将该数组扩容。待程序运行一段时间后,等数组的长度足够长,每次在轮询到NIO事件的时候,Netty只需要O (1)的时间复杂度就能将SelectionKey塞到set中去,而JDK底层使用的HashSet put的时间复杂度最少是O (1),最差是O (n),使用数组替换掉HashSet还有一个好处是遍历的时候非常高效。
接着来看processSelectedKeysOptimized()方法
scss
private void processSelectedKeysOptimized(SelectionKey[] selectedKeys) {
for (int i = 0;; i ++) {
final SelectionKey k = selectedKeys[i];
if (k == null) {
break;
}
// null out entry in the array to allow to have it GC'ed once the Channel close
// See https://github.com/netty/netty/issues/2363
selectedKeys[i] = null;
final Object a = k.attachment();
if (a instanceof AbstractNioChannel) {
processSelectedKey(k, (AbstractNioChannel) a);
} else {
@SuppressWarnings("unchecked")
NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
processSelectedKey(k, task);
}
if (needsToSelectAgain) {
// null out entries in the array to allow to have it GC'ed once the Channel close
// See https://github.com/netty/netty/issues/2363
for (;;) {
i++;
if (selectedKeys[i] == null) {
break;
}
selectedKeys[i] = null;
}
selectAgain();
// Need to flip the optimized selectedKeys to get the right reference to the array
// and reset the index to -1 which will then set to 0 on the for loop
// to start over again.
//
// See https://github.com/netty/netty/issues/1523
selectedKeys = this.selectedKeys.flip();
i = -1;
}
}
}
-
首先从selectedKeys中取出元素并将其设为null。
这里提一下为什么要设置为null;我们知道数组对其内元素的引用是强引用,如果在这里不设置为null,那么即使SelectionKey无效了(如channel断开),GC也无法回收这一块内存。单个的SelectionKey可能不大,但是别忘了在Selector调用register方法的时候(启动流程),是将AbstractNioChannel作为attachment挂上去的。attachment可能很大,这样一来,这些对象就一直存活,造成JVM无法回收,就会导致内存泄漏。
-
接着取出attachment并判断是否为AbstractNioChannel;
取出SelectionKey对应的AbstractNioChannel进行后续操作。
在Netty的Channel中,有两大类型的Channel,一个是NioServerSocketChannel,由boss NioEventLoopGroup负责处理;一个是NioSocketChannel,由worker NioEventLoop负责处理,所以:
(1)对于boss NioEventLoop来说,轮询到的是连接事件,后续通过NioServerSocketChannel的Pipeline将连接交给一个worker NioEventLoop处理;
(2)对于worker NioEventLoop来说,轮询到的是读写事件,后续通过NioSocketChannel的Pipeline将读取到的数据传递给每个ChannelHandler来处理。
-
最后判断是否需要在进行一次轮询。
Channel从Selector上移除的时候,调用cancel方法将key取消,并且在被取消的key到达CLEANUP_INTERVAL的时候,设置needsToSelectAgain为true,CLEANUP_INTERVAL默认值为256。也就是说,对于每个NioEventLoop而言,每隔256个Channel从Selector上移除的时候,就标记needsToSelectAgain为true。
needsToSelectAgain时,首先,将SelectedKeys的内部数组全部清空,方便JVM垃圾回收,然后调用selectAgain重新填装SelectionKeys数组。
csharp
private void selectAgain() {
needsToSelectAgain = false;
try {
selector.selectNow();
} catch (Throwable t) {
logger.warn("Failed to update SelectionKeys.", t);
}
}
添加任务
在分析netty执行任务之前,先来看一下netty是如何添加任务的
netty添加任务大致有三种场景
- 用户自定义普通任务。
less
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
//...
}
});
execute方法如下
java
SingleThreadEventExecutor.java
@Override
public void execute(Runnable task) {
boolean inEventLoop = inEventLoop();
if (inEventLoop) {
addTask(task);
} else {
addTask(task);
}
}
可以看到,不管是外部线程还是Reactor线程,execute方法都会调用addTask方法。
scss
SingleThreadEventExecutor.java
protected void addTask(Runnable task) {
// ...
if (!offerTask(task)) {
reject(task);
}
}
final boolean offerTask(Runnable task) {
// ...
return taskQueue.offer(task);
}
跟到offerTask方法,这个Task就落地了,Netty内部使用一个taskQueue将Task保存起来。在前面的章节已经分析过这个taskQueue,它其实是一个MPSC Queue(一般通过CAS实现),每一个NioEventLoop都与它一一对应。
Netty使用MPSC Queue,方便将外部线程的异步任务聚集,在Reactor线程内部用单线程来批量执行以提升性能。
- 非当前Reactor线程调用Channel的各类方法
channel.write(...)
在业务线程里,根据用户的标识,找到对应的Channel,然后调用Channel的write类方法向该用户推送消息。最终会调用到以下方法
arduino
AbstractChannelHandlerContext.java
private void write(Object msg, boolean flush, ChannelPromise promise) {
// ...
EventExecutor executor = next.executor();
if (executor.inEventLoop()) {
if (flush) {
next.invokeWriteAndFlush(m, promise);
} else {
next.invokeWrite(m, promise);
}
} else {
AbstractWriteTask task;
if (flush) {
task = WriteAndFlushTask.newInstance(next, m, promise);
} else {
task = WriteTask.newInstance(next, m, promise);
}
safeExecute(executor, task, promise, m);
}
}
arduino
private static void safeExecute(EventExecutor executor, Runnable runnable,
ChannelPromise promise, Object msg) {
// ...
executor.execute(runnable);
// ...
}
最终通过safeExecute方法,走到场景一的逻辑。需要注意的是,这种场景下的任务的发起线程是用户线程,可能会有多个;此时MPSC队列就很适用。
- 用户自定义定时任务
这段代码的作用是60s后执行某个任务。
less
AbstractScheduledEventExecutor.java
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {
}
}, 60, TimeUnit.SECONDS);
netty会将用户自定义任务包装成一个netty内部任务,在通过一个优先级队列将任务按照顺序丢到队列中。并通过以下代码保证不会产生并发问题
less
AbstractScheduledEventExecutor.java
<V> ScheduledFuture<V> schedule(final ScheduledFutureTask<V> task) {
// 如果是Reactor线程,则直接往优先级队列中添加任务
if (inEventLoop()) {
scheduledTaskQueue().add(task);
} else {
// 如果是外部线程,则将添加定时任务这个逻辑进一步封装,退回到场景二
execute(new Runnable() {
@Override
public void run() {
scheduledTaskQueue().add(task);
}
});
}
return task;
}
Queue<ScheduledFutureTask<?>> scheduledTaskQueue() {
if (scheduledTaskQueue == null) {
scheduledTaskQueue = new PriorityQueue<ScheduledFutureTask<?>>();
}
return scheduledTaskQueue
}
如果是在外部线程调用schedule方法,Netty会将添加定时任务这个逻辑封装成一个普通的task,这个task的任务是一个添加"添加定时任务"的任务,而不是添加定时任务,所以其实就退回到第二种场景。
这里在说一下定时任务的排序和执行模式 每个定时任务(ScheduledFutureTask封装的)都有唯一id和执行时间,优先级队列按照执行时间顺序排序,如果执行时间相同,则按照id排序,先入队的任务先执行。
通过periodNanos确定执行模式,periodNanos为0表示一次性任务,大于0表示以固定频率执行任务,小于0表示以固定间隔执行任务(依赖上一次任务完成);
执行任务
这里面会有两个分支,通过ioRatio协调IO操作和任务的执行
scss
NioEventLoop.java
if (ioRatio == 100) {
try {
processSelectedKeys();
} finally {
// Ensure we always run tasks.
runAllTasks();
}
} else {
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys();
} finally {
// Ensure we always run tasks.
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}
直接来分析runAllTasks(long timeoutNanos)方法
ini
SingleThreadEventExecutor.java
fetchFromScheduledTaskQueue();
Runnable task = pollTask();
if (task == null) {
afterRunningAllTasks();
return false;
}
final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
long runTasks = 0;
long lastExecutionTime;
for (;;) {
(task);
runTasks ++;
// Check timeout every 64 tasks because nanoTime() is relatively expensive.
if ((runTasks & 0x3F) == 0) {
lastExecutionTime = ScheduledFutureTask.nanoTime();
if (lastExecutionTime >= deadline) {
break;
}
}
task = pollTask();
if (task == null) {
lastExecutionTime = ScheduledFutureTask.nanoTime();
break;
}
}
afterRunningAllTasks();
this.lastExecutionTime = lastExecutionTime;
return true;
- 通过 fetchFromScheduledTaskQueue方法将优先级队列中(通过peek方法)快要到期的任务转移到MPSC队列中。
- 计算本轮任务执行的截止时间。到了这一步,所有截止时间已经到达的定时任务均被填充到普通任务队列。接下来,Netty会计算一下本轮任务最多可以执行到什么时候。
- 通过safeExecute方法执行任务并每隔64次判断一次是否执行超过截止时间(对于大量小任务场景,不必每次判断,提高性能)。
- 判断本轮任务是否全部执行完成。
总结
1.NioEventLoop在执行过程中不断检测是否有事件发生,如果有事件发生就处理,处理完事件之后再处理外部线程提交过来的异步任务。
2.在检测是否有事件发生的时候,为了保证异步任务的及时处理,只要有任务要处理,就立即停止事件检测,随即处理任务。
3.外部线程异步执行的任务分为两种:定时任务和普通任务,分别落地到MpscQueue和PriorityQueue,而PriorityQueue中的任务最终都会填充到MpscQueue中处理。
4.Netty每隔64个任务检查一次是否该退出任务循环。