Netty核心组件之EventLoop和EventLoopGroup源码分析(三)

2021SC@SDUSC

我们这一篇博客接着上次的博客,主要分析run()方法以及四个重要方法:

  • selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())

  • select(boolean oldWakenUp)

  • processSelectedKeys()

  • runAllTasks()

目录

run()

一、selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())

二、select(boolean oldWakenUp)

三、processSelectedKeys()

四、runAllTasks() 

最后总结


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是如何解决这个问题的呢? 

过程如下:

  1. 对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,
  2. 若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。
  3. 重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
     

从代码上具体来看,这个处理其实不是很难理解,我们设置了一个阈值512,如果selectCnt计数达到了512,说明触发了空轮训,此时 selectRebuildSelector 方法会创建一个新的selector,将原selector上的全部事件重新注册到新selector上。

方法小结

这个方法我感觉还是比较复杂的,我也是看了很多资料后 ,才逐步理解的。

下面对这个方法的流程进行一个小结:

  1. 判断1 如果超过了截止时间,selector.selectNow() 直接退出循环
  2. 判断2 如果任务队列中出现了新的任务selector.selectNow()直接退出循环
  3. 经过了上面两次判断后,,netty 进行阻塞式select(time),默认是1秒,这时可会会出现空轮询的问题
  4. 判断3 如果经过阻塞式的轮询之后,出现的感兴趣的事件,或者任务队列又有新任务了,或者定时任务中有新任务了,或者被外部线程唤醒了 都直接退出循环
  5. 如果前面都没出问题,最后检验是否出现了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();

方法小结:

  1. 第一步是聚合任务, while循环把到期的定时任务都转移到普通任务队列。
  2. for循环从普通队列获取任务,执行任务。
  3. 每执行完64个任务,判断是否超时。

最后总结

   这也是本次博客第一次完整的分析了一个组件的代码。在这个过程中,从一开始的完全没有头脑,面对众多的代码,不知道如何进行入手。后来通过向老师求助,在老师给我们培训后,有了初步的经验,可以通过写demo先明白这个如何使用,在进行逐步的分析,会更加的直观。在这里也非常感谢指导老师给我们的帮助。其次,在这次的分析中,我也发现了自己的问题。可能因为一开始的整体架构把握的不到位,第一次分析的这个组件,综合性还是比较强的,在后面的分析中明显可以看出有点力不从心,有一些部分还没有学习到,所以对这部分的分析也有一点影响,所以这次将分析分成了三部分(最初打算两部分分析完),在这部分的分析中,也查阅了很多的资料来帮助理解。或许一开始从selector或者channel这类基础一些的组件入手分析会更好一些。在后面的分析中,会加快速度,尽快的分析完核心组件,进入通信协议和私有协议栈开发这一更复杂的部分进行研究。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值