性能文章>彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,poll,epoll>

彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,poll,epoll原创

11月前
352355

本文为《高性能网络编程游记》的第三篇 “IO复用:IO处理杀手锏,带您深入了解select,poll,epoll”。

节我们介绍IO复用,通过简单的例子演示IO复用的使用,以及实现原理,这个技术是目前构建目前的高性能服务器必备技术,在后面我们介绍到各种网络编程模型的时候,会用到IO复用。

看完本文,您将了解到:

  • 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(第三版). 人民邮电出版社.

  • 盛延敏. 网络编程实战. 极客时间

点赞收藏
arthinking
请先登录,查看5条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
5