两篇文章看懂EventLoopGroup,EventLoop的设计和运行机制(一)

前言

学习初衷

今天分析的都是netty的内容,但是我自己还没有真正用netty实战过,我主要在用vert.x,一直想把vert.x的架构设计和线程模型搞得明明白白的,之前也看过一些源码,但我觉得没有彻彻底底地搞清楚,这次花点心思搞明白。但线程模型里面最重要的就是eventLoop了,所以最近一直在学习。
最核心的类就是NIOEventLoop了,也搜索过很多博客,里面也有不少将这个类从头到尾一行一行代码分析地明明白白的。但是这样又有什么用呢?这样就算是学会了EventLoop了吗?远远不够。

学习核心

学习EventLoop的话,核心有如下几点:

  1. 它的设计需求是什么。
  2. 它的类图是如何设计的?每一个接口或者抽象类的职责是什么,它们之间的区别和联系是啥?
  3. 它的内部运行机制(最好用一张图能表达清楚)。
  4. 它是如何把JAVA的NIO结合在自己的框架里面的。

第一点:很重要,任何一种框架都有基本的设计需求,了解需求才能更好的理解整个框架。但是我们怎么可能知道它的需求呢?确实不容易,能查就查点,其它的就靠多学习多实践去体会了。
第二点:因为它的类图比较复杂,想学好它,就要做到这点。
第三点:如果你经常用它,它的内部运行机制必须要清楚,当你提交了一个任务,脑海里面立马能出现它的执行画面,做到心中有数,完全不慌。没有什么是一张图表达不清楚的,如果不能,说明理解的不到位。
第四点:它基于Java原生的NIO,但是也做了很多封装。那它到底做了什么?为什么要做这些工作?

学习它的每一行代码是为了上面这几点服务的,能够让自己的理解更加到位,说的就是更简单一点就是总结;说实话,我虽然也看了很多遍源码,当时也都看明白了,但是过几天好多细节就忘光光了。对我而言主要是可能大脑觉得没有什么用,就自动遗忘了。事实也是如此,但是如果说自己做了总结,把非常关键的设计和机制了解清楚,然后以可视化的方式表达出来,这效果可能非常好。

目前自己可能只对二、三比较了解,这和自己的学习初衷有关系,我是来研究vert.x的,不是来学习netty的,搞清楚这几点就够了。不过第四点,我觉得后续还是有必要学习的。当了解后面几点了,就可以去反推它的设计需求了,然后跟自己的理解不断碰撞,反复推敲,然后一瞬间恍然大悟~~~

类图的学习

先放一张所有博客都会出现的图吧:
在这里插入图片描述
确实很复杂,但是很遗憾,我几乎没有看见过对这个类图进行详细分析的博客,仅仅只是讲NIOEventLoop这个类的核心代码,包括比较火爆的netty书籍《Netty权威指南(第2版)》也没有。但是你不觉得这很重要吗?道行比较深的人,只要把类图分析清楚了,它的设计和机制基本也了然于胸了。
当然我也没有做到,刚开始我也打算这样做,但是因为类图中的一个点,觉得不可能思议,根本不合理,就放弃了,也是先看了NIOEventLoop类的所有代码以后,才回头去分析的。当然,最后也解答了内心的疑惑,后面会提到。
那么如何来分析这个类图呢?
我推荐有两个步骤:删减和增补。
删减就是把最下面的实现类全部删除掉,仅仅保留最上面的一些接口定义,比如:
在这里插入图片描述
就是EventExecutorGroup,这个接口是netty自定义的第一个接口,然后看了一下这个接口,我发现它本身就有自己的实现,并不是NIOEventLoopGroup,所以我又采取了增补的方式,把上面的类图补充了一下:
在这里插入图片描述
(话说IDEA的类图生成功能还是可以的)
从这个图里面就能够清晰的看到有两条线:

NIOEventLoopGroup,NIOEventLoop
以及
DefaultEventExecutorGroup,DefaultEventExecutor
对应的接口定义分别是:
EventLoopGroup,EventLoop
EventExecutorGroup,EventExecutor
并且从他们的层次关系上面可以看出来,前者都是继承了后者的。
所以我的结论来了:先分析和学习EventExecutorGroup,EventExecutor,把这个学懂了,再学习EventLoop。一定会事半功倍。

当然接下来我先不接着分析类图了,提上面的类图只是想引出EventExecutor,然后再提出我学习这块的总结,因为里面包含了它,直接提出来感觉有点突兀(捂脸表情)。
所以接下来先说结论,结论说完了,再说我的分析和学习过程。

先上结论:EventLoop的运行机制

这边重点给大家分享一下它的运行机制,本篇幅不会分享太多细节的东西,因此结论里面有些内容看不懂的话,可以先看后面的具体分析。

EventExecutorGroup和EventExecutor

简单来说,EventExecutorGroup就是一个线程池,EventExecutor是它里面执行任务的最小单元事件执行器。
它的运行机制图如下:
在这里插入图片描述
这两个接口的默认实现上面提到过,上面的机制肯定来自于具体的实现类,可以对照这具体实现类进行看。
解释一下图里面的重点内容:

  1. EventExecutorGroup里面包含了n个EventExecutor,n需要在初始化的时候就指定。
  2. 在EventExecutorGroup提交的任务,它都会选择组内一个EventExecutor(顺序轮询选择)去执行,所以运行机制的重点是EventExecutor。
  3. EventExecutor内部会和一个线程进行关联,当提交第一个任务的时候,线程启动并且不停运行,除非外界执行关闭操作。
  4. EventExecutor内部有两个队列:taskQueue和scheduleTaskQueue,前者是一个阻塞队列,存储提交的普通task(用execute或者submit方法提交的);后者是一个优先级队列(线程不安全),存储的是调度任务(用schedule方法提交的),它会把最早应该执行的任务放在队首,保证peek出来的一定是队列优先级最高的。
  5. 提交普通task的时候,直接加进队列;提交定时任务的时候,当前线程是关联线程的话,加到调度队列中,否则提交一个普通task,task的内容就是:把调度任务加入调度队列中。
  6. 关联的线程一直在循环做一件事情:取任务,然后执行。
  7. 取任务的过程,图中应该比较清晰,它的具体代码实现是在类SingleThreadEventExecutor的takeTask()方法,可以结合在一起看。

关于组和成员的思考

EventExecutorGroup是个组,而EventExecutor是组里面的成员,那么它是如何来管理的呢?这也是我在学习过程中比较关心的点,也是我刚开始看类图的时候比较疑惑的点。

特点描述

组和成员之间的关系有如下特点:

  • 组拥有成员的一部分功能
    • 其中一些功能的具体实现需要依靠成员去完成,比如提交任务
    • 其中一些功能与成员表示的含义层次不同,比如关闭等方法。
  • 成员拥有组的所有功能
  • 成员比组多一些额外的功能

基于如上特点,才有了EventExecutorGroup与EventExecutor的继承关系。
当组的功能大于成员,并且放在成员上特别不合适的时候(比如类似于next和iterator这些的,成员方法实现这些方法的时候,直接返回的就是本身),继承关系是不是就应该反过来了,或许吧,还没有见过。

最大疑惑

刚开始看类图的时候,我最想不明白的地方在于它们的继承关系,我的直觉上认为正确的应该是反着的:EventExecutorGroup应该继承EventExecutor。所以才导致分析类图非常辛苦。

根源在于我平时经常使用的策略模式,策略模式它必须有一个上下文对象来维护对实际策略的一个引用。然后外界通过调用上下文的某个方法然后去真正执行实际的策略。
为了更方便直观的调用(暂且就这个理由吧),我通常喜欢上下文也实现一下策略接口,这样的话,隐隐约约就会出现一种感觉:上下文在管理着策略;当策略更具体一些(具体的应用场景)的时候,这种感觉就会明显。
这种感觉导致的是:管理者需要实现成员的接口。这也是我刚看这边类图的时候的最大的疑惑。
最后硬着头皮学习完以后,我觉得我理解了。上面有关策略模式的感觉是错误的,它并没有存在一种管理的含义,仅仅只是一种聚合而已,更像是一种代理。和这边组以及成员的关系不一样。

不过是不是也会有“当组的功能大于成员”的情况呢?继承关系是会反过来的吧。

EventLoopGroup与EventLoop的关系也是如此。

ThreadPoolExecutor的运行机制

当然我意识到EventExecutorGroup是一个线程池的时候,脑子里面立马就想起来了ThreadPoolExecutor,这不就是我们经常用的线程池吗?那他们有什么区别呢。我就顺便做了一下对比。
能用一张图说明白的事情,绝不大篇幅描述;先上一个图:
在这里插入图片描述
关于这个类的分析,包括运行机制和每一行的代码,博客还是比较多的,大家说的都挺对的。我不做具体分析了,简单强调几个我认为比较重要的点吧。

  • 线程分为核心和非核心,但是它并没有真正去赋予线程这样一个属性,而且当每一个线程执行任务结束以后通过当前运行的线程数与核心线程数的大小比较而得来的,运行的多了,那你这个线程就是非核心的,反之,你就是核心线程。
  • 针对不同线程类型,它们从队列里面取任务的策略也会有所不同,默认情况下,核心线程会使用take()方法无限阻塞从队列取任务,取不出来我就不行,一直等着。非核心线程会使用pool(long,TimeUnit)方法进行超时获取,超时时间是初始化时候配置的KeepAliveTime,如果超过这个点,还没有取到任务,说明任务有点少,那么当前线程就可以销毁了。这也就是最大存活时间了。
  • 但是如果将属性allowCoreThreadTimeOut置为true的话,核心线程的特殊待遇也就没有了,所有线程都一样了,都会超时获取,然后销毁。但一般不会这样配置的。

但是通过图解和解释,也不一定能够真正用懂线程池,看一下Java推荐的用法吧:
在这里插入图片描述
如果我们自己去配置参数,其实还是不太容易的,但是有一些属性,我们可以获取到,比如当
corePooSize=x,maxPoolSize=y,queue.size=z
那么你这个线程池正常情况下,会有x个线程去处理,也就是最多有x个任务正在执行,其它的都在等着。
任务比较多的时候,同时提交的任务超过了(x+z)个,那么同时可能会有x<n<=y个线程正在执行,n个任务被执行。但是这种配置仅支持(y+z)个任务在同一时刻执行。
说的也挺抽象的,但是可能会有一些用,比如配置tomcat或者quartz的参数的时候,如果自己要配置的话,根据自己情况调整吧。
当然支持(y+z)个任务执行并不是每s可以支撑这么多,它指的是同一时刻,那每秒有多少的话,就看任务的执行时间,比如每个任务只要10ms,那每秒就能处理100*(y+z)个任务。

EventExecutorGroup和ThreadPoolExecutor的比较

还是上图,相同点的话,并不是很严格,不用太纠结,哈哈。
在这里插入图片描述
都在图里面了,就不解释了。
既然有两个线程池了,那么应该用哪个呀?当然是ThreadPoolExecutor了,毫不犹豫。EventExecutorGroup毕竟是netty的实现,非netty的地方,应该用不到它,而且它在netty里面的使用场景也不多,确实有,但是那块还没有仔细看过。从功能上来说,还是jdk提供的功能多一些。这个一点都不纠结。

EventLoopGroup和EventLoop

前面的学习完了,然后再来分析EventLoop,这也是我实际的学习过程。
EventLoop里面会出现一个多路复用器(选择器)Selector,这个是Java中Nio的内容,这边学习的话,一定要至少把NIO的server端和client端的交互的demo跑一跑,再顺便理解一下几种常见的IO模型,再来学习会比较合适。这边不解释NIO的一些概念。
老样子,先上个图:
在这里插入图片描述
(图中暂时忽略了调度任务,对于这个类而言重点是处理IO和非IO,尽管它对定时任务的处理方式和上面讲的EventExecutor的方式不一样)

这边强调一下,虽然讲的是EventLoop,但是它这个接口里面并没有定义任何有关多路复用器的相关方法,虽然有几个注册通道的方法,但和多路复用器并没有直接的联系,这一点我还没有去深究。我最起码看到过,netty在使用的时候,有的接口定义的虽然是EventLoop,但是在使用的时候,会强转成NIOEventLoop,比如AbstractNioChannel。
这边强调一下图中的关键内容:

  • EventLoop里面关键属性有两个,多路复用器Selector和任务队列。可以把通道(Channel)注册在多路复用器上面,可以不断轮询其中的事件然后执行。任务队列存储提交的task。
  • EventLoop处理的事件(叫任务也行,事件更加贴切吧)整体上有两种:
    • IO事件。当一个EventLoop所关联的多路复用器上面注册的通道发生“连接、接收(Acceptor)、读、写”事件的时候,就相当于触发了IO事件。一般也就两种场景:作为server端的时候,监听一个端口,别人来访问你的端口,就会先触发接收事件,然后读取,写入事件。作为client端的时候,要和目标连接,连接成功以后就会触发连接事件,然后写入,读取事件。(场景简化了一下)
    • 非IO事件。这边又分为两种:
      • 普通任务。使用execute提交的任务,直接执行的。
      • 调度任务。使用schedule提交的任务,一般需要延迟或者周期性执行的。
  • EventLoop在执行的时候,也是无线循环,循环体内主要有3件事:阻塞轮询、执行IO事件和执行非IO事件。
    • 若当前没有任务非IO事件(普通任务)需要执行,且在0.5s内没有需要执行的调度任务的时候,先会进入一个无限循环,里面会调用多路复用器的select(long)方法进行阻塞超时轮询,阻塞超时默认是1s或者有定时任务的话,就取定时任务应该执行的时间与当前时间的间隔为超时时间(意思就是,我超时结束的时候,最早的定时任务刚好可以执行了)。
    • 多路复用器的阻塞超时轮询,并不会一直等到超时,有多种方式可以唤醒它:
      • 多路复用器已经准备好了至少一个事件;基本上就是有IO事件的话,就直接返回了,不会阻塞。
      • 使用wakeup方法。当其它线程调用的时候,会立刻唤醒正在阻塞轮询多路复用器的线程。而EventLoop也是利用了这一点,当有新的任务提交进来,并且当前情况满足4个条件的话,就会执行wakeUp。条件很好满足。而且其中某些条件就是在判断是不是在做阻塞轮询,如果是的话,才会去唤醒。
      • 当正在阻塞轮询的时候,有新的非IO任务进来的话,就会立刻唤醒。和上一点是一回事,换了一种说法。
      • 这边也有一个骚操作,它在执行一些中断操作的时候,会提交一个空任务来唤醒。
      • 超时时间到。
      • 当前线程被中断。后面这两种没有什么可说的。
    • 它的这种唤醒机制,保证了不会影响到任何事件。但是仔细想想,这也是应该的,毕竟是它实在没有事情做的时候,才回去阻塞轮询,因为对于NIO而已,根本不需要进行阻塞,你去忙你的,忙完了回来叫我,我都给你准备好了,你忙你的,我做我的,相互不影响(你=eventLoop,我=多路复用器)。正因为如此,它的代码实现上面,对于跳出无限阻塞轮询(阻塞轮询外层有个无限循环)的条件也是非常开放(不知道怎么描述了),很容易就跳出了,可以看看代码。
    • 阻塞轮询完了或者根本不需要阻塞轮询的(有非IO事件),就要处理事件了。它这边有个IO比例,默认是50,就是IO:非IO=50:50,比如处理IO的时间是100ms,那么处理非IO的时间最大也得是100ms,但是它并没有强行去限制,也确实不好做。它仅仅只是在每执行64个非IO事件以后去判断一下这个时间,超了的话,就停下来。64,也不知道是怎么定义的,说实话我觉得挺多的,太小的话,是不是就会影响到非IO任务的执行了呢?还有这个IO比例,当=100的时候,就完全忽略了时间比,每轮询一次,就会把剩余的所有非IO全部执行完。既然都是IO比例了,这种情况就不应该是只执行IO吗?只执行IO肯定不对,但是这个实现和对应的情况实在是不搭呀,理解不了。或许是因为有些事情我还没有理解透彻。
    • 执行IO的时候,就是把所有轮询到的事件,挨个去执行。这块就是我开篇提到的第四个核心,不过我还没有细看(主要是挺复杂的,不花点事情是搞不明白的),就不说了。反正是一个一个执行IO事件,而且肯定是用当前线程去执行,但是肯定不会花太多时间去处理完的,到最后一定会交给另外一个EventLoopGroup,这也是标准的Reactor模型。netty服务端启动的时候,需要提供两个EventLoopGroup,也是这个作用吧,我猜的。
    • 执行非IO的时候,先把调度队列中所有到期的取出来放进任务队列中,然后挨个去执行。一个是全部执行完,一个有时间限制。执行完了以后,会执行tailTasks队列里面的任务,这个设计不知道用来干嘛的,意思就是每一次轮询结束,就去执行一下。感觉没有什么用呀。
    • 结束以后,下一波轮询又开始了。
  • 它再内部阻塞轮询多路复用器的时候,也修复了JDK的epoll bug。
    • bug描述:它会导致Selector空轮询,IO线程CPU 100%,严重影响系统的安全性和可靠性。
    • 修复思路:
      • 根据该BUG的特征,首先侦测该BUG是否发生:正常情况下,开始时间+阻塞轮询时间<=当前时间;这个是正常的;但是如果反过来的话,就不正常了。实际上阻塞的时间比预期的时间会小,不符合javadoc的描述,就认为做了一次空轮询。当空轮询次数超过默认值512次时,就去重新构建多路复用器。
      • 将问题Selector上注册的Channel转移到新建的Selector上
      • 老的问题Selector关闭,使用新建的Selector替换
  • 它内部还有一个比较重要的原子性的布尔值:wakeUp。它是用来确定是否需要唤醒正在使用阻塞轮询多路复用器的线程(就是EventLoop的线程)。
    • true:代表应该被唤醒或者已经被唤醒了(它有的地方会判断为ture的时候,会立即唤醒,之后也不会修改它的状态)
    • false:代表应该去阻塞轮询了或者正在阻塞轮询。
    • 修改的它的位置有3个:
      • 开始打算轮询的时候,会置为false(select(boolean)方法)。代表我马上要阻塞轮询了。
      • 在无限轮询的循环体内,每次都会判断:有新任务并且是false的时候,会置为true,然后跳出。这个应该是来解决,当添加任务不满足4个条件的时候,就不会触发唤醒;这个是每次阻塞轮询前判断,也就是有的任务添加进来,虽然不会立即唤醒阻塞轮询线程,但是当阻塞结束的时候,它一定就会跳出循环。结束,有新任务进来了。
      • 添加任务的时候,如果是false,会置为true。wakeup(boolean inEventLoop)。添加任务需要触发唤醒,需要满足4个条件。

到这边,EventLoop的运行机制应该是讲清楚了吧,虽然说好多细节没有体现,也没有分析代码。但是所有的机制,基本上我都是看了很多遍代码才得来的结论。不过还是觉得最起码有两点可以继续研究一下,比如NIO那边的具体处理逻辑,以及整体的关闭策略,毕竟人家是优化了JDK的关闭接口的。

看下一篇吧

篇幅有点多了,本来还想继续分享类图的,也就是我实际的阅读代码的过程,再写一篇分享吧。

猜你喜欢

转载自blog.csdn.net/ywg_1994/article/details/103796821