对Reactor和Proactor模式的探究

150 阅读12分钟

前言

前面我们了解了redis和多路IO复用

本篇将会一起聊聊 Reactor模式 并扩展聊聊 Proactor模式, 并且可能也会聊聊 linux多出的一项Proactor技术

什么是IO操作?

当我们的程序需要与外部环境进行交互时,比如从磁盘中读取数据,或者向网络发送数据,就需要进行IO操作。从更本质的角度来看,IO操作其实是在不同的硬件设备之间进行数据传输的过程。

在这个过程中,需要有一个能够管理这个传输过程的系统,这个系统通常是操作系统。对于输入设备(比如键盘、鼠标),操作系统会将输入的数据存储在内存中,等待程序来读取。对于输出设备(比如屏幕、打印机),程序会将要输出的数据写入内存中,等待操作系统将其传输到相应的设备上。

总的来说,IO操作是在计算机硬件之间进行数据传输的过程,需要有操作系统来管理这个过程,并提供相应的接口给应用程序来进行数据的读取和写入。

总结一下,就可以说操作系统其实是中间的操作员,或者说是一个操作媒介,可以从外部设备中读取和写入一些数据到操作系统中的内存或者缓存中。

IO操作最常见的场景有: 文件读写, 网络socket套接字, 控制外部设备等。

所以我们就举一个文件读写的例子吧。

在文件读写的过程中。一般情况下都会使用DMA控制器去读取和写入磁盘。 CPU一般负责提交申请和从缓存中读取文件内容。

在这过程中CPU的操作也就两回,所以不会太占用CPU的资源,主要的操作方面是DMA在控制。

在这个过程中,磁盘属于计算机之外的设备。在以前没有DMA的情况下,需要CPU去磁盘中读写数据。到内存中,这会导致高速运转中的CPU变慢。

网络套接字机制也是这样子的,网络套接字这方面我们只要给网卡提供一些地址和我们所需要传输的文字,那么我们就可以借助网卡向另一个服务器发送消息,并且同时借助网卡,我们可以接收另一个服务器发过来的消息。

这个发送给服务器的过程是由网卡去完成的。 CPU其实也不需要做太多事情。

CPU只要将你想要发送的消息从缓存中加入到网卡发送队列就行了,网卡会全程,从发送队列中读取并发送一些消息出去。如果网卡收到一些消息,他会将那些消息加入到网卡接收队列,CPU只要从网卡接收队列中读取消息并将其加入到缓存中就行了。

在网络套接字传输的过程中,网卡就是计算机外部的设备。

Reactor设计模式

是什么?

Reactor模式是一种常用于网络编程的设计模式,它基于事件驱动的思想。它的工作方式类似于一个电话交换机,当一个电话来到时,电话交换机会根据电话的目的地把电话转接到正确的接收人。

在Reactor模式中,事件处理器类似于电话交换机,它会将输入的请求事件通过一个或多个服务处理器分发给相应的请求处理器进行处理。

Reactor模式通常由以下几个组件组成:

  • Reactor:负责接收并分发请求事件(下图橙色部分);
  • Handler:实际处理请求事件的对象(下图黄色部分);
  • Synchronous Event Demultiplexer(下图绿色部分):用于等待事件并通知Reactor。(实际上就是epoll或者select的抽象)
  1. Reactor中注册需要监听的事件和对应的处理器。
  2. 启动事件循环,Reactor调用Synchronous Event Demultiplexer等待事件发生。
  3. 当有事件发生时,Reactor会调用对应的Handler处理器进行处理。
  4. 处理器会执行相应的操作,并将处理结果返回给Reactor
  5. Reactor将处理结果返回给客户端。

Reactor单线程模式

小白: 你这样说的我还是不太懂, 那如果我借助socket发送一条hello world的消息到另一台服务器此时在Reactor模式下怎么工作呢?

小黑: 如果你使用socket发送一条hello world的消息到另一台服务器,在Reactor模式下,会有以下几个步骤:

  • 你的应用程序创建一个socket,并调用connect函数连接到目标服务器。
  • Acceptor对象监听目标服务器的端口,等待连接请求。
  • Acceptor对象收到连接请求时,它会创建一个新的socket,并将其注册到Reactor对象中,同时指定一个Handler对象来处理该socket上的事件。
  • Reactor对象负责监听所有注册的socket上的事件,并将事件分发给对应的Handler对象。
  • Reactor对象检测到你的socket上有可写事件时,它会通知Handler对象。
  • Handler对象调用write函数将hello world消息写入到你的socket中,并发送给目标服务器。
  • Reactor对象检测到目标服务器的socket上有可读事件时,它会通知Handler对象。
  • Handler对象调用read函数从目标服务器的socket中读取数据,并处理数据或回复数据。

与传统的多线程或者线程池比较

在传统的多线程服务中,一个事件的到来会有一个线程去处理,这样会导致事件与线程1对1,会有更多的线程出现在我们的系统中。导致资源的浪费。

在线程池模式下, 一般都是CPU密集型操作, 而非IO密集型, 因为线程池的线程数量是有限的, 线程池重在使用优先的线程完成更多的计算, 而非IO, IO需要很多的线程数量

Reactor模式更多的是利用linux底层的epoll尽量实现用较少的线程完成更多的IO操作, 所以线程池模式并不合适IO密集型, 当然可以使用线程池 + epoll技术代替Reactor模式, 但这样不也是类Reactor模式了么?!!!

在Reactor模式下, 你像是在每一个事件的身上安装了一个监听器一样的。此时你可以监听事件的状态。你可以关注事件的某一些状态,比如说可写的状态。事件在运作的过程中发现自身的状态变成可写状态时,此时你会接受到监听器上该事件以为可写,你就可以直接为该事件服务,而这里的你相当于一条线程。

Reactor模式改变了程序处理事件的方式。在传统的程序中,程序会等待某个事件的发生,然后去处理该事件。但在Reactor模式中,程序会事先注册一些事件,然后等待这些事件发生状态转变。当事件状态符合触发条件时,Reactor模式会自动调用相应的回调函数来处理该事件,从而实现异步非阻塞的处理方式。

这个模式的基本思想是将事件的处理权交给了程序,而不是让程序去等待事件的发生。

这样做的好处在哪里?

我们知道线程越多,那么就会有相应的线程切换(因为内核线程的数量通常少于系统线程的数量),线程切换一般被叫做线程上下文切换,在切线程的时候需要保存线程当前的执行状态,然后在线程恢复的时候需要加载线程保存的状态就会导致性能问题。

"因为内核线程的数量通常少于系统线程的数量"这句话不要杠精哦,即便你在java中创建了一个线程,但是你系统中的系统线程呢,其他应用的线程呢,是吧?

这样你创建线程的数量在总体量上看会减少一部分线程

这是很自然的事情,本来需要很多条件的去完成io操作,但是现在的话就需要一条线程负责监听和运行就行了。

多线程Reactor模式

但是前面那张图展示的单线程Reactor模式还是存在大量问题

比如 Acceptor 只有一个, 如果外部有无限个client, 他也遭不住, 相应的 ReactorHandler 也存在类似的问题

那么就都要改造成 多线程 咯?

是的, 当然你也可以先从 Handler 使用多线程开始, 其次是 Reactor使用MainSub , 然后才是 Acceptor

最先承受不住的明显是 Handler, 他需要处理很多操作, 不仅仅只有 readwrite

所以在很多Reacor多线程的介绍中, 通常都是 Acceptor Reactor是单线程, 但Handler是多线程

1

  • Reactor 主线程 MainReactor 对象通过 select 监听连接事件,收到事件后,通过 Acceptor 处理连接事件;
  • Acceptor 处理连接事件后,MainReactor 将连接分配给 SubReactor
  • SubReactor 将连接加入到连接队列进行监听,并创建 Handler 进行各种事件处理;
  • 当有新事件发生时,SubReactor 就会调用对应的 Handler 进行处理;
  • Handler 通过 read 读取数据,分发给后面的 Worker 线程进行处理;
  • Worker 线程池分配独立的 Worker 线程进行业务处理,并返回结果;
  • Handler 收到响应的结果后,再通过 send 将结果返回给 Client
  • Reactor 主线程可以对应多个 Reactor 子线程,即 MainRecator 可以关联多个 SubReactor

在上面的步骤中,前三个步骤是注册事件和监听事件,从后面开始是触发事件,分发事件任务

Reactor模式存在的问题

Reactor模式中存在的问题大抵是使用了同步IO操作, 比如 readwrite

因为Reactor的本质是监听, 触发然后内核会通知进程, 让进程主动调用 read 还是 write 函数, 而 readwrite 都是同步函数

这会导致线程被持续占有

那么有没有办法解决这个问题呢?

答案就是 Proactor模式

Proactor模式

Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程

抓住重点, 操作系统通知, 和 操作系统来处理 处理完毕之后才会通知进程

这样的好处在哪里呢? 也就是说, 我们不需要主动去关注我们是 read 还是 write , 也不需要我们主动去调用

我们只要等待内核通知我们说, 我们的数据准备好了, 让我们去内核缓冲区copy到用户态内存就行了

Proactor这不是妥妥的内核推模式么? 内核主动推数据给用户, 而Reactor模式更像是内核拉模式, 需要用户主动去拉取数据

内核推模式内核拉模式是两种不同的**socket异步I/O模式**。内核推模式是指当内核检测到socket有数据可读时,会主动将数据推送给应用程序,而不需要应用程序去查询或请求。这种模式的优点是可以减少应用程序的负担,缺点是可能会造成数据丢失或重复。内核拉模式是指当内核检测到socket有数据可读时,会通知应用程序,然后由应用程序主动去拉取数据。这种模式的优点是可以避免数据丢失或重复,缺点是需要应用程序多次调用I/O函数。

这里需要注意的是: Proactor关注的不是就绪事件,而是完成事件,这是区分Reactor模式的关键点

然而可惜的是,Linux下的异步 I/O 是不完善的,aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。

Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。

由于其windows系统用于服务器的局限性,目前应用范围较小

Linux Kernel 5.1io-uring 才完全支持纯异步 IO, 也就是Proactor模式

io-uring它也存在一些问题,比如:

  • 需要较高版本的内核,不同版本之间的特性和兼容性可能不一致。
  • 改变异步模式并不是一件容易的事,需要应用程序适应新的接口和编程方式。
  • 还有一些bug和缺陷需要修复和完善,比如对文件描述符、缓冲区、信号等资源的管理。

这些问题可能导致io-uring还没有被广泛使用。但是,随着内核的更新和应用程序的适配,io-uring有望成为Linux异步IO的未来。