【Linux 网络编程】生动讲解 Reactor 模式与 Proactor 模式

五种 I/O 模型

先花费点时间了解这几种 I/O 模型,有助于后面的理解。

阻塞 I/O 与非阻塞 I/O

阻塞和非阻塞的概念能应用于所有的文件描述符,而不仅仅是 socket。我们称阻塞的文件描述符为阻塞 I/O,称非阻塞的文件描述符为非阻塞 I/O。

socket 在创建的时候默认是阻塞的,我们可以给 socket 系统调用的第 2 个参数传递 SOCK_NONBLOCK 标志,或者通过 fcntl 系统调用的 F_SETFL 命令将其设置为非阻塞的。

  • 针对阻塞 I/O 执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。可能被阻塞的系统调用为 acceptsendrecvconnect

  • 针对非阻塞 I/O 执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回 -1,和出错的情况一样。此时我们必须根据 errno 来区分这两种情况。

    • acceptsendrecv 而言,事件未发生时 errno 通常被设置为 EAGAIN(意为“再来一次”)或者 EWOUDBLOCK(意为“期望阻塞”);

    • connect 而言,errno 则被设置成 EINPROGRESS(意为“在处理中”)。

很显然,只有在事件已经发生的情况下操作非阻塞 I/O(读、写等),才能提高程序的效率。因此,非阻塞 I/O 通常要和其他 I/O 通知机制一起使用,比如 I/O 复用和 SIGIO 信号。

笔者认为,我们使用非阻塞 I/O 的最佳情况是:【当我们进行系统调用的时候,它所需要的事件已经发生了】,这样系统调用就不会被阻塞,直接进行处理。比如 accept 函数,I/O 复用的好处就是当我们调用 accept 函数的时候,已经有客户端在请求连接,这样直接调用 accept,提高运行效率。

I/O 复用

I/O 复用是一种 I/O 通知机制,而且是最常用的通知机制。

I/O 复用是指应用程序通过 I/O 复用函数(select、poll、epoll_wait)向内核注册一组事件,内核通过 I/O 复用函数把其中就绪的事件通知给应用程序。

需要注意的是 I/O 复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个 I/O 事件的能力。

信号驱动 I/O

为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到 SIGIO 信号。这样,当文件描述符上有事件发生时,SIGIO 信号的信号处理函数将被触发,我们也就可以在该信号处理函数中对目标文件描述符执行非阻塞 I/O 操作了。

异步 I/O

理论上讲,阻塞 I/O、非阻塞 I/O、信号驱动 I/O 和 I/O 复用都是同步 I/O

  • 同步I/O:内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自动执行I/O操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区);
  • 异步I/O:内核向应用程序通知的是完成事件,比如读取客户端的数据之后才通知应用程序,由内核完成I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。

对异步 I/O 而言,用户可以直接对 I/O 执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及 I/O 操作完成之后内核通知应用程序的方式。异步 I/O 的读写操作总是立即返回,不论 I/O 是否是阻塞的,因为真正的读写操作已经由内核接管。

两种高效的事件处理模式

Reactor 模式

Reactor 模式要求主线程(I/O 处理单元)只负责监听文件描述符上是否有事件发生。有的话立即通知工作线程(逻辑单元),读写数据、接受新的连接及处理客户请求均在工作线程中完成。通常由同步I/O实现。

工作流程:

  • 主线程向 epoll 内核事件表中注册 socket 上的读就绪事件;

    告诉 socket 的对方(客户端):我这边准备好读数据啦,你可以发数据啦!

  • 主线程调用 epoll_wait() 等待 socket 上有数据可读;

  • 当 socket 上有数据可读时,epoll_wait() 通知主线程,主线程将 socket 可读事件插入请求队列;

    主线程:干活啦干活啦,这有个活,你们看看谁干了它!

  • 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 上读取数据,并处理客户请求,然后往 epoll 内核事件表中注册该 socket 上的写就绪事件;

    某一个苦工(工作线程)干完活之后告诉 socket 的对方:我准备好写了!

  • 主线程调用 epoll_wait() 等待 socket 可写;

  • 当 socket 可写时,epoll_wait() 通知主线程,主线程将 socket 可写事件放入请求队列;

    主线程:又来活啦,你们看看谁来干!

  • 睡眠在请求队列上的某个工作线程被唤醒,它往 socket 上写入服务器处理客户请求的结果。

    某个苦工又被唤醒来干活,写入处理结果。

Reactor 模式工作流程

Reacto 模式类似于老板(主线程)与苦工(工作线程)之间的关系,有活了老板就派给苦工来干(哭了…..莫名被 cue 到)

Proactor 模式

Proactor 模式将所有的 I/O 操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。通常由异步 I/O (aio_read()aid_write())实现。

工作流程

  • 主线程调用 aio_read() 向内核注册 socket 上的读完成事件,并告诉内核 用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例);

    主线程告诉内核:用户这边准备好收货了,你直接把货卸在这!你卸完了直接给用户打电话!

  • 主线程继续处理其他逻辑;

    溜了溜了,我先干点别的…

  • 当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用;

    苦逼的内核干完活,给用户打了电话通知他活干完了…

  • 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用 aio_write() 函数向内核注册 socket 上的写完成事件,并告诉内核 用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例);

    用户这边有很多苦工(工作线程),预先指定好了一个苦工来对接这批货物。这个苦工加工完所有的货物,告诉内核我这边货加工好了,放在老地方了,你直接过来拿!

  • 主线程继续处理其他逻辑;

    没我什么事,继续摸鱼(bushi

  • 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕;

    然后,内核就来拉货了,拉完货之后又给用户打电话:货我全拉走了!

  • 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。

    苦工收到消息,来看看是否需要清理场地…

Proactor 模式工作流程

可以看到,Proactor 模式相当于找了个快递员(内核)来帮助运输货物(读写数据),工作线程只需要处理业务逻辑,主线程只需要监听连接事件,读写事件由内核和工作线程直接通信。

模拟 Proactor 模式

由于 Proactor 模式需要异步 I/O 来实现,这里提出使用同步 I/O 的方式模拟出 Proactor 模式的一种方法。其原理是:主线程执行数据读写操作,读写完成后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

工作流程

  • 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件;
  • 主线程调用 epoll_wait() 等待 socket 上有数据可读;
  • 当 socket 上有数据可读时,epoll_wait() 通知主线程。主线程从 socket 循环读取数据,直到没有更多数据可读,然后将读到的数据封装成一个请求对象并插入请求队列;
  • 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核事件表中注册 socket 上的写就绪事件;
  • 主线程调用 epoll_wait() 等待 socket 可写;
  • 当 socket 可写时,epoll_wait() 通知主线程,主线程往 socket 上写入服务器处理客户请求的结果。

模拟 Proactor 模式工作流程

可以看到,模拟 Proactor 模式其实就是主线程自己来充当快递员(内核)的角色,所以在工作线程的角度来看与 Proactor 模式差不多。

本文参考自游双大神的《Linux 高性能服务器编程》一书。