美文网首页老男孩的成长之路Java架构技术进阶嵌入式
彻底弄懂IO复用:IO处理杀手锏,带您深入了解select,po

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

作者: Java入门到入坟 | 来源:发表于2020-12-20 21:24 被阅读0次

    推荐阅读:

    本节,我们介绍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

    基于epoll的缺点,于是出现了第二个系统调用,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处理杀手锏,带您深入了解select,po

        本文链接:https://www.haomeiwen.com/subject/fbtlnktx.html