I/O多路复用之epoll

一、select、poll的些许缺点

先回忆下select和poll的接口html

int select(int nfdsfd_set *readfdsfd_set *writefdsfd_set *exceptfds, struct timeval *timeout);linux

int poll(struct pollfd *fds, nfds_t nfds, int timeout);面试

这两个多路复用实现的特色是:编程

  • 每次调用select和poll都要把用户关心的事件集合(select为readfds,writefds,exceptfds集合,poll为fds结构体数组)从用户空间到内核空间。
  • 若是某一时间段内,只有少部分事件是活跃的(用户关心的事件集合只有少部分事件会发生),会浪费cpu在对无效事件轮询上,使得效率较低,好比,用户关心1024个tcp socket的读事件,当是,每次调用select或poll时只有1个tcp连接是活跃的,那么对其余1023个事件的轮询是没有必要的。

select支持的文件描述符数量较小,通常只有1024,poll虽然没有这个限制,但基于上面两个缘由,poll和select存在一样一个缺点,就是包含大量文件描述符的数组被总体复制于用户态和内核的地址空间之间,并且不论这些文件描述符是否就绪,每次都会轮询全部描述符的状态,使得他们的开销随着文件描述符数量的增长而线性增大。epoll针对这几个缺点进行了改进,再也不像select和poll那样,每次调用select和poll都把描述符集合拷贝到内核空间,而是一次注册永久使用;另外一方面,epoll也不会对每一个描述符都轮询时间是否发生,而是只针对事件已经发生的文件描述符进行资源抢占(由于同一个描述符资源(如可读或可写)可能阻塞了多个进程,调用epoll的进程须要与这些进程抢占该相应资源)。下面记录一下本身对epoll的学习和理解。数组

二、epoll的几个接口

上面说到每次调用select和poll都把描述符集合拷贝到内核空间,这是由于select和poll注册事件和监听事件是绑定在一块儿的,为甚这么说呢,咱们看select和poll的编程模式就明白了:服务器

while(true){   select(maxfd+1,readfds,writefds,execpfds,timeout)/poll(pollfd,nfds,timeout); }

I/O多路复用之select中说到了select的实现,调用select时就会进行一次用户空间到内核空间的拷贝。epoll的改进其实就是把注册事件和监听事件分开了,epoll使用了一个特殊的文件来管理用户关心的事件集合,这个文件存在于内核之中,由特殊的数据结构和一组操做构成,这样的话,用户就能够提早告知内核本身关心的事件,而后再进行监听,所以,就只须要一次用户空间到内核空间的拷贝了。其中管理事件集合的文件经过epoll_create建立,注册用户行为经过epoll_ctl实现,监听经过epoll_wait实现。那么编程模型大概是这个样子:数据结构

epoll_fd=epoll_create(size); epoll_ctl(epoll_fd,operation,fd,event); while(true){   epoll_wait(epoll_fd,events,max_events,timeout); }

2.一、epoll_create接口

#include <sys/epoll.h>app

int epoll_create(int size);socket

epoll_create建立epoll文件,其返回epoll的句柄,size用来告诉内核监听文件描述符的最大数目,这个参数不一样于select()中的第一个参数(给出最大监听的fd+1的值)。须要注意的是,当建立好epoll句柄后,它会占用一个fd值,在linux下若是查看/proc/进程id/fd/,可以看到这个fd,因此在使用完epoll后,必须调用close()关闭,不然可能致使fd被耗尽。(摘自epoll精髓tcp

epoll_create会在内核初始化完成epoll所需的数据结构,其中一个关键的结构就是rdlist,表示就绪的文件描述符链表,epoll_wait函数就是直接检查该链表,从而抢占准备好的事件;另外一个关键的结构是一颗红黑树,这棵树专门用于管理用户关心的文件描述符集合。

注:关于epoll文件的核心数据结构以及epoll_create的源码请参考这两份资料

linux 内核poll/select/epoll实现剖析

epoll源码实现分析[整理]

2.二、epoll_ctl接口

#include <sys/epoll.h>

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

epoll_ctl用于用户告知内核本身关心哪一个描述符(fd)的什么事件(event),

  • epfd,使用epoll_create函数建立的epoll句柄,epfd文件描述符对应的结构中,有一颗红黑树,专门用于管理用户关心的事件集合。
  • op,用于指定用户行为,op参数有三种取值:fd,用户关心的文件描述符
    • EPOLL_CTL_ADD,注册新的fd到epfd中;
    • EPOLL_CTL_MOD,修改已注册fd的事件;
    • EPOLL_CTL_DEL,从epfd中删除一个fd;
  • event,用户关心的事件(读,写)

参数event的结构以下:

struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable,内核会修改该属性 */ };

events能够是如下几个宏的集合:

  • EPOLLIN ,表示对应的文件描述符能够读(包括对端SOCKET正常关闭);
  • EPOLLOUT,表示对应的文件描述符能够写;
  • EPOLLPRI,表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR,表示对应的文件描述符发生错误;
  • EPOLLHUP,表示对应的文件描述符被挂起;
  • EPOLLET,将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来讲的。
  • EPOLLONESHOT,只监听一次事件,当监听完此次事件以后,若是还须要继续监听这个socket的话,须要再次把这个socket加入到EPOLL队列里

 2.2.一、EPOLL_CTL_ADD

 重点说一下这个取值,当op=EPOLL_CTL_ADD时,epoll_ctl主要作了四件事:

  • 把当前文件描述符及其对应的事件(fd,epoll_event)加入红黑树,便于内核管理
  • 注册设备驱动poll的回调函数ep_ptable_queue_proc,当调用f_op->poll()时,最终会调用该回调函数ep_ptable_queue_proc()
  • ep_ptable_queue_proc回调函数中,注册回调函数ep_poll_callback,ep_poll_callback表示当描述符fd上相应的事件发生时该如何告知进程。
  • ep_ptable_queue_proc回调函数中,检测是文件描述符fd对应的设备的epoll_event事件是否发生,若是发生则把fd及其epoll_event加入上面提到的就绪队列rdlist中

注:关于epoll_ctl、ep_ptable_queue_proc、ep_poll_callback的原理及源码请参考这两份资料

linux 内核poll/select/epoll实现剖析

epoll源码实现分析[整理]

2.三、epoll_wait接口

#include <sys/epoll.h>

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

  • epfd,使用epoll_create函数建立的epoll句柄,epfd文件描述符对应的结构中,有一颗红黑树,专门用于管理用户关心的事件集合。
  • events,传出参数,表示发生的事件
  • maxevents,传入参数,表示events数组的最大容量,其值不能超过epoll_create函数的参数size
  • timeout,0,不阻塞;整数,阻塞timeout时间;负数,无限阻塞

epoll_wait函数的原理就是去检查上面提到的rdlist链表中每一个结点,rdlist的每个结点可以索引到监听的文件描述符,就能够调用该文件描述符对应设备的poll驱动函数f_op->poll,用以检查该设备是否可用。这里有个问题须要思考一下,既然rdlist就表示就绪的事件,也就是设备对应的资源可用了,为何还要进行检查?这是由于设备的某个资源可能被多个进程等待,当设备资源准备好后,设备会唤醒阻塞在这个资源上的全部进程,当前调用epoll_wait的进程未必能抢占这个资源,因此须要再调用检查一次资源是否可用,以防止被其余进程抢占而致使再次不可用,检查的方法就是调用fd设备的驱动f_op->poll。

这也是为何epoll效率可能比较高的缘由,epoll每次只检查已经就绪的设备,不像select、poll,无论有没有就绪,都去检查。

注:关于epoll_wait的原理及源码请参考这两份资料

linux 内核poll/select/epoll实现剖析

epoll源码实现分析[整理]

三、epoll的两种触发模式ET&LT

两者的差别在于level-trigger模式下只要某个socket处于readable/writable状态,不管何时进行epoll_wait都会返回该socket;而edge-trigger模式下只有某个socket从unreadable变为readable或从unwritable变为writable时,epoll_wait才会返回该socket,et模式注重的是状态发生改变的时候才触发。下面两幅图清晰反映了两者区别,这两幅图摘自Epoll在LT和ET模式下的读写方式 

       

在ET模式下,在使用epoll_ctl注册文件描述符的事件时,应该把描述符设置为非阻塞,为何呢?以上面左边这幅图为例,当数据到来以后,该socket实例从不可读状态边为可读状态,从该socket读取一部分数据后,再次调用epoll_wait,因为socket的状态没有发生改变(buffer上一次空到有数据可读触发了et,而这一次buffer还有数据可读,状态没改变),因此该次调用epoll_wait并不会返回这个socket的可读事件,并且以后也不会再发生改变,这个socket实例将永远也得不处处理。这就是为何将监听的描述符设置为非阻塞的缘由。

使用ET模式时,正确的读写方式应该是这样的:

设置监听的文件描述符为非阻塞
while(true){
  epoll_wait(epoll_fd,events,max_evens);
  读,只要可读,就一直读,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK
}

正确的写方式应该是这样的:

设置监听的文件描述符为非阻塞
while(true){
  epoll_wait(epoll_fd,events,max_evens);
  写,只要可写,就一直写,直到返回0,或者-1,errno=EAGAIN/EWOULDBLOCK
}

四、两个问题

使用单进程单线程IO多路复用,服务器端该如何正确使用accept函数?

应该将监听的socket实例设置为非阻塞。

使用io多路复用时,通常会把监听链接的socket实例listen_fd交给select、poll或epoll管理,若是使用阻塞模式,假设,select、poll或epoll调用返回时,有大量描述符的读或写事件准备好了,并且listen_fd也可读,

咱们知道,从select、poll或epoll返回到调用accept接收新链接是有一个时间差的,若是这个时间内,发起请求的一端主动发送RST复位请求,服务器会把该链接从ACCEPT队列(socket原理详解,3.6节)中取出,并把该链接复位,这个时候再调用accept接收链接时,服务器将被阻塞,那其余的可读可写的描述符将得不处处理,直到有新链接时,accept才得以返回,才能去处理其余早已准备好的描述符。因此应该将listen_fd设置为非阻塞。

 

腾讯后台开发面试题。使用Linux epoll模型,LT触发模式,当socket可写时,会不停的触发socket可写的事件,但并不老是须要写,该如何处理?

第一种最广泛的方式,步骤以下:

  1. 须要向socket写数据的时候才把socket加入epoll,等待可写事件。
  2. 接受到可写事件后,调用write或者send发送数据,直到数据写完。
  3. 把socket移出epoll。

这种方式的缺点是,即便发送不多的数据,也要把socket加入epoll,写完后在移出epoll,有必定操做代价。

一种改进的方式,步骤以下:

  1. 设置socket为非阻塞模式
  2. 调用write或者send发送数据,直到数据写完
  3. 若是返回EAGAIN,把socket加入epoll,在epoll的驱动下写数据,所有数据发送完毕后,再移出epoll。

这种方式的优势是:数据很少的时候能够避免epoll的事件处理,提升效率。

 

参考资料:

linux 内核poll/select/epoll实现剖析

epoll源码实现分析[整理]

Epoll在LT和ET模式下的读写方式 

Epoll在LT和ET模式下的读写方式(搞不懂这两个谁是原创,不少一样的博文,都标志着原创的字样)

相关文章
相关标签/搜索