Netty 那些事儿 ——— Reactor模式详解

Netty 那些事儿 ——— Reactor模式详解

https://www.jianshu.com/p/1ccbc6a348dbhtml

本文是Netty文集中“Netty 那些事儿”系列的文章。主要结合在开发实战中,咱们遇到的一些“奇奇怪怪”的问题,以及如何正确且更好的使用Netty框架,并会对Netty中涉及的重要设计理念进行介绍。react

在学习Reactor模式以前,咱们须要对“I/O的四种模型”以及“什么是I/O多路复用”进行简单的介绍,由于Reactor是一个使用了同步非阻塞的I/O多路复用机制的模式。linux

I/O的四种模型

I/0 操做 主要分红两部分
① 数据准备,将数据加载到内核缓存
② 将内核缓存中的数据加载到用户缓存编程

  • Synchronous blocking I/O
    设计模式

     
    Typical flow of the synchronous blocking I/O model

     

  • Synchronous non-blocking I/0
    缓存

     
    Typical flow of the synchronous non-blocking I/O model

     

  • Asynchronous blocking I/0
    服务器

     
    Typical flow of the asynchronous blocking I/O model (select)

     

  • Asynchronous non-blocking I/0
    网络

     
    Typical flow of the asynchronous non-blocking I/O model

     

堵塞、非堵塞的区别是在于第一阶段,即数据准备阶段。不管是堵塞仍是非堵塞,都是用应用主动找内核要数据,而read数据的过程是‘堵塞’的,直到数据读取完。
同步、异步的区别在于第二阶段,若由请求者主动的去获取数据,则为同步操做,须要说明的是:read/write操做也是‘堵塞’的,直到数据读取完。
若数据的read都由kernel内核完成了(在内核read数据的过程当中,应用进程依旧能够执行其余的任务),这就是异步操做。多线程

换句话说,BIO里用户最关心“我要读”,NIO里用户最关心"我能够读了",在AIO模型里用户更须要关注的是“读完了”。
NIO一个重要的特色是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操做是同步阻塞的(消耗CPU但性能很是高)。
NIO是一种同步非阻塞的I/O模型,也是I/O多路复用的基础。
并发

I/O多路复用

I/O多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,好比调用select和poll函数,传入多个文件描述符,若是有一个文件描述符就绪,则返回,不然阻塞直到超时。获得就绪状态后进行真正的操做能够在同一个线程里执行,也能够启动线程执行(好比使用线程池)。

通常状况下,I/O 复用机制须要事件分发器。 事件分发器的做用,将那些读写事件源分发给各读写事件的处理者。
涉及到事件分发器的两种模式称为:Reactor和Proactor。 Reactor模式是基于同步I/O的,而Proactor模式是和异步I/O相关的。本文主要介绍的就是 Reactor模式相关的知识。

经典的I/O服务设计 ———— BIO模式

 
 

👆这就是经典的每链接对应一个线程的同步阻塞I/O模式。

  • 流程:
    ① 服务器端的Server是一个线程,线程中执行一个死循环来阻塞的监听客户端的链接请求和通讯。
    ② 当客户端向服务器端发送一个链接请求后,服务器端的Server会接受客户端的请求,ServerSocket.accept()从阻塞中返回,获得一个与客户端链接相对于的Socket。
    ③ 构建一个handler,将Socket传入该handler。建立一个线程并启动该线程,在线程中执行handler,这样与客户端的全部的通讯以及数据处理都在该线程中执行。当该客户端和服务器端完成通讯关闭链接后,线程就会被销毁。
    ④ 而后Server继续执行accept()操做等待新的链接请求。

  • 优势:
    ① 使用简单,容易编程
    ② 在多核系统下,可以充分利用了多核CPU的资源。即,当I/O阻塞系统,但CPU空闲的时候,能够利用多线程使用CPU资源。

  • 缺点:
    该模式的本质问题在于严重依赖线程,但线程Java虚拟机很是宝贵的资源。随着客户端并发访问量的急剧增长,线程数量的不断膨胀将服务器端的性能将急剧降低。
    ① 线程生命周期的开销很是高。线程的建立与销毁并非没有代价的。在Linux这样的操做系统中,线程本质上就是一个进程,建立和销毁都是重量级的系统函数。
    ② 资源消耗。内存:大量空闲的线程会占用许多内存,给垃圾回收器带来压力。;CPU:若是你已经拥有足够多的线程使全部CPU保持忙碌状态,那么再建立更过的线程反而会下降性能。
    ③ 稳定性。在可建立线程的数量上存在一个限制。这个限制值将随着平台的不一样而不一样,而且受多个因素制约:a)JVM的启动参数、b)Threa的构造函数中请求的栈大小、c)底层操做系统对线程的限制 等。若是破坏了这些限制,那么极可能抛出OutOfMemoryError异常。
    ④ 线程的切换成本是很高的。操做系统发生线程切换的时候,须要保留线程的上下文,而后执行系统调用。若是线程数太高,不只会带来许多无用的上下文切换,还可能致使执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现每每是系统负载偏高、CPU sy(系统CPU)使用率特别高,致使系统几乎陷入不可用的状态。
    ⑤ 容易形成锯齿状的系统负载。一旦线程数量高但外部网络环境不是很稳定,就很容易形成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
    ⑥ 如果长链接的状况下而且客户端与服务器端交互并不频繁的,那么客户端和服务器端的链接会一直保留着,对应的线程也就一直存在在,但由于不频繁的通讯,致使大量线程在大量时间内都处于空置状态。

  • 适用场景:若是你有少许的链接使用很是高的带宽,一次发送大量的数据,也许典型的IO服务器实现可能很是契合。

Reactor模式

Reactor模式(反应器模式)是一种处理一个或多个客户端并发交付服务请求的事件设计模式。当请求抵达后,服务处理程序使用I/O多路复用策略,而后同步地派发这些请求至相关的请求处理程序。

Reactor结构

 

 
 

Reactor模式的角色构成(Reactor模式一共有5中角色构成):

 

  • Handle(句柄或描述符,在Windows下称为句柄,在Linux下称为描述符):本质上表示一种资源(好比说文件描述符,或是针对网络编程中的socket描述符),是由操做系统提供的;该资源用于表示一个个的事件,事件既能够来自于外部,也能够来自于内部;外部事件好比说客户端的链接请求,客户端发送过来的数据等;内部事件好比说操做系统产生的定时事件等。它本质上就是一个文件描述符,Handle是事件产生的发源地。
  • Synchronous Event Demultiplexer(同步事件分离器):它自己是一个系统调用,用于等待事件的发生(事件多是一个,也多是多个)。调用方在调用它的时候会被阻塞,一直阻塞到同步事件分离器上有事件产生为止。对于Linux来讲,同步事件分离器指的就是经常使用的I/O多路复用机制,好比说select、poll、epoll等。在Java NIO领域中,同步事件分离器对应的组件就是Selector;对应的阻塞方法就是select方法。
  • Event Handler(事件处理器):自己由多个回调方法构成,这些回调方法构成了与应用相关的对于某个事件的反馈机制。在Java NIO领域中并无提供事件处理器机制让咱们调用或去进行回调,是由咱们本身编写代码完成的。Netty相比于Java NIO来讲,在事件处理器这个角色上进行了一个升级,它为咱们开发者提供了大量的回调方法,供咱们在特定事件产生时实现相应的回调方法进行业务逻辑的处理,即,ChannelHandler。ChannelHandler中的方法对应的都是一个个事件的回调。
  • Concrete Event Handler(具体事件处理器):是事件处理器的实现。它自己实现了事件处理器所提供的各类回调方法,从而实现了特定于业务的逻辑。它本质上就是咱们所编写的一个个的处理器实现。
  • Initiation Dispatcher(初始分发器):实际上就是Reactor角色。它自己定义了一些规范,这些规范用于控制事件的调度方式,同时又提供了应用进行事件处理器的注册、删除等设施。它自己是整个事件处理器的核心所在,Initiation Dispatcher会经过Synchronous Event Demultiplexer来等待事件的发生。一旦事件发生,Initiation Dispatcher首先会分离出每个事件,而后调用事件处理器,最后调用相关的回调方法来处理这些事件。Netty中ChannelHandler里的一个个回调方法都是由bossGroup或workGroup中的某个EventLoop来调用的。

Reactor模式流程

① 初始化Initiation Dispatcher,而后将若干个Concrete Event Handler注册到Initiation Dispatcher中。当应用向Initiation Dispatcher注册Concrete Event Handler时,会在注册的同时指定感兴趣的事件,即,应用会标识出该事件处理器但愿Initiation Dispatcher在某些事件发生时向其发出通知,事件经过Handle来标识,而Concrete Event Handler又持有该Handle。这样,事件 ————> Handle ————> Concrete Event Handler 就关联起来了。
② Initiation Dispatcher 会要求每一个事件处理器向其传递内部的Handle。该Handle向操做系统标识了事件处理器。
③ 当全部的Concrete Event Handler都注册完毕后,应用会调用handle_events方法来启动Initiation Dispatcher的事件循环。这是,Initiation Dispatcher会将每一个注册的Concrete Event Handler的Handle合并起来,并使用Synchronous Event Demultiplexer(同步事件分离器)同步阻塞的等待事件的发生。好比说,TCP协议层会使用select同步事件分离器操做来等待客户端发送的数据到达链接的socket handler上。
好比,在Java中经过Selector的select()方法来实现这个同步阻塞等待事件发生的操做。在Linux操做系统下,select()的实现中 a)会将已经注册到Initiation Dispatcher的事件调用epollCtl(epfd, opcode, fd, events)注册到linux系统中,这里fd表示Handle,events表示咱们所感兴趣的Handle的事件;b)经过调用epollWait方法同步阻塞的等待已经注册的事件的发生。不一样事件源上的事件可能同时发生,一旦有事件被触发了,epollWait方法就会返回;c)最后经过发生的事件找到相关联的SelectorKeyImpl对象,并设置其发生的事件为就绪状态,而后将SelectorKeyImpl放入selectedSet中。这样一来咱们就能够经过Selector.selectedKeys()方法获得事件就绪的SelectorKeyImpl集合了。
④ 当与某个事件源对应的Handle变为ready状态时(好比说,TCP socket变为等待读状态时),Synchronous Event Demultiplexer就会通知Initiation Dispatcher。
⑤ Initiation Dispatcher会触发事件处理器的回调方法,从而响应这个处于ready状态的Handle。当事件发生时,Initiation Dispatcher会将被事件源激活的Handle做为『key』来寻找并分发恰当的事件处理器回调方法。
⑥ Initiation Dispatcher会回调事件处理器的handle_event(type)回调方法来执行特定于应用的功能(开发者本身所编写的功能),从而相应这个事件。所发生的事件类型能够做为该方法参数并被该方法内部使用来执行额外的特定于服务的分离与分发。

Reactor模式的实现方式

单线程Reactor模式
 
 

流程:
① 服务器端的Reactor是一个线程对象,该线程会启动事件循环,并使用Selector来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的链接请求事件(ACCEPT事件)。
② 客户端向服务器端发起一个链接请求,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器经过accept()方法获得与这个客户端对应的链接(SocketChannel),而后将该链接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该链接的READ事件了。或者当你须要向客户端发送数据时,就向Reactor注册该链接的WRITE事件和其处理器。
③ 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。好比,读处理器会经过SocketChannel的read()方法读取数据,此时read()操做能够直接读取到数据,而不会堵塞与等待可读的数据到来。
④ 每当处理完全部就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。

注意,Reactor的单线程模式的单线程主要是针对于I/O操做而言,也就是因此的I/O的accept()、read()、write()以及connect()操做都在一个线程上完成的。

但在目前的单线程Reactor模式中,不只I/O操做在该Reactor线程上,连非I/O的业务操做也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。因此咱们应该将非I/O的业务逻辑操做从Reactor线程上卸载,以此来加速Reactor线程对I/O请求的响应。

改进:使用工做者线程池
 
 

与单线程Reactor模式不一样的是,添加了一个工做者线程池,并将非I/O操做从Reactor线程中移出转交给工做者线程池来执行。这样可以提升Reactor线程的I/O响应,不至于由于一些耗时的业务逻辑而延迟对后面I/O请求的处理。

使用线程池的优点:
① 经过重用现有的线程而不是建立新线程,能够在处理多个请求时分摊在线程建立和销毁过程产生的巨大开销。
② 另外一个额外的好处是,当请求到达时,工做线程一般已经存在,所以不会因为等待建立线程而延迟任务的执行,从而提升了响应性。
③ 经过适当调整线程池的大小,能够建立足够多的线程以便使处理器保持忙碌状态。同时还能够防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

注意,在上图的改进的版本中,因此的I/O操做依旧由一个Reactor来完成,包括I/O的accept()、read()、write()以及connect()操做。
对于一些小容量应用场景,可使用单线程模型。可是对于高负载、大并发或大数据量的应用场景却不合适,主要缘由以下:
① 一个NIO线程同时处理成百上千的链路,性能上没法支撑,即使NIO线程的CPU负荷达到100%,也没法知足海量消息的读取和发送;
② 当NIO线程负载太重以后,处理速度将变慢,这会致使大量客户端链接超时,超时以后每每会进行重发,这更加剧了NIO线程的负载,最终会致使大量消息积压和处理超时,成为系统的性能瓶颈;

多Reactor线程模式
 
 

Reactor线程池中的每一Reactor线程都会有本身的Selector、线程和分发的事件循环逻辑。
mainReactor能够只有一个,但subReactor通常会有多个。mainReactor线程主要负责接收客户端的链接请求,而后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通讯。

流程:
① 注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的链接请求事件(ACCEPT事件)。启动mainReactor的事件循环。
② 客户端向服务器端发起一个链接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器经过accept()方法获得与这个客户端对应的链接(SocketChannel),而后将这个SocketChannel传递给subReactor线程池。
③ subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。固然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操做。Reactor线程池中的每一Reactor线程都会有本身的Selector、线程和分发的循环逻辑。
④ 当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操做,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后须要返回数据给客户端,则相关的I/O的write操做仍是会被提交回subReactor线程来完成。

注意,因此的I/O操做(包括,I/O的accept()、read()、write()以及connect()操做)依旧仍是在Reactor线程(mainReactor线程 或 subReactor线程)中完成的。Thread Pool(线程池)仅用来处理非I/O操做的逻辑。

多Reactor线程模式将“接受客户端的链接请求”和“与该客户端的通讯”分在了两个Reactor线程来完成。mainReactor完成接收客户端链接请求的操做,它不负责与客户端的通讯,而是将创建好的链接转交给subReactor线程来完成与客户端的通讯,这样一来就不会由于read()数据量太大而致使后面的客户端链接请求得不到即时处理的状况。而且多Reactor线程模式在海量的客户端并发请求的状况下,还能够经过实现subReactor线程池来将海量的链接分发给多个subReactor线程,在多核的操做系统中这能大大提高应用的负载和吞吐量。

Netty 与 Reactor模式

Netty的线程模式就是一个实现了Reactor模式的经典模式。

  • 结构对应:
    NioEventLoop ———— Initiation Dispatcher
    Synchronous EventDemultiplexer ———— Selector
    Evnet Handler ———— ChannelHandler
    ConcreteEventHandler ———— 具体的ChannelHandler的实现

  • 模式对应:
    Netty服务端使用了“多Reactor线程模式”
    mainReactor ———— bossGroup(NioEventLoopGroup) 中的某个NioEventLoop
    subReactor ———— workerGroup(NioEventLoopGroup) 中的某个NioEventLoop
    acceptor ———— ServerBootstrapAcceptor
    ThreadPool ———— 用户自定义线程池

  • 流程:
    ① 当服务器程序启动时,会配置ChannelPipeline,ChannelPipeline中是一个ChannelHandler链,全部的事件发生时都会触发Channelhandler中的某个方法,这个事件会在ChannelPipeline中的ChannelHandler链里传播。而后,从bossGroup事件循环池中获取一个NioEventLoop来现实服务端程序绑定本地端口的操做,将对应的ServerSocketChannel注册到该NioEventLoop中的Selector上,并注册ACCEPT事件为ServerSocketChannel所感兴趣的事件。
    ② NioEventLoop事件循环启动,此时开始监听客户端的链接请求。
    ③ 当有客户端向服务器端发起链接请求时,NioEventLoop的事件循环监听到该ACCEPT事件,Netty底层会接收这个链接,经过accept()方法获得与这个客户端的链接(SocketChannel),而后触发ChannelRead事件(即,ChannelHandler中的channelRead方法会获得回调),该事件会在ChannelPipeline中的ChannelHandler链中执行、传播。
    ④ ServerBootstrapAcceptor的readChannel方法会该SocketChannel(客户端的链接)注册到workerGroup(NioEventLoopGroup) 中的某个NioEventLoop的Selector上,并注册READ事件为SocketChannel所感兴趣的事件。启动SocketChannel所在NioEventLoop的事件循环,接下来就能够开始客户端和服务器端的通讯了。

后记

本文主要对Reactor模式进行详细的解析,Netty中正是应用Reactor模式来实现异步事件驱动网络应用框架的,因此对于Reactor模式的掌握在Netty的学习是相当重要的。
若文章有任何错误,望你们不吝指教:)

参考

圣思园《精通并发与Netty》
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
https://tech.meituan.com/nio.html
http://www.infoq.com/cn/articles/netty-threading-model
http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf Netty 权威指南 Java并发编程实战

做者:tomas家的小拨浪鼓 连接:https://www.jianshu.com/p/1ccbc6a348db 來源:简书 简书著做权归做者全部,任何形式的转载都请联系做者得到受权并注明出处。
相关文章
相关标签/搜索