2021SC@SDUSC
我们这一篇博客接着上次的博客,主要分析run()方法以及四个重要方法:
-
selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())
-
select(boolean oldWakenUp)
-
processSelectedKeys()
- runAllTasks()
目录
一、selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())
run()
protected void run() {
for (;;) {
try {
try {
// 1、确定处理策略
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
// 2、表示有socket事件,需要进行处理
select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
selector.wakeup();
}
default:
}
} catch (IOException e) {
// selector有异常,则重新创建一个
rebuildSelector0();
handleLoopException(e);
continue;
}
cancelledKeys = 0;
needsToSelectAgain = false;
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
try {
// 3、处理来自客户端或者服务端的socket事件
processSelectedKeys();
} finally {
// 4、处理队列中的task任务
runAllTasks();
}
} else {
final long ioStartTime = System.nanoTime();
try {
// 3、处理来自客户端或者服务端的socket事件
processSelectedKeys();
} finally {
final long ioTime = System.nanoTime() - ioStartTime;
// 4、处理队列中的task任务
runAllTasks(ioTime * (100 - ioRatio) / ioRatio); }
}
} catch (Throwable t) {
handleLoopException(t);
}
// 执行shutdown后的善后逻辑
try {
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
return;
}
}
} catch (Throwable t) {
handleLoopException(t);
}
}
}
run方法中有四个主要的方法,已在上面注释中标出,主要逻辑概括起来就是:先通过select方法探知是否当前channel上有就绪的事件(方法1和方法2),然后处理这些事件(方法3),最后再处理队列中的任务(方法4)
一、selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())
首先简单的看一下方法1:selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())
selectStrategy只有一个默认实现类DefaultSelectStrategy,实现方法如下,如果判断有任务,则走selectSupplier.get()方法,否则直接返回SelectStrategy.SELECT ,进入方法2。
public int calculateStrategy(IntSupplier selectSupplier, boolean hasTasks) throws Exception {
return hasTasks ? selectSupplier.get() : SelectStrategy.SELECT;
}
然后看一下匿名类selectSupplier.get方法中的逻辑,如下,可以看到它直接调的非阻塞select方法。
private final IntSupplier selectNowSupplier = new IntSupplier() {
@Override
public int get() throws Exception {
return selectNow();
}
};
分析calculateStrategy方法这样做的原因:从run方法的整体顺序中可以看到,每次循环中都是先执行方法3处理channel事件,再执行方法4处理队列中的任务,即处理channel事件的优先级更高。但如果队列中有任务待处理,那么为提高框架处理性能,就不允许执行阻塞的select方法,而是执行非阻塞的selectNow方法,这样就能快速处理完channel事件后去处理队列中的任务。
二、select(boolean oldWakenUp)
再来看select(boolean oldWakenUp)方法。
调用部分代码如下:
case SelectStrategy.SELECT:
// 2、表示有socket事件,需要进行处理
select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
selector.wakeup();
}
我们首先看一下这个函数的参数:wakenUp.getAndSet(false),要理解这个方法,我们先看一下wakenUp变量和wakeup方法的作用:
getAndSet(false)在上学期的操作系统的进程同步中学习过,是一个原子指令。wakenUp是AtomicBoolean类型的变量,如果是true,则表示最近调用过wakeup方法,如果是false,则表示最近未调用wakeup方法,另外每次进入select(boolean)方法都会将wakenUp置为false。而wakeup方法是针对selector.select方法设计的,如果调用wakeup方法时处于selector.select阻塞方法中,则会直接唤醒处于selector.select阻塞中的线程,而如果调用wakeup方法时selector不处于selector.select阻塞方法中,则效果是在下一次调selector.select方法时不阻塞。
下面是select(boolean)方法逻辑:
1 private void select(boolean oldWakenUp) throws IOException {
2 Selector selector = this.selector;
3 try {
4 // selectCnt这个变量记录了 循环 select的次数
int selectCnt = 0;
// 记录当前时间
long currentTimeNanos = System.nanoTime();
// 计算出估算的截止时间, 意思是, select()操作不能超过selectDeadLineNanos这个时间, 不让它一直耗着,外面也可能有任务等着当前线程处理
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
7 for (;;) {
8 long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
9 if (timeoutMillis <= 0) {//判断1
10 if (selectCnt == 0) {
11 selector.selectNow();
12 selectCnt = 1;
13 }
14 break;
15 }
16 // 重点1:在调用阻塞的select方法前再判断一遍是否有任务需要处理,这里虽然逻辑不多,但我感觉很细心才可以想到这部分处理
17 if (hasTasks() && wakenUp.compareAndSet(false, true)) {//判断2
18 selector.selectNow();
19 selectCnt = 1;
20 break;
21 }
22 // 调用阻塞的select方法,但设置了超时时间
23 int selectedKeys = selector.select(timeoutMillis);
24 selectCnt ++;
25
26 if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
27 // 有事件;wakenUp之前是true(说明有新任务进入了队列中);wakenUp现在是true(说明有新任务在本方法执行的过程中进来了),有任务 满足以上任意一个都退出循环
28 break;
29 }
30 if (Thread.interrupted()) {
31 // 省略异常日志打印
32 selectCnt = 1;
33 break;
34 }
35
36 long time = System.nanoTime();
37 if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
38 // timeoutMillis elapsed without anything selected.
39 selectCnt = 1;
40 } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
41 selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
42 // 重点2: 说明触发了空轮训,需要做处理
43 selector = selectRebuildSelector(selectCnt);
44 selectCnt = 1;
45 break;
46 }
47 currentTimeNanos = time;
48 }
49 // catch 异常处理省略
50 }
1.我们先看循环中第一个判断部分:
// 计算超时时间
long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
if (timeoutMillis <= 0) {// 如果超时了 , 并且selectCnt==0 , 就进行非阻塞的 select() , break, 跳出for循环
if (selectCnt == 0) {
selector.selectNow();
selectCnt = 1;
}
break;
}
这里首先计算了超时时间,如果超时了 , 并且selectCnt==0 , 就进行非阻塞的 select(),跳出循环。
2.
// 判断任务队列中时候还有别的任务, 如果有任务的话, 进入代码块, 非阻塞的select()
// 线程安全的把 wakenU设置成true,已进入时,我们设置oldWakenUp是false
if (hasTasks() && wakenUp.compareAndSet(false, true)) {
selector.selectNow();
selectCnt = 1;
break;
}
该处逻辑需结合wakenUp变量和wakeup方法来理解。
首先,对wakenUp变量的操作除了run方法外,还有SingleThreadEventExecutor的execute方法。execute中添加完task后,会调用在NioEventLoop中重写的wakeup方法:
protected void wakeup(boolean inEventLoop) {
if (!inEventLoop && wakenUp.compareAndSet(false, true)) {
//用于唤醒被selector.select()或者selector.select(long time)阻塞的selector,让其立马返回key的数量。
selector.wakeup();
}
}
这个方法将wakeUp设置为true,而在我们select()调用前,是将这个变量的值设为false。
这部分判断代码是判断如果任务队列中时候还有别的任务, 并且wakeUp变量等于true就进入代码块, 调用非阻塞的select,同时原子的把 wakeUp设置成true表示最近调用过wakeup方法。
但这部分,让我有疑惑的是,我们刚刚分析了,除了进入这部分代码时,会将wakeUp变量设置为false,再添加任务时,正如我们上面代码显示的,会将这个变量设置为true,所以一旦前面hasTasks()为真,说明已经添加过任务,那么后面的这个变量肯定不等于false,那么这个判断条件按理说应该是矛盾的。
后来我联系上学期学的操作系统的知识,可能存在一种情况是添加任务后,还没有来得及将变量设置成true,这个线程的时间片就到了,所以此时的变量值还是false。这部分代码是专门处理这种情况的。那为什么如果将变量设置成true就不用再进行这个方法了?因为正如上面的代码所示,如果设置为true,则已经执行过wakeup()方法,不需要再进行一次了。
3.这部分就是阻塞的select方法了。
// 上面设置的超时时间没到,而且任务为空,进行阻塞式的 select() , timeoutMillis 默认1
// netty任务,现在可以阻塞1秒去轮询channel连接上是否发生的selector感兴趣的事件
int selectedKeys = selector.select(timeoutMillis);
// 表示当前已经轮询了SelectCnt次了
selectCnt++;
// 阻塞完成轮询后,马上进一步判断 只要满足下面的任意一条. 也将退出无限for循环, select()
// selectedKeys != 0 表示轮询到了事件
// oldWakenUp 当前的操作是否需要唤醒
// wakenUp.get() 可能被外部线程唤醒
// hasTasks() 任务队列中又有新任务了
// hasScheduledTasks() 当时定时任务队列里面也有任务
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
break;
}
上面设置的timeoutMillis是我们设置的时间,因为如果执行到这里说明没有要处理的任务,所以我们可以十分放心的再有时间控制的情况下进行轮询。在轮训结束后,我们进行判断,只要出现了下面五个情况之一,就跳出循环,不再进行下面的操作。五种情况如下:
- selectedKeys != 0 表示轮询到了事件
- oldWakenUp 当前的操作是否需要唤醒
- wakenUp.get() 可能被外部线程唤醒
- hasTasks() 任务队列中又有新任务了
- hasScheduledTasks() 当时定时任务队列里面也有任务
4. 如果进行了一次阻塞式的select还没有监听到感兴趣的事件,会执行如下部分的代码:
// 每次执行到这里就说明,已经进行了一次阻塞式操作 ,并且还没有监听到任何感兴趣的事件,也没有新的任务添加到队列
long time = System.nanoTime();
//如果 当前的时间-超时时间 >= 开始时间 把 selectCnt设置为1 , 表明已经进行了一次阻塞式操作
// 我们已经进行了一次时长为timeoutMillis的阻塞式select了,按理说此时应该 "时间 - 开始的时间 >= 超时的时间",但是如果出现了 "当前时间- 超时时间< 开始时间", 说明并没有阻塞select, 而是立即返回了, 就表明这是一次空轮询
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) {
// selectCnt如果大于 512 表示cpu确实在空轮询, 于是rebuild Selector
logger.warn(
"Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
selectCnt, selector);
//它的逻辑创建一个新的selectKey , 把老的Selector上面的key注册进这个新的selector上面 , 进入查看
rebuildSelector();
selector = this.selector;
// Select again to populate selectedKeys.
// 解决了Select空轮询的问题
selector.selectNow();
selectCnt = 1;
break;
}
这部分我们可以看到有一个对于 " 当前的时间-开始时间 " 和 " 超时时间 " 的大小判断。因为我们已经进行了一次时长为timeoutMillis的阻塞式select了,按理说此时应该 "时间 - 开始的时间 >= 超时的时间",但是如果出现了 "当前时间- 超时时间< 开始时间", 说明并没有阻塞select, 而是立即返回了, 就表明这是一次空轮询。
什么是空轮询呢?
空轮询即调select(time)/select()阻塞方法的时候,由于出现了bug导致不阻塞而是直接返回空结果,并且后面每次都这样,执行非常的“畅通”,最终可能导致cpu的使用率非常高。
对于这个bug产生的原因,我查阅了相关的资料,下面的这个说法,是我比较理解的一种:
这个bug的描述内容为,在NIO的selector中,即使是关注的select轮询事件的key为0的话,NIO照样不断的从select本应该阻塞的情况中wake up出来(这就是一个大问题,正常是不会出现的)。然后,因为selector的select方法,返回numKeys是0,所以下面本应该对key值进行遍历的事件处理根本执行不了,又回到最上面的while(true)循环,循环往复,不断的轮询,直到系统出现100%的CPU情况,其它执行任务干不了活,最终导致程序崩溃。
那么netty是如何解决这个问题的呢?
过程如下:
- 对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,
- 若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。
- 重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
从代码上具体来看,这个处理其实不是很难理解,我们设置了一个阈值512,如果selectCnt计数达到了512,说明触发了空轮训,此时 selectRebuildSelector 方法会创建一个新的selector,将原selector上的全部事件重新注册到新selector上。
方法小结:
这个方法我感觉还是比较复杂的,我也是看了很多资料后 ,才逐步理解的。
下面对这个方法的流程进行一个小结:
- 判断1 如果超过了截止时间,selector.selectNow() 直接退出循环
- 判断2 如果任务队列中出现了新的任务selector.selectNow()直接退出循环
- 经过了上面两次判断后,,netty 进行阻塞式select(time),默认是1秒,这时可会会出现空轮询的问题
- 判断3 如果经过阻塞式的轮询之后,出现的感兴趣的事件,或者任务队列又有新任务了,或者定时任务中有新任务了,或者被外部线程唤醒了 都直接退出循环
- 如果前面都没出问题,最后检验是否出现了JDK空轮询的BUG
至此,这个方法的分析终于结束了。
三、processSelectedKeys()
run()的三步中,第一步轮询已经完成了, 下一步就是处理轮询出来的感兴趣的IO事件。
private void processSelectedKeys() {
if (selectedKeys != null) {
processSelectedKeysOptimized();
} else {
processSelectedKeysPlain(selector.selectedKeys());
}
}
因为这一部分的代码较多的涉及了selector的相关方法,在后面详细分析了selector和channel的源码后,将会在新的博客中,对这个方法进行详细的分析。
四、runAllTasks()
runAllTasks()是处理非IO任务的。上面的方法处理IO事件结束后 , run()方法的第三步就来了,负责处理任务队列中的任务, runAllTask(timeOutMinils)也是有生命时长限制的 。
先来回顾一下,进行这个方法的代码:
1 final long ioStartTime = System.nanoTime();
2 try {
3 processSelectedKeys();
4 } finally {
5 // Ensure we always run tasks.
6 final long ioTime = System.nanoTime() - ioStartTime;
7 runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
8 }
看到这部分代码,我首先对于runAllTasks方法的参数产生疑惑,在netty的源码中,没有找到关于这两个变量的注释,于是我查询了一下资料,初步了解了这两个变量的含义。
首先说一下ioRatio变量,此变量控制的是当前线程中处理channel事件和处理任务队列所用的时间比,如果为50(即50%),则二者用的时间相同。从上面代码中可以看出,ioTime即处理channel事件所用的时间,当ioRatio=50时,runAllTasks的入参就是ioTime;而如果ioRatio=10,则runAllTasks入参为9*ioTime,即处理任务队列的最大时间是处理channel事件的9倍。
初始工作了解后,下面进入源码:
1 protected boolean runAllTasks(long timeoutNanos) {
2 // 把定时任务放入普通的任务队列中
fetchFromScheduledTaskQueue();
3 Runnable task = pollTask();
4 if (task == null) {
5 afterRunningAllTasks();
6 return false;
7 }
8 final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
9 long runTasks = 0;
10 long lastExecutionTime;
//for循环执行任务
11 for (;;) {
12 safeExecute(task);
13 runTasks ++;
14 if ((runTasks & 0x3F) == 0) { // 每隔64次计算一下超时时间
15 lastExecutionTime = ScheduledFutureTask.nanoTime();
16 if (lastExecutionTime >= deadline) {
17 break;
18 }
19 }
//拿新的任务
20 task = pollTask();
21 if (task == null) {
22 lastExecutionTime = ScheduledFutureTask.nanoTime();
23 break;
24 }
25 }
26 afterRunningAllTasks();
27 this.lastExecutionTime = lastExecutionTime;
28 return true;
29 }
1. 首先来看第一个方法:fetchFromScheduledTaskQueue()
// 聚合任务, 会把定时任务放入普通的任务队列中
fetchFromScheduledTaskQueue();
聚合任务就是把已经到执行时间的任务从定时任务队列中全部取出 ,放入普通任务队列然后执行。进入该方法源码:
private boolean fetchFromScheduledTaskQueue() {
// 拉取第一个聚合任务
long nanoTime = AbstractScheduledEventExecutor.nanoTime();
// 从任务队列中取出 截止时间是 nanoTime的定时任务 ,
Runnable scheduledTask = pollScheduledTask(nanoTime);
while (scheduledTask != null) {
// scheduledTask != null表示定时任务该被执行了, 于是将定时任务添加到普通任务队列
if (!taskQueue.offer(scheduledTask)) {
//往定时队列中添加 ScheduledFutureTask任务
// 如果添加失败了, 把这个任务从新放入到定时任务队列中, 再尝试添加
scheduledTaskQueue().add((ScheduledFutureTask<?>) scheduledTask);
return false;
}
//循环,尝试拉取定时任务 , 循环结束后,所有的任务全部会被添加到 task里面
scheduledTask = pollScheduledTask(nanoTime);
}
return true;
}
根据指定的截止时间,从定时任务队列中取出任务,定时任务队列中任务按照时间排序,时间越短的,排在前面, 时间相同,按照添加的顺序排序。现在的任务就是检查定时任务队列中任务,尝试把里面的任务挨个取出来,于是netty使用这个方法 Runnable scheduledTask = pollScheduledTask(nanoTime)进行取出,然后在while(){}循环中判断是否被执行过。这个取出定时任务的方法源码如下, 可以看出是在根据有没有到执行时间来决定是否要取出来这个任务。
protected final Runnable pollScheduledTask(long nanoTime) {
assert inEventLoop();
Queue<ScheduledFutureTask<?>> scheduledTaskQueue = this.scheduledTaskQueue;
ScheduledFutureTask<?> scheduledTask = scheduledTaskQueue == null ? null : scheduledTaskQueue.peek();
if (scheduledTask == null) {
return null;
}
// 如果定时任务的截止时间<= 我们传进来的时间, 就把这个任务返回
if (scheduledTask.deadlineNanos() <= nanoTime) {
scheduledTaskQueue.remove();
return scheduledTask;
}
// 否则返回空,表示定时任务没到期, 没有可以执行的
return null;
}
经过while循环之后,到期的任务,全被添加到 taskQueue里面了,下面就是执行TaskQueue里面的任务。
2.从普通队列中拿出一个任务。
// 从普通的队列中拿出一个任务
Runnable task = pollTask();
然后用一个for循环来依次取出任务处理。使用safeExecute(task) 方法,执行任务队列中的任务。实际上就行执行了 task这个Runable的Run方法。
源码如下:
/**
* Try to execute the given {@link Runnable} and just log if it throws a {@link Throwable}.
*/
protected static void safeExecute(Runnable task) {
try {
task.run();
} catch (Throwable t) {
logger.warn("A task raised an exception. Task: {}", task, t);
}
}
为了提高效率,每隔64次计算一下超时时间(获取系统纳秒时间也是一笔性能开支,尽量少的获取)。
// nanoTime()的执行也是个相对耗时的操作,因此每执行完64个任务后,检查有没有超时
if ((runTasks & 0x3F) == 0) {
lastExecutionTime = ScheduledFutureTask.nanoTime();
if (lastExecutionTime >= deadline) {
break;
}
}
处理完后,再拿新的任务。
// 拿新的任务
task = pollTask();
方法小结:
- 第一步是聚合任务, while循环把到期的定时任务都转移到普通任务队列。
- for循环从普通队列获取任务,执行任务。
- 每执行完64个任务,判断是否超时。
最后总结
这也是本次博客第一次完整的分析了一个组件的代码。在这个过程中,从一开始的完全没有头脑,面对众多的代码,不知道如何进行入手。后来通过向老师求助,在老师给我们培训后,有了初步的经验,可以通过写demo先明白这个如何使用,在进行逐步的分析,会更加的直观。在这里也非常感谢指导老师给我们的帮助。其次,在这次的分析中,我也发现了自己的问题。可能因为一开始的整体架构把握的不到位,第一次分析的这个组件,综合性还是比较强的,在后面的分析中明显可以看出有点力不从心,有一些部分还没有学习到,所以对这部分的分析也有一点影响,所以这次将分析分成了三部分(最初打算两部分分析完),在这部分的分析中,也查阅了很多的资料来帮助理解。或许一开始从selector或者channel这类基础一些的组件入手分析会更好一些。在后面的分析中,会加快速度,尽快的分析完核心组件,进入通信协议和私有协议栈开发这一更复杂的部分进行研究。