五种 I/O 模型
先花费点时间了解这几种 I/O 模型,有助于后面的理解。
阻塞 I/O 与非阻塞 I/O
阻塞和非阻塞的概念能应用于所有的文件描述符,而不仅仅是 socket。我们称阻塞的文件描述符为阻塞 I/O,称非阻塞的文件描述符为非阻塞 I/O。
socket 在创建的时候默认是阻塞的,我们可以给 socket 系统调用的第 2 个参数传递 SOCK_NONBLOCK
标志,或者通过 fcntl
系统调用的 F_SETFL
命令将其设置为非阻塞的。
-
针对阻塞 I/O 执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。可能被阻塞的系统调用为 accept、send、recv 和 connect;
-
针对非阻塞 I/O 执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回
-1
,和出错的情况一样。此时我们必须根据errno
来区分这两种情况。-
对 accept、send、recv 而言,事件未发生时
errno
通常被设置为EAGAIN
(意为“再来一次”)或者EWOUDBLOCK
(意为“期望阻塞”); -
对 connect 而言,
errno
则被设置成EINPROGRESS
(意为“在处理中”)。
-
很显然,只有在事件已经发生的情况下操作非阻塞 I/O(读、写等),才能提高程序的效率。因此,非阻塞 I/O 通常要和其他 I/O 通知机制一起使用,比如 I/O 复用和 SIGIO 信号。
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 是否是阻塞的,因为真正的读写操作已经由内核接管。
两种高效的事件处理模式
Reactor 模式
Reactor 模式要求主线程(I/O 处理单元)只负责监听文件描述符上是否有事件发生。有的话立即通知工作线程(逻辑单元),读写数据、接受新的连接及处理客户请求均在工作线程中完成。通常由同步I/O实现。
工作流程:
-
主线程向 epoll 内核事件表中注册 socket 上的读就绪事件;
-
主线程调用
epoll_wait()
等待 socket 上有数据可读; -
当 socket 上有数据可读时,
epoll_wait()
通知主线程,主线程将 socket 可读事件插入请求队列; -
睡眠在请求队列上的某个工作线程被唤醒,它从 socket 上读取数据,并处理客户请求,然后往 epoll 内核事件表中注册该 socket 上的写就绪事件;
-
主线程调用
epoll_wait()
等待 socket 可写; -
当 socket 可写时,
epoll_wait()
通知主线程,主线程将 socket 可写事件放入请求队列; -
睡眠在请求队列上的某个工作线程被唤醒,它往 socket 上写入服务器处理客户请求的结果。
Proactor 模式
Proactor 模式将所有的 I/O 操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。通常由异步 I/O (aio_read()
和aid_write()
)实现。
工作流程
-
主线程调用
aio_read()
向内核注册 socket 上的读完成事件,并告诉内核 用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例); -
主线程继续处理其他逻辑;
-
当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用;
-
应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用
aio_write()
函数向内核注册 socket 上的写完成事件,并告诉内核 用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例); -
主线程继续处理其他逻辑;
-
当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕;
-
应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。
模拟 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 上写入服务器处理客户请求的结果。