vlambda博客
学习文章列表

IO模型和IO多路复用系统调用

IO操作数据流程

通常的应用场景是用户通过网页发送一个http get请求,从网页获取一个文件。我们探究一下这个文件的数据经历了哪些流程。

图1:IO数据流程

上图描述了一个典型的业务应用场景,通过网页获取一个文件。当服务器进程httpd获取到请求并需要将index.html的数据从磁盘中加载到自己的httpd app buffer中,然后复制到send buffer中发送出去。

但是在httpd想要加载index.html时,它首先检查自己的app buffer中是否有index.html对应的数据,没有就发起read()系统调用让内核去加载数据,内核会先检查自己的kernel buffer中是否有index.html对应的数据,如果没有,则从磁盘中加载,然后将数据准备到kernel buffer,再复制到app buffer中,最后被httpd进程处理。httpd进程有了数据,就调用send系统调用,通过tcp连接把数据先拷贝到tcp socket的发送缓冲区,然后通过DMA copy拷贝到网卡,经过网络发送到客户端。客户端经过类似流程读取到自己进程展示给用户。

其中从存储(一般为磁盘)到kernel buffer是通过DMA copy,即direct memory access,直接内存访问。简单地说,就是内存和设备之间的数据交互可以直接传输,不再需要计算机的CPU参与,而是通过硬件上的芯片(非CPU)进行控制。而kernel buffer到app buffer的拷贝,这是两段内存空间的数据传输,只能由内核占用CPU来完成拷贝。

网络IO数据流总结

通过网络发送数据时,一般是应用进程从存储中读取数据到内存,再通过网络的系统调用,发送到网络连接的另一端。对应的系统层面的数据流为,文件从存储通过直接内存访问(DMA copy)拷贝到进程的内核缓冲区,内核缓冲区有数据后,进程的内核线程通过使用CPU把内核空间的缓冲区内容拷贝到用户进程的缓冲区,用户进程有了数据,通过发起send系统调用,发送数据到网络对端,先通过CPU把用户进程缓冲区内容拷贝到内核空间的socket发送缓冲区,再通过直接内存访问(DMA copy)拷贝到网卡,经过网络发送到对端机器。

UNIX 5种IO模型

所谓的IO模型,描述的是出现I/O等待时进程的状态以及处理数据的方式。围绕着进程的状态、数据准备到kernel buffer再到app buffer的两个阶段展开。其中数据复制到kernel buffer的过程称为数据准备阶段(这个阶段通过DMA copy,不需要CPU参与),数据从kernel buffer复制到app buffer的过程称为数据复制阶段(这个阶段是CPU copy,需要内核线程占用CPU进程拷贝)。

UNIX网络IO的模型大致有如下几种:

阻塞IO(bloking IO)

非阻塞IO(non-blocking IO)

多路复用IO(multiplexing IO)

信号驱动式IO(signal-driven IO)

异步IO(asynchronous IO)

阻塞IO模型

阻塞IO模型是最常见的IO模型,默认情况下所有socket都是阻塞的。recvfrom从一个套接字里面读取数据的系统调用,默认是阻塞的,即应用进程调用recvfrom后,然后切换到内核空间中运行,直到数据报到达且被复制到应用进程缓冲区中才返回。我们说进程从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

非阻塞IO模型

当我们将套接字设置为非阻塞时,我们是在告诉内核“当我请求的I/O操作不能在不让进程休眠的情况下完成时,不要让进程休眠,而是返回一个错误(EWOULDBLOCK )。”

一般情况下,我们需要在一个循环里面去读取一个非阻塞的socket,直到读取成功才退出循环。这种行为叫做轮询。但非阻塞IO+轮询通常并不是一种好的选择,频繁的轮询白白耗费CPU资源,还造成大量的上下文切换(每执行一次recvfrom要切换到内核态,socket buffer没内容又要切换为用户态)。

多路复用IO

所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符(包括socket),一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll、kqueue来配合。

在多路复用IO模型中,会有一个内核线程不断地去轮询多个 socket 的状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用。

  • IO复用模式是使用select或者poll函数向系统内核发起调用,阻塞在这两个系统函数调用,而不是真正阻塞于实际的IO操作(recvfrom调用才是实际阻塞IO操作的系统调用)。

  • 阻塞于select函数的调用,等待数据报套接字变为可读状态。

  • 当select套接字返回可读状态的时候,就可以发起recvfrom调用把数据报复制到用户空间的缓冲区。

多路复用IO也是一种阻塞IO,不过不会阻塞于实际的IO操作,而是阻塞在select,epoll等系统调用。select和epoll的返回结果,会告诉我们,哪些描述符已经就绪(可读写),这时再对所有就绪的socket调用recvfrom,把数据报从内核缓冲区拷贝到用户缓冲区。这种IO模型适用于服务器高并发场景,通常一台服务器要负责成千上万甚至上百万的用户连接,不可能为每个socket连接都开一个线程来处理数据的recv和send,而是用一个线程来处理多个socket连接的数据收发。

信号IO模型

我们也可以使用信号,告诉内核在描述符准备好时用 SIGIO 信号通知我们。我们称之为信号驱动 I/O。

在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。

这个一般用于UDP中,对TCP套接字几乎没用,原因是该信号产生得过于频繁(tcp是面向连接的协议,有太多种情况会产生SIGIO信号,比如连接请求完成,断开连接发起,断开连接完成,数据到达socket,数据从socket发送走),并且该信号的出现并没有告诉我们发生了什么请求,导致我们不知道该做哪种处理,因此实际很少使用,不过可以对TCP监听套接字可以使用SIGIO,因为对于监听套接字,产生SIGIO信号的唯一条件是某个新连接完成了。这样就可以在SIGIO信号处理函数中获取新连接了。

异步IO模型

前面四种IO模型实际上都属于同步IO,只有最后一种异步IO模型是真正的异步IO,因为无论是多路复用IO模型还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。

异步IO,是POSIX定义的规范,通常,异步IO的函数通过告诉内核开始操作并在整个操作(包括将数据从内核复制到我们的缓冲区)完成时通知我们来工作。该模型与信号驱动 I/O 模型的主要区别在于,对于信号驱动 I/O,内核会告诉我们何时可以启动 I/O 操作(需要用户线程自己调用recvfrom系统调用来将内核缓冲区数据拷贝到用户缓冲区),但对于异步 I/O,内核会告诉我们 I/O 操作何时完成(整个IO操作已完成)。

相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程。当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉它read操作完成了。

在 Linux 中,通知的方式是“信号”:

  • 如果这个进程正在用户态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事。

  • 如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知。

  • 如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。

五种IO模型总结

阻塞和非阻塞区别

阻塞与非阻塞指的是应用程序等待调用结果结果时的状态,阻塞指的是在内核缓冲区没有数据时,recv调用会阻塞在内核态等待内核缓冲区有数据才返回。而非阻塞指的是在内核缓冲区没有数据时,recv调用会直接返回EWOULDBLOCK(error)。

同步IO和异步IO的区别

POSIX 对这两个术语的定义如下:

  • 同步 I/O 操作会导致请求进程被阻塞,直到该 I/O 操作完成。

  • 异步 I/O 操作不会导致请求进程被阻塞。

使用这些定义,前四个 I/O 模型(阻塞、非阻塞I/O 多路复用和信号驱动 I/O)都是同步的,因为实际的 I/O 操作 (recvfrom) 会阻塞进程。只有异步 I/O 模型与异步 I/O 定义匹配。

同步与阻塞,异步与非阻塞并不是同义词,同步也可以是非阻塞的。比如非阻塞IO就是同步非阻塞的,如果内核缓冲区有数据则会直接从内核缓冲区读取数据,否则直接返回异常抛出的结果,因此非阻塞IO是同步的,同样进程不会阻塞在调用里,因此是非阻塞的。当然异步一般都是非阻塞的,否则异步将没有意义(相当于调用会帮助进程完成任务并通知进程任务完成,进程没必要还阻塞等待这个调用发出任务完成的通知)。

五种IO模型的应用场景

  • 阻塞IO与非阻塞IO 这是最简单的模型,一般配合多线程来实现。阻塞IO是默认的IO模型;非阻塞IO,一般要在用户进程加轮询查看IO状态,以完成读写。

  • 多路复用(select/poll/epoll/kqueqe):一个线程解决多连接的问题,常用于服务端处理并发连接。

  • 信号驱动IO模型:一种同步IO,更加灵活。但实际使用比较少,因为tcp连接中,触发信号的事件太多了,而且远不如异步IO方便。

  • 异步IO模型:高效主流的模型,效率很高。AIO 方式适用于连接数目多且连接比较长(重操作)的架构, 比如redis数据库就是使用异步非阻塞IO。

IO多路复用

前面讲了,IO多路复用是为了解决一个进程(或线程)同时监控多个连接的问题。目前linux提供的IO多路复用的系统调用主要有select,poll,epoll,kqueue。

select

select函数可以指示内核等待一个或多个事件发生,然后等其中一个或多个事件发生时,唤醒调用select的进程。或者到达select指定的超时时间,也会唤醒调用select的进程。

这意味着,我们需要通过select系统调用告诉内核我们关心哪些文件描述符的读事件,哪些文件描述符的写事件,哪些文件描述符的异常事件,并且指示超时时间。

select系统调用接口形式

#include <sys/select.h>
#include <sys/time.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
/* Returns: positive count of ready descriptors, 0 on timeout, 1 on error */

struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};

操作 fd_set 集合的宏
void FD_CLR(int fd, fd_set *set); // 将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *set); // 检查集合中指定的文件描述符是否可以读、写、有发生异常
void FD_SET(int fd, fd_set *set); // 将一个给定的文件描述符加入集合之中
void FD_ZERO(fd_set *set); // 清空集合

上面是select系统调用的接口形式,共有5个参数,参数说明如下:

  • nfds:一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1。

  • readfds:指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的读变化的,即我们关心是否可以从这些文件中读取数据了,如果这个集合中有一个文件可读,select就会返回一个大于0的值,表示有文件可读;如果没有可读的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的读变化。

  • writefds:指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的写变化的,即我们关心是否可以向这些文件中写入数据了,如果这个集合中有一个文件可写,select就会返回一个大于0的值,表示有文件可写,如果没有可写的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的写变化。

  • exceptfds:指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符是否有异常条件发生。

  • timeout:是select的超时时间,这个参数至关重要,它可以使select处于三种状态

    第一,若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;

    第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;

    第三,timeout的值大于0,这就是等待的超时时间,即 select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回,返回值同上述。

函数返回:

  • (1)当监视的相应的文件描述符集中满足条件时,比如说读文件描述符集中有数据到来时,内核(I/O)根据状态修改文件描述符集,并返回一个大于0的数。

  • (2)当没有满足条件的文件描述符,且设置的timeval监控时间超时时,select函数会返回一个为0的值。

  • (3)当select返回-1时,发生错误。

文件描述符就绪的条件

读就绪的条件
  • 套接字接收缓冲区中的数据字节数大于或等于套接字接收缓冲区的低水位标记的当前大小。对套接字的读取操作不会阻塞,并且会返回一个大于 0 的值(即套接字接收缓冲区有可以读取的数据)。我们可以使用 SO_RCVLOWAT 套接字选项设置这个低水位标记。对于 TCP 和 UDP 套接字,它默认为 1。

  • 连接的读取部分已关闭(即已收到 FIN 的 TCP 连接)。对套接字的读操作不会阻塞,将返回 0(即 EOF)。

  • 该套接字是一个侦听套接字,并且已完成的连接数非零。

  • 套接字错误未决,比如对一个未建立连接的socket读取数据等。对套接字的读取操作不会阻塞,并且会返回错误 (–1),并将 errno 设置为特定的错误条件。

写就绪的条件
  • 套接字发送缓冲区中可用空间的字节数大于或等于套接字发送缓冲区的低水位标记的当前大小,并且套接字已连接,或套接字不需要连接(例如,UDP)。我们可以使用 SO_SNDLOWAT 套接字选项设置这个低水位标记。对于 TCP 和 UDP 套接字,此低水位标记通常默认为 2048。即默认情况下,当套接字发送缓冲区可用空间大于2048字节的时候会被认为写就绪。

  • 连接的写入部分关闭(即收到RST的socket)。对套接字的写操作将生成 SIGPIPE。该信号的默认动作是终止进程,因此进程必须捕获该信号以避免被非自愿终止。

  • 使用非阻塞连接的套接字已完成连接,或连接失败。

  • 套接字错误未决,比如对一个未建立连接的socket写数据等。对套接字的写操作不会阻塞,并且会返回错误 (–1),并将 errno 设置为特定的错误条件。

从上面读就绪,写就绪的条件可以看出当套接字发生错误时,它会被标记为读就绪也会标记为写就绪。

异常描述符就绪的条件

如果套接字存在带外数据或套接字仍处于带外标记处,则该套接字具有未决的异常条件。所谓带外数据即URG 字段为1的紧急数据,现在已经几乎不用了。

select实现多路复用总结

select是多路复用最早和最简单的实现,可以在一个线程中监控和处理多个socket的状态,然后做出相应处理。它的缺点很明显,调用返回结果只有三种情况,有n个事件发生,没有事件发生,select调用出现异常。一般情况下——有n个事件发生时,select的调用者并不知道究竟是哪个文件描述符的哪个事件发生了,只能遍历传入参数的3个文件描述符数组,查询每一个描述符的状态。以select具有O(n)的无差别轮询复杂度,处理的socket越多,遍历查询的时间也越长,效率较低。

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024,所以select最多监控1024个文件描述符;

现在使用select的场景比较少,但是对于少量(几十几百数量级)的大部分长期处于活跃状态的socket,使用select来监控和处理的效率还是很高的。

此外poll系统调用是一种类似select的多路复用系统调用,不过它用链表存储关心的文件描述符,所以没有监控最大文件数量的限制,但它同样要经过fd从用户空间到内核空间的拷贝,同样要经过O(n)的无差别轮询,才能完成读写。因此不过多介绍

epoll

前面的select和poll已经提供了实现多路复用的思路,但主要问题在于需要用户程序自己遍历描述符才能知道哪个文件描述符就绪。如果能给套接字注册某个回调函数,当他们活跃(有读写异常就绪事件发生)时,自动完成相关操作,那就避免了轮询。epoll和kqueqe就是这么做的。

epoll接口是Linux内核的可扩展I/O事件通知机制,是为了解决Linux内核处理大量文件描述符而提出的方案。该接口属于Linux下多路I/O复用接口中select/poll的增强。它经常应用于Linux下高并发服务型程序,特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的CPU利用率。

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是 事件驱动(每个事件关联上fd) 的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

epoll设计思路

  • (1)epoll在Linux内核中构建了一个文件系统(即epoll实例对象,也可以用一个文件描述符表示),这个文件系统可以视为interest list和ready list的组合。

    interest list: 即记录用户关心哪些文件描述符的何种事件,这个成员采用红黑树来构建,红黑树在增加和删除上面的效率极高O(log n),因此是epoll高效的原因之一。

    ready list: 即记录发生了就绪事件的文件描述符的集合,用链表表示。这个集合是interest list的子集。这个就绪链表会随着IO活动被内核自动扩充。

  • (2)epoll提供了两种触发模式,水平触发(LT)和边沿触发(ET)。当然,涉及到I/O操作也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET+非阻塞I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。

  • (3)epoll所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024,可以通过cat /proc/sys/fs/file-max 查看,一般是百万,千万级别。

epoll相关的系统调用

epoll_create
#include <sys/epoll.h>
int epoll_create(int size);

epoll_create系统调用创建一个epoll实例对象,并返回这个epoll实例对象的文件描述符。size表示关心的文件描述符的数量,从Linux2.6.8之后size参数就会被忽略,但是如果输入必须保证size大于0。这个文件描述符会用于后面所有的epoll相关的系统调用接口。当这个epoll实例对象不再需要使用时,应该调用close来关掉。当这个epoll实例引用的所有文件描述符都被关闭,内核会销毁这个实例并释放相关的资源以供重用。

epoll_ctl

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

typedef union epoll_data
{
void* ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t; /* 保存触发事件的某个文件描述符相关的数据 */

struct epoll_event
{

__uint32_t events; /* epoll event */
epoll_data_t data; /* User data variable */
};
/* epoll_event.events:
EPOLLIN 表示对应的文件描述符可以读
EPOLLOUT 表示对应的文件描述符可以写
EPOLLPRI 表示对应的文件描述符有紧急的数据可读
EPOLLERR 表示对应的文件描述符发生错误
EPOLLHUP 表示对应的文件描述符被挂断
EPOLLET 表示对应的文件描述符有事件发生
*/

这个系统调用用于添加、修改或删除被epfd引用的interest list。参数释义:

  • epfd:为epoll实例对象的句柄(epoll的文件描述符)

  • op:表示动作,用3个宏来表示:

    EPOLL_CTL_ADD(注册新的 fd 到epfd)

    EPOLL_CTL_DEL(从 epfd 中删除一个 fd)

    EPOLL_CTL_MOD(修改已经注册的 fd 监听事件)

  • fd :用户关心的文件描述符。

  • event参数描述了fd关注的事件。epoll_event 结构体的data成员指定了内核应该保存的数据,然后在文件描述符就绪的时候返回这个数据。epoll_event 的events成员指示一个事件类型,定义如上面代码所示。

epoll_ctl的返回值:当epoll_ctl操作成功时,函数返回0,;当epoll_ctl操作失败时,函数返回-1,并设置error_no为相应的值。

epoll_wait

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

epoll_wait() 系统调用阻塞等待文件描述符 epfd 引用的 epoll 实例上的事件返回事件的数目,并将触发的事件写入events数组中。

  • events参数描述一个由用户分配的struct epoll event数组,调用返回时,内核将ready list复制到这个数组中,并将实际复制的个数作为返回值。注意,如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。

  • maxevents:返回的events的最大个数,必须大于零。

  • timeout: 指示epoll_wait最多阻塞的时间。

    timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;

    timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;

    timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。

水平触发和边缘触发

epoll事件机制可以运行在水平触发模式和边缘触发模式,水平触发模式即每次socket的缓冲区有数据,这个socket都会被加到ready-list,而边缘触发则是只有当缓冲区有新的数据到来才会加socket加到ready list。

LT(level triggered) 是 缺省 的工作方式 ,并且同时支持 block 和 no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的 select/poll 都是这种模型的代表。

ET(edge-triggered) 是高速工作方式 ,只支持 no-block socket 。如果希望使用边缘触发,可以在epoll_ctl添加文件描述符时,在events参数加上EPOLLET 标志。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。使用边缘触发的应用应该使用非阻塞文件描述符来防止阻塞读或者处理多个文件描述符的写任务出现饿死现象。推荐的边缘模式的使用方式是:把所有文件描述符设置为非阻塞模式,只有在所有文件描述符读写都返回EAGAIN(即都读写完成)时才进行下一次epoll_wait。

select和epoll对比

IO多路复用实现步骤 select epoll
用户态怎么将文件句柄传递到内核态? select创建3个文件描述符集,并将这些文件描述符拷贝到内核中,这里限制了文件句柄的最大的数量为1024(注意是全部传入---第一次拷贝) 首先执行epoll_create在内核专属于epoll的高速cache区,并在该缓冲区建立红黑树和就绪链表,用户态传入的文件句柄将被放到红黑树中(第一次拷贝)。
内核态怎么判断I/O流可读可写 ? 内核针对读缓冲区和写缓冲区来判断是否可读可写,这个动作和select无关 内核针对读缓冲区和写缓冲区来判断是否可读可写,这个动作与epoll无关
内核怎么通知监控者有I/O流可读可写? 内核在检测到文件句柄可读/可写时就产生中断通知监控者select,select被内核触发之后,就返回可读可写的文件句柄的总数 epoll_ctl执行add动作时除了将文件句柄放到红黑树上之外,还向内核注册了该文件句柄的回调函数,内核在检测到某句柄可读可写时则调用该回调函数,回调函数将文件句柄放到就绪链表。
监控者如何找到可读可写的I/O流并传递给用户态应用程序? select会将之前传递给内核的文件句柄再次从内核传到用户态(第2次拷贝),select返回给用户态的只是可读可写的文件句柄总数,再使用FD_ISSET宏函数来检测哪些文件I/O可读可写(遍历); epoll_wait只监控就绪链表就可以,如果就绪链表有文件句柄,则表示该文件句柄可读可写,并返回到用户态(少量的拷贝)
继续循环时监控者怎样重复上述步骤? select对于事件的监控是建立在内核的修改之上的,也就是说经过一次监控之后,内核会修改位,因此再次监控时需要再次从用户态向内核态进行拷贝(第N次拷贝) 由于内核不修改文件句柄的位,因此只需要在第一次传入就可以重复监控,直到使用epoll_ctl删除,否则不需要重新传入,因此无多次拷贝。

kqueue

epoll已经很完美了,但是只在Linux操作系统上支持,在其它类Unix操作系统上不被支持。kqueue是一个类似epoll的在macOS和FreeBSD操作系统上被支持的IO多路复用系统调用。

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

struct kevent {
uintptr_t ident; /* identifier for this event */
short filter; /* filter for event */
u_short flags; /* action flags for kqueue */
u_int fflags; /* filter flag value */
int64_t data; /* filter data value */
void *udata; /* opaque user data identifier */
uint64_t ext[4]; /* extensions */
};

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

kqueue系统调用类似于epoll_create,它创建一个新的kqueue,它是一个通知通道或者可以称为通知队列,应用程序可以向它注册自己感兴趣的事件,应用程序也是从这个队列取出内核的文件描述符就绪事件。kqueue的返回值是文件描述符id,它可以和普通文件描述符一样作为select/poll的参数,甚至也可以注册在另一个kqueue上。

kevent系统调用用于注册应用程序关心的事件到kqueue上,并且返回其中发生的事件给应用程序。

  • changelist: 是一个指向kevent结构体数组的指针,它记录了用户感兴趣的事件。

  • nchanges: 指示changelist的大小

  • eventlist:是一个指向kevent结构体数组的指针,它记录了 kevent阻塞期间发生的事件列表。

  • nevents:决定了eventlists的大小(上限)。当nevents设置为0,kevent调用会立刻返回,即使timeout设置非0。

  • timeout:kevent阻塞的超时时间,和select的timeout类似,如果这个参数为null,kevent会无限阻塞,直到有关注的事件发生或者收到信号。

EV_SET是用于初始化kevent结构体的宏。

struct kevent结构体解析

上面定义struct keven结构体是kevent操作的基本数据结构,在一个kqueue中<ident, filter>唯一确定一个事件。

  • ident参数是事件的id,一般设置为文件描述符,这里没有明确地写是文件描述符id,是因为kqueue不仅仅是select/poll的替代,为了实现套接字事件的多路复用,而是设计为一种更通用的机制来实现各种操作系统事件的多路复用。

  • filter参数指示了内核事件的类型,它是在内核事件发生后将会执行的一小段内核代码的标志符。一般为读,写。

  • flags参数用来指示应该对kqueue做什么操作,比如EV_ADD:指示加入kevent事件到 kqueue, EV_DELETE:指示将传入的事件从 kqueue 中移除。

  • fflags用来指定在注册时会对应用程序感兴趣的描述符做什么操作。后面的四个参数一般都设置为0或者null,在IO多路复用时一般不会用到。

epoll和kqueue的对比

  • epoll设计有一个缺陷就是它不支持在一个系统调用(epoll_ctl)里添加多个应用程序感兴趣的事件。当我对100个文件描述符的读写事件感兴趣的时候,我必须执行100次epoll_ctl系统调用。但是通过kevent系统调用却可以通过changelist数组参数指定多个应用程序感兴趣的事件。

  • 计算机界常说:在UNIX中一切都是文件。但是这只是大多数情况,还是存在不属于文件的例外。比如计时器不是文件,信号不是文件,信号量不是文件。epoll只能支持文件描述符的多路复用,而kqueue更通用,可以支持上述的信号,信号量,进程id的多路复用等。

  • 在 kqueue 中,多功能的 struct kevent 结构支持各种非文件事件。也就是说kqueue支持更多的事件类型,不限于文件可读可写等。例如,您的应用程序可以在子进程退出时收到通知(过滤器 = EVFILT_PROC、ident = pid 和 fflags = NOTE_EXIT)。

IO多路复用总结

IO多路复用,就是用一个进程或线程监控多个socket或者普通文件描述符的状态。select和poll只是监控到了socket有关注的事件发生时,内核返回发生的事件数量。应用程序需要自己去遍历输入的文件描述符,来确定哪些文件描述符发生了可读(写)事件。这是一个O(n)的扫描,因此效率很低,而且受限于进程打开文件描述符数量限制,能监控的文件描述符很少。另外每次调用select或者poll阻塞等待事件发生时,都需要传入用户感兴趣的所有文件描述符,n次调用会经历n次从用户态到内核态的文件描述符数组的拷贝。

epoll系列系统调用通过epoll_create系统调用创建了一个epoll_fd的实例对象来存储用户感兴趣的事件列表和阻塞等待期间内核发生的事件列表,并通过epoll_ctl来增加删除和修改应用程序感兴趣的事件列表,然后通过epoll_wait阻塞等待感兴趣的事件发生。。另外由于epoll_fd存储了用户感兴趣的事件列表,在多次epoll_wait时不需要多次拷贝用户感兴趣的文件描述符。最后epoll_wait调用返回时,可以从events参数直接拿到阻塞等待期间内核发生的事件列表。而不需要重新遍历输入的感兴趣列表。因此O(1)的操作。

epoll之所以能实现这样的功能,是因为内核帮我们完成了在文件描述符有事件发生时,通过回调函数自动维护epoll_fd数据结果里的ready_list,它记录了各个文件描述符发生的事件。最后在epoll_wait返回时,把epoll_fd里的ready_list拷贝到events数组,返回给用户态的程序。而select系统调用内核只在检测到用户关心的文件描述符里有事件发生了就直接返回了,要用户态程自己去查哪些文件描述符发生了事件,所以效率很低。

epoll已经很好用了,但是只在linux下支持。kqueue是FreeBSD下实现的一个类似epol的多路复用的系统调用,而且相比epoll它可以支持一次添加多个用户感兴趣的事件列表,因此效率会更高一点。另外kqueue的设计初衷是为了通用的多路复用,不仅仅只是支持文件描述符的多路复用,也支持信,进程id等的多路复用。

参考文档

  • [1] select 系统调用

  • [2] epoll 系统调用

  • [3] kqueue 系统调用

  • [4] Jonathan Lemon. Kqueue: A generic and scalable event notification facility