【I/O设计总结三】详解Reactor/Proactor高性能IO处理模式

前言

随着IO多路复用技术的出现,出现了很多事件处理模式,其中Reactor/Proactor模式是其中的佼佼者。

Reactor模式是非阻塞同步的I/O模型,Proactor模式是非阻塞异步I/O模型。

平时接触的开源产品如Netty、Mina、Redis、ACE,事件模型都使用的Reactor模式;

而同样做事件处理的Proactor,由于缺少操作系统支持的原因,相关的开源产品也少;这里学习下其模型结构,重点对比下两者的异同点;

1 Reactor事件处理模型

我们来回顾一下《【I/O设计总结一】五种I/O模型总结》中学习的IO多路复用模型:

同时,我们在《【I/O设计总结二】详解IO多路复用和其三种模式——select/poll/epoll》一文中,我们介绍了linux系统的select,poll和epoll三个事件的分发函数,我们可以直接编写代码,让应用线程去调用这三个函数中的某一个,完成最简单的IO多路复用模型,也就是如上图所示的这般流程。

这样原始版本的IO多路复用模型好吗?显然不好,直接调用无法并发,效率太低,如果当前的请求没有处理完,那么后面的请求只能被阻塞,服务器的吞吐量太低。

利用线程池技术稍加改进,我们想到了经典的connection per thread,每一个连接用一个线程处理,对于每一个请求都分发给一个线程,每个线程中都独自处理I/O操作。tomcat服务器的早期版本确实是这样实现的。

一连接一线程的方式当然有很多优点,但缺点也很明显:对于资源要求太高,系统中创建线程是需要比较高的系统资源的,如果连接数太高,系统无法承受,而且,线程的反复创建-销毁也需要代价。

这时,我们采用了基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理。Reactor模式应运而生。

Reactor是“事件反应”的意思,可以通俗地理解为“来了一个事件Reactor就有相应的反应”,具体的反应就是我们写的业务代码,Reactor会根据事件类型来调用相应的代码进行处理。

Reactor模式也叫Dispatcher模式(在很多开源的系统里面会看到这个名称的类,其实就是实现Reactor模式的),分发确实更加贴近模式本身的含义,即I/O多路复用统一监听事件,收到事件后分发(Dispatch)给某个进程。

归根结底,Reactor就是基于事件驱动设计,利用回调和线程池技术来高效率使用select等函数的设计。

1.1 Reactor模型的架构

模型架构如上图所示,我们来一一解释这些控件:

  • Handle:句柄,用来封装或标识socket连接或是打开文件,你可以理解为在模型中,它代表一个连接或者I/O流文件。
  • Event Handler:事件处理接口,用来绑定某个handle和应用程序所提供的特定事件处理逻辑。Concrete Event HandlerA和Concrete Event HandlerB是它的具体实现类,应用程序可针对不同的连接(handle)定制不同的处理逻辑(event)。
  • Synchronous Event Demultiplexer:同步事件多路分解器,由操作系统内核实现的一个函数(如Linux的select/poll/epoll);用于阻塞等待发生在句柄集合上的一个或多个事件;返回就绪的Event Handler集合。Java NIO的Selector就是一个Demultiplexer。
  • Initiation Dispatcher:分发器,定义一个接口,实现以下功能:
    1. register_handle():供应用程序注册它定义的Event Handler。即应用程序通过该方法将Event Handler加入Reactor的Synchronous Event Demultiplexer。
    2. remove_handle():供应用程序删除Synchronous Event Demultiplexer中关注的Event Handler。
    3. handle_events():核心方法,也是Reactor模式的发动机,这个方法的核心逻辑如下:
      • 首先通过同步事件多路选择器提供的select()方法监听网络事件。
      • 当有网络事件就绪后,就遍历注册的Event Handler,找到对应的Event Handler来处理该网络事件。
      • handle_events()是非阻塞的,主程序可以调用handle_events()后继续其他的操作,handle_events()内的逻辑会触发轮询,回调等操作。调用一次handle_events只会触发一次轮询检查。
      • 如果主程序是服务端的话,由于网络事件是源源不断的,主程序一般会不停调用Dispatcher的handle_events()。比如一个server服务端,开辟一个线程,循环调用handle_events(),非阻塞同步性的处理多个客户端的连接。

Demultiplexer和Dispatcher是Reactor的核心,如果我们看到有人说Reactor负责监听和分发事件,那么其实就是将Demultiplexer和Dispatcher整合成一个Reactor组件来描述。

下文的图,以及描述中出现的单Reactor和多Reactor,指的就是Demultiplexer+Dispatcher的组合提,即Reactor=Demultiplexer+Dispatcher

由图可以看到,Reactor模型的简单流程就是:

  1. 每当有一个客户端连接进来,被服务端接收到,服务端就会将这个连接封装为一个handle,同时会创建的一个个Event Handler,将handle封装进去,并在Event Handler内设定响应的回调函数。
  2. 服务端调用Dispatcher的register_handle()方法,将Event Handler注册进来。
  3. 服务端会不停的调用Dispatcher的handle_events()方法,而handle_events()方法会调用Demultiplexer的select()方法,得到就绪的Event Handler。然后调用这个Event Handler的handle_event()方法,执行回调函数。

1.2 Reactor模型的实现方案

Reactor模式的设计,一般和资源池(进程池或线程池)相配合。其中Reactor组件包含Demultiplexer和Dispatcher这两个组件,包含数量不固定,分别负责监听和分配事件,处理资源池负责调度来处理事件。

初看Reactor的实现是比较简单的,但实际上结合不同的业务场景,Reactor模式的具体实现方案灵活多变,主要体现在:

  • Reactor的数量可以变化:可以是一个Reactor,也可以是多个Reactor。
  • 资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程,线程同理。

将上面两个因素排列组合一下,理论上可以有 4 种选择,但由于多Reactor单进程实现方案相比单Reactor单进程方案,既复杂又没有性能优势,因此多Reactor单进程方案仅仅是一个理论上的方案,实际没有应用。

最终Reactor模式有这三种典型的实现方案:

  1. 单Reactor单进程/线程。
  2. 单Reactor多线程。
  3. 多Reactor多进程/线程。

以上方案具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java语言一般使用线程(例如,Netty),C语言使用进程和线程都可以。例如,Nginx使用进程,Memcache使用线程。

1.2.1 非Reactor的传统模型

为了比较Reactor模型的优势,我们先来介绍一下Reactor模型出现以前的传统模型,Java OIO(old IO)时代,这种模型经常被使用:客户端与服务端建立好连接过后,服务端对每一个建立好的连接使用一个handler来处理,而每个handler都会绑定一个线程。

图中的acceptor是注册的一个特殊的Event Handler,负责创建连接的请求,如果Dispatcher接收到某个客户端请求,发现是创建连接的请求,那么就会直接将其转发给acceptor来处理。

这样做在连接的客户端不多的情况下,也算是个不错的选择。但在连接的客户端很多的情况下就会出现问题:

  1. 每一个连接服务端都会产生一个线程,当并发量比较高的情况下,会产生大量的线程。
  2. 在服务端很多线程的情况下,大量的线程的上下文切换是一个很大的开销,会比较影响性能。
  3. 与服务端连接建立后,连接上未必是时时刻刻都有数据进行传输的,但是创建的线程一直都在,会造成服务端线程资源的一个极大的浪费。

1.2.2 单线程Reactor模型

介绍了非Reactor的传统模型,我们再来介绍Reactor模型的朴素原型——单线程Reactor模型。这是Java NIO常用的模型

由于Java OIO的网络编程模型在客户端很多的情况下回产生服务端线程数过多的问题,因此根据Reactor模式做出了改进。

根据上图,Reactor角色对IO事件进行监听(Demultiplexer负责)和分发(Dispatcher负责)。当事件产生时,Dispatcher会将handle分发给对应的处理器Event Handler进行处理。

面对IO阻塞,传统OIO使用多线程来消除阻塞的影响,一个socket开启一个线程来处理以防止一个连接IO的阻塞影响到其他的连接的处理。

而在单线程Reactor模型中,通过Reactor对于IO事件的监听和分发,服务端只需要一个IO线程就能处理多个客户端的连接。这就解决了导致服务端线程数过多的问题。

Reactor的单线程模式的单线程主要是针对于IO操作而言,也就是所有的IO的accept()、read()、write()以及connect()操作都在一个线程上完成的。

这个线程可以是服务端调用Dispatcher.handle_events()的线程,也可以是Dispatcher自己无限循环调用handle_events()的线程。我们称这个线程为Reactor的IO线程。

这个IO线程会循环调用Dispatcher的handle_events()方法,handle_events()方法会继续调用Demultiplexer的select方法,轮询检查注册进来的Event Handler中的handle,如果发生事件,那么回调Event Handler的handle_event(),执行包括read、decode、complete、encode、send的完整IO处理逻辑。

但是这种模型还是有缺陷的,那就是所有的客户端的请求都由一个IO线程来进行处理。当并发量比较大的情况下,服务端的处理性能无法避免会下降,因为服务端IO线程每次只能处理一个客户端的请求,其他的请求只能等待。

1.2.3 多线程Reactor模型

在目前的单线程Reactor模式中,不仅IO操作在该Reactor的IO线程上,连非IO的业务操作也在该线程上进行处理了,这可能会大大延迟IO请求的响应。所以我们应该将非IO的业务逻辑操作从Reactor线程上卸载,以此来加速Reactor线程对IO请求的响应。

如上图所示,Reactor还是一个IO线程,负责监听IO事件以及分发。只不过在事件发生时,Dispatcher回调Event Handler的handle_event(),只会执行read和send操作,中间的业务逻辑处理部分,即decode、complete、encode,都使用了一个线程池来进行处理,这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理,解决了服务端单线程处理请求而带来的性能瓶颈。

但是这样还是有问题,这样会把性能的瓶颈转移到IO处理上。因为IO事件的监听和分发采用的还是单个线程,在并发量比较高的情况下,这个也是比较影响性能的。这是否还有继续优化的空间呢?

1.2.4 多线程多Reactor模型

虽然多线程Reactor模型将非I/O操作交给了线程池来处理,但是所有的I/O操作依然由Reactor单线程执行,在高负载、高并发或大数据量的应用场景,依然较容易成为瓶颈。所以,对于Reactor的优化,又产生出下面的多Reactor模式。

对于多个CPU的机器,为充分利用系统资源,将Reactor拆分为两部分,如上图

mainReactor负责监听server socket,用来处理网络新连接的建立,将建立的socketChannel指定注册给subReactor,通常一个线程就可以处理;

subReactor维护自己的Demultiplexer, 基于mainReactor注册的Event Handler多路分离I/O读写事件;

对非I/O的操作,依然转交给线程池(Thread Pool)执行。

此种模型中,每个模块的工作更加专一,耦合度更低,性能和稳定性也大量的提升,支持的可并发客户端数量可达到上百万级别。关于此种模型的应用,目前有很多优秀的框架已经在应用了,比如mina、Nginx、Memcached和Netty等。

1.2.5 总结

3种模式可以用个比喻来理解:餐厅常常雇佣接待员负责迎接顾客,当顾客入坐后,侍应生专门为这张桌子服务。

单Reactor单线程:接待员和侍应生是同一个人,全程为顾客服务。
单Reactor多线程:1 个接待员,多个侍应生,接待员只负责接待。
主从Reactor多线程:多个接待员,多个侍应生。

1.3 Reactor模型的优劣

  • 优点:
    • Reactor实现相对简单,对于链接多,但耗时短的处理场景高效;
    • 操作系统可以在多个事件源上等待,并且避免了线程切换的性能开销和编程复杂性;
    • 事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
    • 事务分离:将与应用无关的多路复用、分配机制和与应用相关的回调函数分离开来。
  • 缺点:
    • Reactor处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;

1.4 Reactor模型的应用

1.4.1 Reactor模型在Java NIO中

我们知道,Java NIO的网络编程中,会有一个死循环执行Selector.select()操作,找出注册到Selector上的Channel中已经准备好的IO事件,然后再对这些事件进行处理。

故而NIO中的Selector组件对应的就是Reactor模式的Synchronous Event Demultiplexer同步事件多路分解器。

选择过后得到的SelectionKey,其实就对应的是上面的handle,也就是代表的一个个的IO事件。而NIO中并没有进行事件分发和封装处理器,因此Reactor模式中的其他组件NIO并没有给出实现。

1.4.2 Reactor模式在Netty中

上面java NIO实现了reactor模式的两个角色,Demultiplexer和handle。

而剩余的三个角色,则由Netty给出了实现。

学习过Netty的应当知道,Netty服务端的编程有一个bossGroup和一个workerGroup,还需要编写自己的ChannelHandler。

bossGroup和workerGroup都是一个事件循环组(EventLoopGroup,一般我们用的是NIOEventLoopGroup),每个事件循环组有多个事件循环(EventLoop,NIO对应的是NIOEventLoop)。

mainReactor对应Netty中配置的BossGroup线程组,主要负责接受客户端连接的建立。一般只暴露一个服务端口,BossGroup线程组一般一个线程工作即可。

subReactor对应Netty中配置的WorkerGroup线程组,BossGroup线程组接受并建立完客户端的连接后,将网络socket转交给WorkerGroup线程组,然后在WorkerGroup线程组内选择一个线程,进行I/O的处理。WorkerGroup线程组主要处理I/O,一般设置2*CPU核数个线程。

bossGroup/workerGroup中的事件循环EventLoop就充当了Dispatcher的角色。

Netty中我们需要实现的Handler的顶层接口ChannelHandler对应的就是Event Handler角色,而我们添加进去的一个个的Handler对应的就是Concrete Event Handler。

注意,Netty的Handler是Concrete Event Handler的角色,Netty的SelectionKey才是handle的角色。

最后我们总结一下Netty和Reactor角色的对应关系:

  1. Initiation Dispatcher ———— NioEventLoop
  2. Synchronous Event Demultiplexer ———— Selector
  3. Handle———— SelectionKey
  4. Event Handler ———— ChannelHandler
  5. ConcreteEventHandler ———— 具体的ChannelHandler的实现
  6. mainReactor ———— bossGroup(NioEventLoopGroup)线程组
  7. subReactor ———— workerGroup(NioEventLoopGroup)线程组
  8. acceptor ———— ServerBootstrapAcceptor
  9. ThreadPool ———— 用户自定义线程池或者EventLoopGroup

2 Proactor事件处理模型

Reactor模式是非阻塞同步的I/O模型,Proactor模式是非阻塞异步I/O模型。它们的区别就在于异步二字,Proactor能实现真正的异步,但真正的异步IO也需要操作系统更强的支持。

在Reactor模型中,事件循环利用select等函数,主动地将已经就绪的handle找出,并通过回调通知给用户线程,由用户线程定义的回调函数自行读取数据、处理数据。换句话说,用户线程定义的回调函数中,要自行操作write(),将IO数据从内核空间写到用户空间

而在Proactor模型中,就不是利用select等函数寻找就绪的handle了,要记得Proactor是异步IO模型,异步是什么?我们不用主动调用select函数去寻找,而是直接等通知就行了

并通过回调通知给用户线程,但当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。也就是说,此时用户线程定义的回调函数中,不需要自行操作write(),因为IO数据已经从内核空间写到用户空间了

Reactor中,write()操作还是同步的,由用户线程自己解决,而Proactor中,真正做到了异步write(),它依赖于内部实现的异步操作处理器(Asynchronous Operation Processor)以及异步事件分离器(Asynchronous Event Demultiplexer)将IO操作与应用回调隔离。

相比于Reactor,Proactor并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步IO的支持并非特别完善,更多的是采用Reactor模型模拟Proactor异步IO的方式:IO事件触发时不直接通知用户线程,而是非阻塞地将数据读写完毕后放到用户指定的缓冲区中,再执行回调逻辑

Java7之后已经支持了异步IO,感兴趣的读者可以尝试使用。

2.1 Proactor模型的架构

模型架构如上图所示,我们来一一解释这些控件:

其中Handle的含义,跟Reactor模型一致,Event Handler和Completion Handler也类似,它们都是事件的抽象封装。

  • Handle:句柄,用来封装或标识socket连接或是打开文件,你可以理解为在模型中,它代表一个连接或者I/O流文件。

  • Completion Handler:完成事件接口,用来绑定某个handle和应用程序所提供的特定事件处理逻辑。Concrete Completion Handler是它的具体实现类,应用程序可针对不同的连接(handle)定制不同的回调函数(event)。

  • Completion Event Queue:完成事件的队列;异步操作完成的结果放到队列中等待后续使用。

  • Asynchronous Operation Processor:异步操作处理器;负责执行异步操作,一般由操作系统内核实现;绑定在Handle上,负责对监听到的Handle事件进行回调唤醒对应的异步操作,生成对应的Completion Event并添加到完成事件的队列中。

  • Asynchronous Operation:异步操作,主要用于处理程序中长时间持续操作;

  • Asynchronous Event Demultiplexer:异步事件多路分解器,和Reactor的Demultiplexer作用类似,但因为Proactor是异步的,故而不需要Demultiplexer主动发起select轮询,只要监视着完成事件的队列,看是否有Completion Handler被异步插入到队列中即可。

  • Proactor:Proactor模型的主动器,提供应用程序的事件循环,重复地从Demultiplexer中获得就绪的Completion Handler,并调用其handle_event()方法。

  • Initiator:本地应用程序服务入口,初始化一个异步操作并注册一个Completion Handler和一个带有异步操作处理器的Proactor,当操作完成时通知它。

可以看到,Proactor角色的作用和Reactor的Dispatcher作用一致。它其实就是Proactor模型的Dispatcher,只不过叫法不一样罢了。

也有部分文章将Dispatcher+Demultiplexer合并在Proactor角色里,就像Reactor模型中有时用Reactor表示Dispatcher+Demultiplexer,如下图:

2.2 Proactor模型的流程

由图可以看到,Proactor模型的流程可以被归纳为:

  1. Initiator创建Proactor,Completion Event Queue和Completion Handler对象,并将其通过Asynchronous Operation Processor的exec_async_op()方法注册到内核,并调用async_op()方法,开启独立的内核线程执行异步操作,实现真正的异步。

  2. 调用之后应用程序和异步操作处理就独立运行;应用程序可以调用新的异步操作,而其它操作可以并发进行;

  3. Initiator调用Proactor.handle_events()方法,启动Proactor主动器,进行无限的事件循环,调用Demultiplexer.wait()方法,等待完成事件到来;

  4. 异步事件的就绪被Asynchronous Operation Processor监听到,将其对应的Completion Handler加入Completion Event Queue队列。

  5. Demultiplexer监视着Completion Event Queue队列,发现有数据,便将队列中的Completion Handler返回。

  6. Proactor从Demultiplexer得到就绪的Completion Handler,知道事件已经就绪,随即调用Completion Handler.handle_event()方法。

虽然Proactor模式中每个异步操作都可以绑定一个Proactor对象(类似多Reactor的实现方式),但是一般在操作系统中,Proactor被实现为Singleton模式,以便于集中化分发操作完成事件。

2.3 Proactor模型的优劣

  • 优点:
    • Proactor在理论上性能更高,能够处理耗时长的并发场景。
  • 缺点:
    • Proactor实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP,但由于其windows系统用于服务器的局限性,目前应用范围较小;而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现。

2.4 Proactor模型的应用

Proactor因为要有操作系统的支持,故而应用的场景并不多,Linux下高性能的网络库中大多使用的Reactor 模式去实现。比如Boost Asio在Linux下用epoll和select去模拟proactor模式。

除此之外,glibc实现的POSIX aio也是采用proactor模式

3 Reactor和Proactor的对比

3.1 区别

  • 同步和异步

    • Reactor无法实现真正的异步,他们所有操作都是同步的,所以有时为了性能考虑,需要借助线程池,将同步的操作并发化。
    • Proactor基于操作系统的支持,可以实现真正的异步。
  • 主动/被动的写操作

    • Reactor将handler放到select(),等待可写就绪,事件就绪后,需要应用程序主动调用write(),将数据从内核空间写入到用户空间,写完数据后再处理后续逻辑;
    • Proactor调用aoi_write后立刻返回,由内核负责将数据从内核空间写入到用户空间,写完后调用相应的回调函数处理后续逻辑。换句话说,Proactor通知应用线程的时候,数据已经在用户空间就绪了。
  • 主动/被动的处理方式

    • Reactor模式被称为反应器,是一种被动的处理,即调用IO多路复用接口来做事件监听(注意此时是在用户空间调用select等函数),select等函数监听事件就是一个等待的过程,有事件来了之后再“做出反应”。
    • 而Proator模式的IO是系统级实现,是在内核中完成,读的过程中,用户空间的函数可以继续处理,并没有被阻塞;读完之后调用相应用户回调函数处理;

3.2 实现

Reactor实现了一个被动的事件分离和分发模型,服务等待请求事件的到来,再通过不受间断的同步处理事件,从而做出反应;

Proactor实现了一个主动的事件分离和分发模型;这种设计允许多个任务并发的执行,从而提高吞吐量。

所以涉及到文件I/O或耗时I/O可以使用Proactor模式,或使用多线程模拟实现异步I/O的方式。

3.3 适用场景

Reactor:同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
Proactor:异步接收和同时处理多个服务请求的事件驱动程序。

0%