高级 IO 模型之 kqueue 和 epoll

参考:一篇文章让你真正搞懂epoll机制 - 知乎

简介

任何一个程序都离不开 IO,有些是很明显的 IO,比如文件的读写,也有一些是不明显的 IO,比如网络数据的传输等。那么这些 IO 都有那些模式呢?我们在使用中应该如何选择呢?高级的 IO 模型 kqueue 和 epoll 是怎么工作的呢?一起来看看吧。

block IO 和 nonblocking IO

大家先来了解一下 IO 模型中最简单的两个模型:阻塞 IO 和非阻塞 IO。

比如我们有多个线程要从一个 Socket server 中读取数据,那么这个读取过程其实可以分成两个部分,第一部分是等待 socket 的数据准备完毕,第二部分是读取对应的数据进行业务处理。对于阻塞 IO 来说,它的工作流程是这样的:

  1. 一个线程等待 socket 通道数据准备完毕。
  2. 当数据准备完毕之后,线程进行程序处理。
  3. 其他线程等待第一个线程结束之后,继续上述流程。

为什么叫做阻塞 IO 呢?这是因为当一个线程正在执行的过程中,其他线程只能等待,也就是说这个 IO 被阻塞了。

什么叫做非阻塞 IO 呢?

还是上面的例子,如果在非阻塞 IO 中它的工作流程是这样的:

  1. 一个线程尝试读取 socket 的数据。
  2. 如果 socket 中数据没有准备好,那么立即返回。
  3. 线程继续尝试读取 socket 的数据。
  4. 如果 socket 中的数据准备好了,那么这个线程继续执行后续的程序处理步骤。

为什么叫做非阻塞 IO 呢?这是因为线程如果查询到 socket 没有数据,就会立刻返回。并不会将这个 socket 的 IO 操作阻塞。

从上面的分析可以看到,虽然非阻塞 IO 不会阻塞 Socket,但是因为它会一直轮询 Socket,所以并不会释放 Socket。

IO 多路复用和 select

IO 多路复用有很多种模型,select 是最为常见的一种。实时不管是 netty 还是 JAVA 的 NIO 使用的都是 select 模型。

select 模型是怎么工作的呢?

事实上 select 模型和非阻塞 IO 有点相似,不同的是 select 模型中有一个单独的线程专门用来检查 socket 中的数据是否就绪。如果发现数据已经就绪,select 可以通过之前注册的事件处理器,选择通知具体的某一个数据处理线程。

这样的好处是虽然 select 这个线程本身是阻塞的,但是其他用来真正处理数据的线程却是非阻塞的。并且一个 select 线程其实可以用来监控多个 socket 连接,从而提高了 IO 的处理效率,因此 select 模型被应用在多个场合中。

为了更加详细的了解 select 的原理,我们来看一下 unix 下的 select 方法:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

先来解释一下这几个参数的含义,我们知道 unix 系统中,一切的对象都是文件,所以这里的 fd 表示的就是 file descriptor ,也就是文件描述符。

fds 表示的是 file descriptor sets,也就是文件描述符集合。

nfds 是一个整数值,表示的是文件描述符集合中最大值+1.

readfds 是要检查的文件读取的描述符集合。

writefds 是要检查的文件写入的描述符集合。

errorfds 是要检查的文件异常描述符集合。

timeout 是超时时间,表示的是等待选择完成的最大间隔。

其工作原理是轮询所有的 file descriptors,然后找到要监控的那些文件描述符,

poll

poll 和 select 类很类似,只是描述 fd 集合的方式不同. poll 主要是用在 POSIX 系统中。

epoll

实时上,select 和 poll 虽然都是多路复用 IO,但是他们都有些缺点。而 epoll 和 kqueue 就是对他们的优化。它是一种事件驱动的I/O模型

epoll 是 linux 系统中的系统命令,可以将其看做是 event poll。首次是在 linux 核心的 2.5.44 版本引入的。

主要用来监控多个 file descriptors 其中的 IO 是否 ready。

对于传统的 select 和 poll 来说,因为需要不断的遍历所有的 file descriptors,所以每一次的 select 的执行效率是 O(n) ,但是对于 epoll 来说,这个时间可以提升到 O(1)。

这是因为 epoll 会在具体的监控事件发生的时候触发通知,所以不需要使用像 select 这样的轮询,其效率会更高。

epoll 使用红黑树 (RB-tree) 数据结构来跟踪当前正在监视的所有文件描述符。

epoll 有三个 api 函数:

int epoll_create1(int flags);

用来创建一个 epoll 对象,并且返回它的 file descriptor。传入的 flags 可以用来控制 epoll 的表现。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

这个方法用来对 epoll 进行控制,可以用来监控具体哪些 file descriptor 和哪些事件。

这里的 op 可以是 ADD, MODIFY 或者 DELETE。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait 用来监听使用 epoll_ctl 方法注册的事件。

epoll 提供了两种触发模式,分别是 edge-triggered 和 level-triggered。

如果一个使用 epoll 注册的 pipe 收到了数据,那么调用 epoll_wait 将会返回,表示存在要读取的数据。但是在 level-triggered 模式下,只要管道的缓冲区包含要读取的数据,对 epoll_wait 的调用将立即返回。但是在 level-triggered 模式下,epoll_wait 只会在新数据写入管道后返回。

kqueue

kqueue 和 epoll 一样,都是用来替换 select 和 poll 的。不同的是 kqueue 被用在 FreeBSD,NetBSD, OpenBSD, DragonFly BSD, 和 macOS 中。

kqueue 不仅能够处理文件描述符事件,还可以用于各种其他通知,例如文件修改监视、信号、异步 I/O 事件 (AIO)、子进程状态更改监视和支持纳秒级分辨率的计时器,此外 kqueue 提供了一种方式除了内核提供的事件之外,还可以使用用户定义的事件。

kqueue 提供了两个 API,第一个是构建 kqueue:

int kqueue(void);

第二个是创建 kevent:

int kevent(int kq, const struct kevent *changelist, int nchanges, struct kevent *eventlist, int nevents, const struct timespec *timeout);

kevent 中的第一个参数是要注册的 kqueue,changelist 是要监视的事件列表,nchanges 表示要监听事件的长度,eventlist 是 kevent 返回的事件列表,nevents 表示要返回事件列表的长度,最后一个参数是 timeout。

除此之外,kqueue 还有一个用来初始化 kevent 结构体的 EV_SET 宏:

EV_SET(&kev, ident, filter, flags, fflags, data, udata);

epoll 和 kqueue 的优势

epoll 和 kqueue 之所以比 select 和 poll 更加高级, 是因为他们充分利用操作系统底层的功能,对于操作系统来说,数据什么时候 ready 是肯定知道的,通过向操作系统注册对应的事件,可以避免 select 的轮询操作,提升操作效率。

要注意的是,epoll 和 kqueue 需要底层操作系统的支持,在使用的时候一定要注意对应的 native libraries 支持。