1、select、poll的些许缺点
先回忆下select和poll的接口
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
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的学习和理解。
2、epoll的几个接口
上面说到每次调用select和poll都把描述符集合拷贝到内核空间,这是因为select和poll注册事件和监听事件是绑定在一起的,为甚这么说呢,我们看select和poll的编程模式就明白了:
while(true){
select(maxfd+,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.1、epoll_create接口
#include <sys/epoll.h>
int epoll_create(int size);
epoll_create创建epoll文件,其返回epoll的句柄,size用来告诉内核监听文件描述符的最大数目,这个参数不同于select()中的第一个参数(给出最大监听的fd+1的值)。需要注意的是,当创建好epoll句柄后,它会占用一个fd值,在linux下如果查看/proc/进程id/fd/,能够看到这个fd,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。(摘自epoll精髓)
epoll_create会在内核初始化完成epoll所需的数据结构,其中一个关键的结构就是rdlist,表示就绪的文件描述符链表,epoll_wait函数就是直接检查该链表,从而抢占准备好的事件;另一个关键的结构是一颗红黑树,这棵树专门用于管理用户关心的文件描述符集合。
注:关于epoll文件的核心数据结构以及epoll_create的源码请参考这两份资料
2.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.1、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的原理及源码请参考这两份资料
2.3、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的原理及源码请参考这两份资料
3、epoll的两种触发模式ET<
二者的差异在于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,或者-,errno=EAGAIN/EWOULDBLOCK
}
正确的写方式应该是这样的:
设置监听的文件描述符为非阻塞
while(true){
epoll_wait(epoll_fd,events,max_evens);
写,只要可写,就一直写,直到返回0,或者-,errno=EAGAIN/EWOULDBLOCK
}
4、两个问题
使用单进程单线程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可写的事件,但并不总是需要写,该如何处理?
第一种最普遍的方式,步骤如下:
- 需要向socket写数据的时候才把socket加入epoll,等待可写事件。
- 接受到可写事件后,调用write或者send发送数据,直到数据写完。
- 把socket移出epoll。
这种方式的缺点是,即使发送很少的数据,也要把socket加入epoll,写完后在移出epoll,有一定操作代价。
一种改进的方式,步骤如下:
- 设置socket为非阻塞模式
- 调用write或者send发送数据,直到数据写完
- 如果返回EAGAIN,把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。
这种方式的优点是:数据不多的时候可以避免epoll的事件处理,提高效率。
参考资料:
Epoll在LT和ET模式下的读写方式(搞不懂这两个谁是原创,很多同样的博文,都标志着原创的字样)