此文大致记录了学习服务器程序编程框架中的 I/O模型、事件处理模式以及并发模式。

服务器编程框架

一般的服务器主要由三个模块组成:

  • I/O 处理单元。服务器用于管理客户连接的模块,主要完成如下工作:等待并接受新的客户连接,接受客户数据,将服务器响应数据返回给客户端。但是数据的收发不一定在 I/O 处理单元中执行,也可能在逻辑处理单元中执行,具体的处理取决于事件处理模式。
  • 逻辑处理单元。一个逻辑处理单元通常是一个进程或线程,用于分析并处理客户数据,将结果传递给 I/O 处理单元或者直接发送给客户,具体的执行也去取决于事件处理模式。
  • 网络存储单元。可以是数据库,缓存或文件。这个部分并不是必须的。

三个模块通过请求队列来进行通信。 I/O 处理单元接受到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求;同样,多个逻辑单元访问一个存储单元时,需要以某种机制来协调处理竞态条件。通常,请求队列会被实现为资源池。

image-20210406105745915

I/O 模型

阻塞I/O 和 非阻塞 I/O:

阻塞 I/O 和 非阻塞 I/O 的区别:

  • 对阻塞 I/O 执行的系统调用很可能无法立刻完成操作而别操作系统挂起,直到等待目标时间发生为止。

    • socket 编程中,可能被阻塞的系统调用包括 accept、connect、recv、send。
  • 对非阻塞 I/O 执行的系统调用则是立即返回,而不管时间是否发生。根据产生的 errno 来验证区分具体事件。

    • 对于accept、send、recv 三个系统调用,当时间未发生时, errno 通常被设置为 EAGAIN 或者 EWOULDBLOCK

    • 对于 connect 系统调用,errno 被设置为 EINPROGRESS(在处理中)

依据 阻塞 I/O 和 非阻塞 I/O 的不同,产生了两种早期的 I/O 模型,分别是

  • 轮询 + 非阻塞 I/O :这种实现方式类似于用户态的自旋锁,浪费了 CPU 时间;
  • 多线程 + 阻塞 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 模型则要求内核来执行 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 多路复用 I/O 复用函数阻塞,监听文件描述符非阻塞 同步 程序阻塞于 I/O 多路复用系统调用,监听的读个I/O事件是非阻塞的,对 I/O 本身的处理是非阻塞的
SIGIO 信号 非阻塞 同步 通过 SIGIO 信号出发就绪事件,设置信号处理函数完成 I/O 事件
异步 I/O - 异步 内核执行读写操作并处罚读写完成事件,程序并无阻塞阶段

两种高效的事件处理模式

Reactor 和 Proactor 事件处理模式是两种常见的网络设计模式,通常,同步 I/O 模型通常用于实现 Reactor 模式,异步 I/O模型式则用于实现 Proactor 模式,不过使用同步 I/O 亦可以模拟 Proactor 模式。

Reactor 模式

Reactor 模式要求主线程(即 I/O 处理单元)只负责监听文件面舒服是否有事情发生,有的话就立刻将该事件通知给工作线程(即逻辑单元),读写数据,接受新的连接,以及处理客户请求均在客户进程中完成。

使用同步 I/O 模型 实现的 Reactor 模式的工作流程如下:

  1. 主线程往 epoll 内核实现表中则测 socket 中的读就绪事件;
  2. 主线程调用 epoll_wait 等待 socket 中有数据可读;
  3. 当 socket 上有数据可读时,epoll_wait 通知主线程。主线程将 socket 可读事件放入请求队列;
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll 内核时间表中注册该 socket 的写就绪事件;
  5. 主线程调用 epoll_wait 等待 socket 可写;
  6. 当 socket 可写时,epoll_wait 通知主线程。主线程该 socket 可写事件放入请求队列。
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往 socket 写入 服务器处理客户请求的结果。

image-20210406130742490

Proactor 模式

Proactor 模式将所有的 I/O 操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

使用异步 I/O 模型 实现的 Proactor 模式的工作流程是:

  1. 主线程调用 aio_read 函数向内核注册 socket 中的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序;
  2. 主线程继续处理其他逻辑;
  3. 当 socket 上的数据被读入用户缓冲区以后,内核将向应用程序发送一个信号,已通知应用数据已经可用;
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序;
  5. 主线程继续处理其他逻辑;
  6. 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序以及发送完毕;
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理。比如决定是否关闭 socket。

下图为工作流程,其中的 epoll_wait 调用仅仅用来监听 socket 上的连接请求事件,不能用来检测连接 socket 上的读写事件。

image-20210406131453248

模拟 Proactor 模式

使用同步 I/O 方式能模拟出 Proactor 模式,其原理是:主线程执行数据读写操作,读写完成以后,主线程向工作线程通知这一完成事件;从工作线程的角度看,他们就直接获得了数据读写的结果,接下来要做的只是对读写结果进行逻辑处理。

使用同步 I/O 模型模拟出的 Proactor 模式的工作流程如下:

  1. 主线程往 epoll 内核时间表中注册 socket 上的读就绪事件;
  2. 主线程调用 epoll_wait 等待 socket 上有数据可读;
  3. 当 socket 上有数据可读时,epoll_wait 通知主线程。主线程将 socket 可读事件放入请求队列;
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核表中注册 socket 上的写就绪事件;
  5. 主线程调用 epoll_wait 等待 socket 可写;
  6. 当 socket 可写时,epoll_wait 通知主线程。主线程往 socket 上写入服务器处理客户请求的结果。

流程图如下:

image-20210406135226737

两种高效的并发模式

并发模式是指 I/O 处理单元和多个逻辑处理单元之间协调完成任务的方法。服务器主要有两种并发编程模式,分别是半同步 / 半异步(half-sync / half-async)和领导者 / 追随者 (leader / followers)模式。

半同步 / 半异步模式

并发模式中的同步和异步不同于 I/O 模型中提及的。

在 I/O 模型中,同步 和 异步 区分的是 内核向应用程序通知的是何种 I/O 事件(是就绪事件还是完成事件),以及该由谁来完成 I/O 读写(是应用程序还是内核)。

在并发模式中,同步指的是程序完全按照代码序列的顺序执行,异步指的是程序的执行需要由系统事件来驱动,例如中断、信号等。

按照同步方式执行的线程称为同步线程,按照异步方式执行的线程称为异步线程。

  • 异步线程的执行效率高,实时性强,但是编程相对复杂,难以扩展和调试,不适合大量开发
  • 同步线程的效率相对较低,实时性较差,但是逻辑简单。

对于像服务器程序这种既要求较好的实时性,又要求同时处理处理多个客户的请求的应用程序,就应该同时使用同步线程和异步线程来实现,即 半同步 / 半异步模式来实现。

半同步 / 半异步 模式中,同步线程用于处理客户逻辑,异步线程用于处理 I/O 事件;异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,取决于请求队列的设计,例如 Round Robin 算法,也可以以条件变量等线程同步手段来随机选择一个工作线程。下图为半同步 / 半异步模式的工作流程。

image-20210406205422495

半同步 / 半反应堆模式

在服务器程序中,如果考虑事件处理模式和 I/O 模型,则存在半同步 / 半异步 模式的多种变体。半同步 / 半反应堆(half-sync / half-reactive)模式就是一种。

下图中,异步线程只有一个,由主线程来充当。它负责监听所有 socket 上的事件。如果监听 socket 上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接 socket,然后往 epoll 内核事件表中注册该 socket 上的读写事件。如果连接 socket 上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接 socket 插人请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。

image-20210406205658150

主线程中插入请求队列的是就绪的连接 socket,说明半同步 / 半反应堆采用的事件处理模式是 Reactor 模式。它要求工作线程自己从 socket 上读取客户请求和往 socket 写人服务器应答。这就是该模式的名称中“half-reactive" 的含义。

此外,亦可以用 模拟的 Proacotr 事件处理模式来构建半同步 / 半反应堆模式,即主线程完成好数据读写后,将应用程序的数据,任务等信息封装为一个对象,插入到请求队列以后,工作线程直接从请求队列中取得对象以后,即可直接处理,无需进行读写操作。

半同步 / 半反应堆模式的缺点:

  • 线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费 CPU 时间。
  • 每个工作线程在同一时间只能处理-一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量 CPU 时间。

一种相对高效的半同步 / 半异步模式

下图为一种相对高效的半同步 / 半异步模式,它的每个工作线程都能同时处理多个客户连接。

image-20210406210414635

其中,主线程只管理监听 socket,连接 socket 由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接 socket 派发给某个工作线程,此后该新socket上的任何 I/O 操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发 socket 的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新 socket 上的读写事件注册到自己的 epoll 内核事件表中。

该模式中的每个线程都维持自己的事件循环,它们各自独立地监听不同地事件,都工作在异步模式,所以这并不是严格意义上地半同步 / 半异步模式。

领导者 / 追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负贵监听 I/O 事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到 I/O 事件,首先要从线程池中推选出新的领导者线程,然后处理 I/O 事件。此时,新的领导者等待新的 I/O 事件,而原来的领导者则处理 I/O 事件,二者实现了并发。

参考

游双 《Linux 高性能服务器编程》