Reactor 和 Proactor 对比以及优缺点
两种高效的事件处理模型:Reactor 模式和 Proactor 模式
在高性能的 I / O 设计中,有两个比较著名的模式 Reactor 和 Proactor 模式,其中 Reactor 模式用于同步 I /O,而 Proactor 运用于异步 I / O 操作。
在比较这两个模式之前,我们首先的搞明白几个概念,什么是阻塞和非阻塞,什么是同步和异步?
同步和异步是针对应用程序和内核的交互而言的,同步指的是用户进程触发 IO 操作并等待或者轮询的去查看 IO 操作是否就绪,而异步是指用户进程触发 IO 操作以后便开始做自己的事情,而当 IO 操作已经完成的时候会得到 IO 完成的通知(异步的特点就是通知)。
阻塞和非阻塞是针对于进程在访问数据的时候,根据 IO 操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作函数的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或者写入函数会立即返回一个状态值。
一般来说 I / O 模型可以分为:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞 IO
同步阻塞 IO:在此种方式下,用户进程在发起一个 IO 操作以后,必须等待 IO 操作的完成,只有当真正完成了 IO 操作以后,用户进程才能运行。JAVA 传统的 IO 模型属于此种方式!
同步非阻塞 IO:在此种方式下,用户进程发起一个 IO 操作以后边可返回做其它事情,但是用户进程需要时不时的询问 IO 操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的 CPU 资源浪费。其中目前 JAVA 的 NIO 就属于同步非阻塞 IO。
异步阻塞 IO:此种方式下是指应用发起一个 IO 操作以后,不等待内核 IO 操作的完成,等内核完成 IO 操作以后会通知应用程序,这其实就是同步和异步最关键的区别,同步必须等待或者主动的去询问 IO 是否完成,那么为什么说是阻塞的呢?因为此时是通过 select 系统调用来完成的,而 select 函数本身的实现方式是阻塞的,而采用 select 函数有个好处就是它可以同时监听多个文件句柄(如果从 UNP 的角度看,select 属于同步操作。因为 select 之后,进程还需要读写数据),从而提高系统的并发性!
异步非阻塞 IO:在此种模式下,用户进程只需要发起一个 IO 操作然后立即返回,等 IO 操作真正的完成以后,应用程序会得到 IO 操作完成的通知,此时用户进程只需要对数据进行处理就好了,不需要进行实际的 IO 读写操作,因为真正的 IO 读取或者写入操作已经由内核完成了。目前 Java 中还没有支持此种 IO 模型。
搞清楚了以上概念以后,我们再回过头来看看,Reactor 模式和 Proactor 模式。
(其实阻塞与非阻塞都可以理解为同步范畴下才有的概念,对于异步,就不会再去分阻塞非阻塞。对于用户进程,接到异步通知后,就直接操作进程用户态空间里的数据好了。)
首先来看看 Reactor 模式,Reactor 模式应用于同步 I / O 的场景。我们分别以读操作和写操作为例来看看 Reactor 中的具体步骤:
读取操作:
应用程序注册读就绪事件和相关联的事件处理器
事件分离器等待事件的发生
当发生读就绪事件的时候,事件分离器调用第一步注册的事件处理器
事件处理器首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理
写入操作类似于读取操作,只不过第一步注册的是写就绪事件。
下面我们来看看 Proactor 模式中读取操作和写入操作的过程:
读取操作:
应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于 Reactor 的关键。
事件分离器等待读取操作完成事件
在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作(异步 IO 都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作,操作系统扮演了重要角色),并将读取的内容放入用户传递过来的缓存区中。这也是区别于 Reactor 的一点,Proactor 中,应用程序需要传递缓存区。
事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。
Proactor 中写入操作和读取操作,只不过感兴趣的事件是写入完成事件。
从上面可以看出,Reactor 和 Proactor 模式的主要区别就是真正的读取和写入操作是有谁来完成的,Reactor 中需要应用程序自己读取或者写入数据,而 Proactor 模式中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的 IO 设备.
综上所述,同步和异步是相对于应用和内核的交互方式而言的,同步需要主动去询问,而异步的时候内核在 IO 事件发生的时候通知应用程序,而阻塞和非阻塞仅仅是系统在调用系统调用的时候函数的实现方式而已。
说到阻塞,首先得说说 I / O 等待。I/ O 等待是不可避免的,那么既然有了等待,就会有阻塞,但是注意,我们说的阻塞是指当前发起 I / O 操作的进程被阻塞。同步阻塞 I / O 便是指,当进程调用某些涉及 I / O 操作的系统调用或库函数时,比如 accept()(注意 accept 也算在了 i / o 操作)、send()、recv()等,进程便暂停下来,等待 I / O 操作完成再继续运行。这是一种简单而有效的 I / O 模型,它可以和多进程结合起来有效的利用 CPU 资源,但是代价就是多进程的大量内存开销。
同步阻塞:进程坐水, 就不能烧粥 同步非阻塞:类似于用一个进程坐水,烧粥。while(true){if… if…} 好处就是一个进程处理多个 i / o 请求,劣势就是需要不停的轮询。
区别在于等不等待数据就绪。因为数据占了等待的 80% 时间。同步非阻塞的优势就是一个进程里同时处理多个 I / O 操作。
在同步阻塞 I / O 中,进程实际上等待的时间可能包括两部分,一个是等待数据的就绪,另一个是等待数据的复制,对于网络 I / O 来说,前者的时间可能要更长一些。与此不同的是, 同步非阻塞 I / O 的调用不会等待数据的就绪,如果数据不可读或者不可写,它会立即返回告诉进程。
比如我们使用非阻塞 recv()接收网络数据的时候,如果网卡缓冲区中没有可接收的数据,函数就及时返回,告诉进程没有数据可读了。相比于阻塞 I /O,这种非阻塞 I / O 结合反复的轮询来尝试数据是否就绪,防止进程被阻塞,最大的好处便在于可以在一个进程里同时处理多个 I / O 操作。但正是由于需要进程执行多次的轮询来查看数据是否就绪,这花费了大量的 CPU 时间,使得进程处于忙碌等待状态。
非阻塞 I / O 一般只针对网络 I / O 有效,我们只要在 socket 的选项设置中使用 O_NONBLOCK 即可,这样对于该 socket 的 send()或 recv()便采用非阻塞方式。如果服务器想要同时接收多个 TCP 连接的数据,就必须轮流对每个 socket 调用接收数据的方法,比如 recv()。不管这些 socket 有没有可以接收的数据,都要询问一遍,假如大部分 socket 并没有数据可以接收,那么进程便会浪费很多 CPU 时间用于检查这些 socket,这显然不是我们所希望看到的。
同步和异步,阻塞和非阻塞,有些混用,其实它们完全不是一回事,而且它们修饰的对象也不相同。阻塞和非阻塞是指当进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,也就是未就绪时是直接返回还是等待就绪;而同步和异步是指访问数据的机制,同步一般指主动请求并等待 I / O 操作完毕的方式,当数据就绪后在读写的时候必须阻塞(区别就绪与读写二个阶段,同步的读写必须阻塞),异步则指主动请求数据后便可以继续处理其它任务,随后等待 I /O,操作完毕的通知,这可以使进程在数据读写时也不阻塞。(等待”通知”)
多数情况下,Web 服务器对这些请求采用基于队列的自由竞争,通过多执行流 (多进程或多线程) 来充分占 用 CPU 以及 I / O 资源,减少任何无辜的等待时间,这其中包括了很多种具体实现的并发策略,在实际应用中,特别是 Web 服务器,同时处理大量的文件描述符是必不可少的。多路 I / O 就绪通知的出现,提供了对大量文件描述符就绪检查的高性能方案,它允许进程 (比如电子屏, 会闻到各个饭馆做好饭菜的味道) 通过一种方法来同时监视所有文件描述符,并可以快速获得所有就绪的文件描述符,然后只针对这些文件描述符进行数据访问。
回到买面条的故事中,假如你不止买了一份面条,还在其它几个小吃店买了饺子、粥、馅饼等,因为一起逛街的朋友看到你的面条后也饿了。这些东西都需要时间来等待制作。在同步非阻塞 I / O 模型中,你要轮流不停的去各个小吃店询问进度,痛苦不堪。现在引入多路 I / O 就绪通知后,小吃城管理处给大厅安装了一块电子屏幕, 以后所有小吃店的食物做好后,都会显示在屏幕上,这可真是个好消息,你只需要间隔性的看看大屏幕就可以了,也许你还可以同时逛逛附近的商店,在不远处也可以看到大屏幕。
多路就绪:1、强调多路;2、只针对请求数据是否就绪,不针对 I / O 读写。epoll 针对的是这样的场景。select, epoll 都只需要进程 (我) 被动接收到数据就绪(面条)”通知”,符合异步的定义。不需要一直在饭馆等(同步阻塞),或轮询(同步非阻塞)。