彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,poll,epoll原创
本文为《高性能网络编程游记》的第三篇 “IO复用:IO处理杀手锏,带您深入了解select,poll,epoll”。
看完本文,您将了解到:
-
IO复用的执行流程;
-
select函数的使用和优缺点,以及实现原理;
-
poll函数的使用和优缺点,以及实现原理;
-
epoll函数的使用和优缺点,以及实现原理;
-
epoll的条件触发和边缘触发,以及实现原理。
1、I/O复用模型介绍
I/O复用(I/O multiplexing),指的是通过一个支持同时感知多个描述符的函数系统调用,阻塞在这个系统调用上,等待某一个或者几个描述符准备就绪,就返回可读条件。常见的如select,poll,epoll系统调用可以实现此类功能功能。这种模型不用阻塞在真正的I/O系统调用上。
工作原理如下图所示:
如上图,这种模型与非阻塞式I/O相比,把轮训判断数据是否准备好的处理方式替换为了通过select()系统调用的方式来实现。
常用的实现IO复用的相关函数有select,poll和epoll,接下俩我们介绍下这三个函数。
2、select函数
select是实现I/O多路复用的经典系统调用函数。select()可以同时等待多个套接字的变为可读,只要有任意一个套接字可读,那么就会立刻返回,处理已经准备好的套接字了。
2.1、select函数定义
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
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);
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
select函数参数:
-
int nfds:指定待测试的描述符的个数,它的值是待测试的最大描述符加1;
-
fd_set readfds:指定要让内核测试读的描述符;
-
fd_set writefds:指定要让内核测试写的描述符;
-
fd_set exceptfds:指定要让内核测试异常的描述符;
-
timeval timeout:告知内核等待所制定描述符中的任何一个准备就绪的超时时间。
其中有一个重要的结构体:fd_set,用于存储描述符集,底层使用bitmap记录描述符的。
与之相关的4个宏:
-
FD_CLR:清除fdset中的所有bit位;
-
FD_SET:开启fdset中fd描述符对应的bit位;
-
FD_ZERO:关闭fdset中fd描述符对应的bit位;
-
FD_ISSET:判断fd描述符对应的bit位是否开启;
2.2、select函数例子
下面通过一个例子演示select是如何使用的,并且分析其执行原理。
这个例子开启了一个监听套接字,然后获取5个客户端连接,通过select函数判断是否有数据到达服务器端,如果有则读取,我把详细的注释都加上了,下面重点介绍标注了序号的代码:
1、socket
调用socket
创建一个监听套接字,并拿到监听套接字描述符;
2、bind
调用bind
把本地协议地址赋予套接字;
3、listen
调用listen
转换为被动套接字,开始接受指向该套接字的连接请求;
4、得到maxfd
在获取5个已连接套接字的过程中,判断获取到最大的套接字文件描述符;
5、初始化fd_set
在循环里面,每次重新调用select
之前,都需要重新设置rset
,在第7步我们解释为什么要这样做;
fd_set
是一个bitmap,由内核固定设置的大小,最大长度为1024,这也限制了我们最多只能同时监听1024个描述符。假如我们这里得到的五个描述符是:1 2 5 6 8
,那么这个位图会是这样的:
6、`select`函数传入的待测试描述符+1
这里为什么要加1呢?
根据第五步,可以知道,fd_set
中的bitmap是从0开始的,所以rset实际有效的bitmap长度是待测试描述符+1。
7、往`select`中传入要让内核测试读的描述符,然后阻塞等待内核返回
这一步的流程是这样的:
-
应用进程调用了select之后,会把fd_set从用户空间拷贝到内核空间,随后应用进程进入阻塞;
-
内核根据fd_set得到需要处理的描述符,根据描述符是否准备好数据,给fd_set进行置位;
-
进程被唤醒,拿到内核处理过后的fd_set,就可以通过FD_ISSET判断到套接字数据是否已经准备好了。
8、判断描述符是否可读
这里会把已准备好的数据的套接字描述符对应的fd_set中的标识进行标记,通过FD_ISSET
即可判断到标记结果。
2.3、select函数优缺点
2.3.1、优点
非阻塞IO直接轮训查询数据是否准备好,每次查询都要切换内核态,轮训消耗CPU。而select函数则直接把查询多个描述符的动作交给了内核,这样避免了CPU消耗和减少了内核态的切换。
2.3.2、缺点
根据上面的过程描述,我们可以知道select有如下缺点:
-
fd_set中的bitmap是固定1024位的,也就是说最多只能监听1024个套接字。当然也可以改内核源码,不过代价比较大;
-
fd_set每次传入内核之后,都会被改写,导致不可重用,每次调用select都需要重新初始化fd_set;
-
每次调用select都需要拷贝新的fd_set到内核空间,这里会做一个用户态到内核态的切换;
-
拿到fd_set的结果后,应用进程需要遍历整个fd_set,才知道哪些文件描述符有数据可以处理。
3、poll
基于select的缺点,于是出现了第二个系统调用,poll,poll与内核交互的数据有所不同,并且突破了文件描述符数量的限制。
3.1、poll函数定义
下面是poll函数的定义:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为1
poll函数参数:
-
pollfd * fds:指向一个结构数组第一个元素的指针,每个元素,都是一个pollfd结构,用于指定测试某个给定描述符fd的条件,结构体格式如下:
-
struct pollfd {
int fd; /* 待检测的文件描述符 */
short events; /* 描述符上待检测的事件类型 */
short revents; /* 返回描述符对应事件的状态 */
}; -
int fd:为待检测的文件描述符;
-
short events:为描述符上待检测的事件类型,这里用了short类型,具体的实现用二进制掩码位操作来完成,常用的事件类型如下:
-
short revents:返回描述符对应事件的状态,在pollfd由系统调用返回之后,会响应具体的事件状态;
-
nfds_t nfds:nfds指定fds数组的大小;
-
int timeout:指定poll函数返回前需要等待多长时间。
接下来我们还是看具体的例子。
3.2、poll函数例子
下面通过一个例子演示poll是如何使用的,并且分析其执行原理。
与select的例子很类似,开启了一个监听套接字,然后获取5个客户端连接,通过poll函数判断是否有数据到达服务器端,如果有则读取,我把详细的注释都加上了,下面重点介绍标注了序号的代码:
1、设置pollfd描述符
这里通过accept阻塞获取已连接描述符,赋值给pollfd结构的fd中。
2、设置pollfd事件
然后给pollfd的events设置POLLIN,指定需要检测POLLIN,即数据读入。
3、poll
调用poll函数,传入刚刚初始化好的pollfds,数量为5,超时时间为10秒。这里进入阻塞等待,直到从内核返回。
与select类似,这一步的执行流程是这样的:
-
应用进程调用了poll之后,会把poll_fd从用户空间拷贝到内核空间,随后应用进程进入阻塞;
-
内核根据poll_fd的fd得到需要处理的描述符,根据描述符是否准备好数据,给poll_fd的revents进行置位;
-
进程被唤醒,拿到内核处理过后的poll_fd,就可以通过与操作判断到对应的事件是否被置位,从而知道套接字数据是否已经准备好了。
4、判断事件是否准备好
从内核返回之后,我们循环判断pollfds中每个元素的revents,通过与操作,看看POLLIN是否被置位了,如果置位了就说明数据已经准备好了。
5、重置事件
这里对revents进行了重置,下次就可以复用这个pollfds,继续执行poll函数了。
3.3、poll函数优缺点
3.3.1、优点
-
与select类似,非阻塞IO直接轮训查询数据是否准备好,每次查询都要切换内核态,轮训消耗CPU,而poll则是把查询多个描述符的动作交给了内核,避免了CPU消耗和减少了内核态的切换。
-
与select相比,这里不是用的bitmap,而是直接用poll_fd数组,没有1024个描述符的限制;
-
这里引入了poll_fd结构体,内核只是修改poll_fd结构体中的revents,这样每次读取数据的时候,重置revents,就可以复用poll_fd了,不用像select那样反复初始化一个新的rset。
3.3.2、缺点
-
每次调用poll都需要拷贝新的poll_fd到内核空间,这里会做一个用户态到内核态的切换;
-
拿到poll_fd的结果后,应用进程需要遍历整个poll_fd,才知道哪些文件描述符有数据可以处理。
4、epoll
与poll不同,epoll本身不是系统调用,而是一种内核数据结构
,它允许进程在多个文件描述符上多路复用I / O。
可以通过三个系统调用来创建,修改和删除此数据结构。
4.1、epoll的相关函数
4.1.1、epoll_create
epoll实例是通过epoll_create系统调用创建的,该系统调用将文件描述符返回到epoll实例,函数定义如下:
#include <sys/epoll.h>
int epoll_create(int size);
size参数向内核指示进程要监视的文件描述符的数量,这有助于内核确定epoll实例的大小。从Linux 2.6.8开始,此参数将被忽略,因为epoll数据结构会随着文件描述符的添加或删除而动态调整大小。
epoll_create系统调用将返回新创建的epoll内核数据结构的文件描述符。然后,调用过程中可以使用此文件描述符来添加,删除或修改其要监视的epoll实例的I/O的其他文件描述符。
如下图,用户进程最终拿到了epoll实例的描述符 EPFD,以支持对epoll实例的访问:
还有另一个系统调用epoll_create1,其定义如下:
int epoll_create1(int flags);
flags参数可以为0或EPOLL_CLOEXEC。
-
设置为0时,epoll_create1的行为与epoll_create相同;
-
设置EPOLL_CLOEXEC标志后,当前进程派生的任何子进程将在执行前关闭epoll描述符,因此该子进程将无法再访问epoll实例;
4.1.2、epoll_ctl
进程可以通过调用epoll_ctl将想要监视的文件描述符添加到epoll实例。
向epoll实例注册的所有文件描述符统称为epoll的兴趣列表[1],会包装成epitem结构体,放到一颗红黑树rbr中:
在上图中,用户进程向epoll实例注册了文件描述符fd1,fd2,fd3,fd4,这是该epoll实例的兴趣列表集。
当任何已注册的文件描述符准备好进行I/O时,它们就被放入事件就绪队列。事件就绪队列是兴趣列表的一个子集。内核在接收到I/O准备好的事件回调的时候,把rbr中的epitem移到事件就绪队列。
epoll_ctl系统调用定义如下:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
int epfd:epoll_create返回的文件描述符,用于标识内核中的epoll实例;
-
int op:指要在文件描述符fd上执行的操作。通常,支持三种操作:
-
EPOLL_CTL_ADD:向epoll实例注册文件描述符对应的事件;
-
EPOLL_CTL_DEL:从epoll实例注销fd。这意味着该进程将不再获取有关该文件描述符上事件的任何通知。如果已将文件描述符添加到多个epoll实例,则关闭该文件描述符会将其从添加了该文件的所有epoll兴趣列表中删除
-
EPOLL_CTL_MOD:修改文件描述符对应的事件。
-
int fd:我们要添加到epoll兴趣列表的文件描述符;
-
struct epoll_event *event:指向名为epoll_event的结构的指针,该结构存储我们实际上要监视fd的事件。
-
typedef union epoll_data {
void *ptr;
int fd; /* 需要监视的文件描述符 */
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 需要监视的Epoll事件,与poll一样,基于mask的事件类型 */
epoll_data_t data; /* User data variable */
};
4.1.3、epoll_wait
可以通过调用epoll_wait系统调用来等到内核通知进程epoll实例的兴趣列表上发生的事件,该事件将阻塞直到被监视的任何描述符准备好进行I/O操作为止。
函数定义如下:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
-
int epfd:epoll实例描述符;
-
struct epoll_event *events:返回给用户空间需要处理的IO事件数组;
-
int maxevents:指定epoll_wait可以返回的最大事件值;
-
int timeout:指定阻塞调用超时时间。-1表示不超时,0表示立即返回。
4.2、epoll例子
注意:epoll机制是在Linux 2.6之后引入的,所以Mac OS不支持。Mac OS下使用kqueue机制代替epoll实现IO复用。
1、定义epoll_event
定义一个epoll_event,存储实际要监视的fd相关信息,以及待接收epll_wait返回的就绪事件列表。
2、调用epoll_create
在内核中创建epoll实例,并发挥epfd文件描述符。
3、设置event.data.fd
event结构设置实际要监视的描述符。
4、设置event.events
event和值实际要监视的事件。
5、调用epoll_ctl
将要监视的event添加到epoll实例。
6、调用epoll_wait
获取内核epoll实例中兴趣列表上发生的事件,即事件就绪队列的内容,epoll_wait的返回值为就绪队列的大小。
4.3、epoll原理解析[2]
还是看看刚才那个图:
这里我们重点来看看epoll实例的以下相关结构体:
eventpoll epoll实例
rdllist:事件就绪队列
rbr:用于快速查找fd的红黑树
epitem:一个fd会对应创建一个epitem
-
eventpoll实例
是存在内核空间的,每次用户进程要请求epoll_wait
调用的时候,都需要通过传递epfd描述
符让内核找到用户要访问的eventpoll实例; -
每次调用
epoll_ctl
为描述符订阅事件的时候,其实是把描述符和事件相关内容包装成epitem结构
,然后往红黑树rbr
添加树节点,用户进程所有关心的描述符都存在这颗红黑树中; -
内核会通过
ep_ptable_queue_proc
函数给每个文件描述符设置回调ep_poll_callback
,对应的文件描述符如果有事件发生,那么就会调用回调函数,从而触发内核进行查找红黑树,把需要的套接字epitem移动到事件就绪队列; -
最后在执行epoll_wait准备把事件就绪队列的内容从内核空间拷贝到用户空间的时候,还会再次调用每个文件描述符的poll方法,以便确定确实有事件发生,从而确保事件还是有效的。
更详细的实现细节,可以进一步阅读epoll的源码[2]。
4.4、边缘触发与条件触发
先讲下边缘触发和条件触发的区别。
4.4.1、边缘触发
当一个添加到epoll实例的epoll_event设置为EPOLLET边缘触发(edge-triggered)
之后,如果后续有描述符的事件准备好了,调用epoll_wait就会把对应的epoll_event返回给应用进程,注意,在边缘触发模式下,只会返回已准备好的描述符的epoll_evnet一次,也就是说程序只有一次的处理机会。
4.4.2、条件触发
当把要添加到epoll实例的epoll_event设置为EPOLLLT条件触发(level-triggered)
时,只要已准备好的描述符没有被处理完,下一次调用epoll_wait的时候,还是会继续返回给应用进程处理。这是系统默认处理方式。
EPOLLET边缘触发的效率要比EPOLLLT高效,因为对于每个准备就绪的套接字,只会通知应用进程一次,但是这也要求程序员必须小心处理,不会留多次机会给你去补偿处理套接字。
4.4.3、实现原理
针对条件触发,返回给内核空间的描述符会再次加入到就绪队列中,那么下次调用epoll_wait的时候,这些epoll_item将会被重新处理:调用文件描述符的poll方法,确定事件是否还有效,如果还有效,那就继续返回,从而实现了条件触发。
而边缘触发的情况下,返回给内核空间的描述符则不会再次放会就绪队列,所以只会返回一次。
4.5、epoll优缺点
4.5.1、优点
-
epoll每次调用epoll_wait的时候,不像poll调用一样,每次都要传递结构体到内核空间,而是复用一个内核的epoll实例结构体,通过epfd进行引用,从而减小了系统开销;
-
epoll底层是套接字一旦有事件,就调用回调立刻通知epoll实例,可以尽早的准备好事件就绪队列,执行epoll_wait的时候相应的更快;
-
epoll底层基于红黑树维护兴趣事件列表,这样每次套接字有新事件触发回调的时候,可以更快的找到套接字的epitem进行后续的处理;
-
提供了性能更佳的边缘触发机制。
正是因为epoll这么多的优点,很多技术都是基于epoll实现的,如nginx、redis,以及Linux下Java的NIO。
4.5.2、缺点
它还不是真正的异步IO,还是要应用进程调用IO函数的时候,才把数据从内核拷贝到应用进程。
关于异步IO,我们下一节会继续探讨。
今天关于IO复用的知识就介绍到这里了,IO复用会在构建高性能网络服务器中大放异彩,为高性能网络程序打下坚实的基础。我们将在后续章节详细介绍高性能网络服务器的实现。
为了把相关系列文章收集起来,方便后续查阅,这里我创建了一个Github仓库,把发布的文章按照分类收集起来了,感兴趣的朋友可以Star跟进:
https://github.com/arthinking/java-tech-stack
References
[1]: The method to epoll’s madness
[2]: linux/fs/eventpoll.c
-
UNIX网络编程 卷1:套接字联网API(第三版). 人民邮电出版社.
-
盛延敏. 网络编程实战. 极客时间