epoll是Linux内核为处理大批量文件描述符而设计的IO多路复用机制,它能显著提高程序在存在大量并发连接而只有少部分活跃连接情况下的系统CPU利用率。epoll之所以可以做到如此高的效率是因为它在获取就绪事件的时候,并不会遍历所有被监听的文件描述符集,而只会遍历那些被设备IO事件异步唤醒而加入就绪链表的文件描述符集。
epoll机制提供了三个系统调用给用户态应用程序,用以管理感兴趣的事件,三个系统调用分别为epoll_create()、epoll_ctl()和epoll_wait()。另外还暴露了一个struct和一个union,分别为struct epoll_event和union epoll_data。而在epoll自身内部,还涉及了多个重要的数据结构,分别为struct eventpoll、struct epitem、struct eppoll_entry、struct ep_pqueue、struct poll_table_struct等。核心数据结构之间的关系如下图所示:
1 | int epoll_create(int size) |
epoll_create()系统调用会创建一个类型为struct eventpoll的对象,并返回一个与之对应文件描述符,之后应用程序在用户态使用epoll的时候都将依靠这个文件描述符,而在epoll内部则是通过该文件描述符进一步获取到struct eventpoll类型的对象,再进行对应的操作,这在其实现中可以很清晰看出。这个接口在内核中对应的实现为epoll_create1(),该函数位于eventpoll.c文件中,其实现如下:
1 | SYSCALL_DEFINE1(epoll_create1, int, flags) |
从实现中可以看到epoll_create()系统调用完成了fd、file、eventpoll三个对象之间的关联,并将fd返回给用户态应用程序,从而减小了用户态的使用难度。每一个epoll fd都会对应一个struct eventpoll类型的对象,该对象用来管理用户态应用程序添加的感兴趣事件以及就绪了的事件,对于struct eventpoll类型,其定义为:
1 | struct eventpoll { |
struct eventpoll类型的成员很多,到目前为止,我们只需要关注两个成员,一个是类型为struct rb_root的rbr,一个是类型为struct list_head的rdllist。其中rbr成员是一棵红黑树的根节点,这棵树中存放着所有通过epoll_ctl()系统调用添加到epoll中的事件对应的类型为struct epitem的对象;而rdllist链表则存放了将要通过epoll_wait()系统调用返回给用户态应用程序的就绪事件对应的struct epitem对象。
2、epoll_ctl()
epoll_ctl()系统调用,其原型如下:
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
epoll_ctl()系统调用提供给用户态应用程序向epoll中添加、删除和修改感兴趣的事件,其中epfd就是通过epoll_create()系统调用获取的epoll对象文件描述符,op用于指明epoll如何操作event事件,其取值包括EPOLL_CTL_ADD、EPOLL_CTL_MOD和EPOLL_CTL_DEL,分别用于指明添加新的事件到epoll中、修改epoll中的事件以及删除epoll中的事件;fd就是被监控事件对应的文件描述符,而event则是被监控的事件对象,其类型为struct epoll_event,定义如下:
1 | struct epoll_event { |
其中events成员指明了用户态应用程序感兴趣的事件类型,比如EPOLLIN和EPOLLOUT等。而data成员则是提供给用户态应用程序使用,一般用于存储事件的上下文,比如在nginx中,该成员用于存放指向ngx_connection_t类型对象的指针。
epoll_ctl()系统调用在内核对应的实现如下:
1 | SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd, |
从epoll_ctl()的实现我们可以看到,在该函数中首先是通过epfd获取到对应的类型为struct eventpoll的epoll对象,接着判断epoll_ctl()参数fd对应的事件已经在epoll的监控中了,即用用户态传递进来的事件fd及其对应的file对象调用ep_find()到epoll对象的rbr成员中去寻找是否有对应的类型为struct epitem的对象,有则返回,否则返回NULL。然后再根据参数fd指定的操作类型对事件做进一步处理或进行异常处理。我们以事件之前未加入到epoll中,及操作类型EPOLL_CTL_ADD情况做进一步解析,在这种情况下会调用ep_insert()做进一步的处理,ep_insert()函数的实现如下:
1 | static int ep_insert(struct eventpoll *ep, struct epoll_event *event, |
从ep_insert()函数的实现我们可以看到,ep_insert()函数主要做了如下三件事:
1)、创建并初始化一个strut epitem类型的对象,完成该对象和被监控事件(包括fd、struct epoll_event类型的对象)以及epoll对象eventpoll的关联。
2)、将struct epitem类型的对象加入到epoll对象eventpoll的红黑树中管理起来。
3)、将struct epitem类型的对象加入到被监控事件对应的目标文件的等待列表中,并注册事件就绪时会调用的回调函数,在epoll中该回调函数就是ep_poll_callback()。
第一和第二点比较清晰,下面将着重分析第三点的流程。
在ep_insert()函数中,epoll会定义一个类型为struct ep_pqueue的对象,该对象包括了epitem成员,以及一个类型为poll_table的对象成员pt。在ep_insert()函数中我们会将 pt的_qproc这个回调函数成员设置为ep_ptable_queue_proc(),并在ep_item_poll()函数中将pt的_key成员设置为用户态应用程序感兴趣的事件类型,然后调用被监控的目标文件的poll回调函数。被监控的目标文件的poll回调函数一般会调用poll_wait()函数,而poll_wait()又会调用pt的_qproc()回调函数,而在ep_insert()函数中设置的pt的_qproc()回调函数ep_ptable_queue_proc()会将epitem对象对应的eppoll_entry对象加入到被监控的目标文件的等待队列中,并设置感兴趣事件发生后的回调函数为ep_poll_callback()。目标文件的poll回调函数调用完poll_wait()之后会获取对应的就绪事件掩码。如果pt的回调函数成员_qproc没有设置,那么目标文件的poll回调函数一般就只会返回对应的就绪事件掩码。所以如果目标文件对应的事件就绪的话,ep_item_poll()函数就会返回。ep_item_poll()在epoll_ctl()和ep_wait()的处理流程中都会调用,两个流程中的区别在于epoll_ctl()处理流程中调用ep_item_poll()函数的是时候会设置poll_table的_qproc成员;而在epoll_wait()处理流程中则不会设置该成员,而只会获取就绪事件的掩码。
以socket为例,因为socket有多种类型,如tcp、udp等,所以socket层会实现一个通用的poll回调函数,这个函数就是sock_poll()。在sock_poll()函数中通常会调用某一具体类型协议对应的poll回调函数,以tcp为例,那么这个poll()回调函数就是tcp_poll()。当socket有事件就绪时,比如读事件就绪,就会调用sock->sk_data_ready这个回调函数,即sock_def_readable(),在这个回调函数中则会遍历socket 文件中的等待队列,然后依次调用队列节点的回调函数,在epoll中对应的回调函数就是ep_poll_callback(),这个函数会将就绪事件对应的epitem对象加入到epoll对象eventpoll的就绪链表rdllist中,这样用户态程序调用epoll_wait()的时候就能获取到该事件,如果调用ep_poll_callback()函数的时候发现epoll对象eventpoll的ovflist成员不等于EP_UNACTIVE_PTR的话,说明此时正在扫描rdllist链表,这个时候会将就绪事件对应的epitem对象加入到ovflist链表暂存起来,等rdllist链表扫描完之后在将ovflist链表中的内容移动到rdllist链表中,此部分实现可以参考ep_scan_ready_list()函数。
3、epoll_wait()
epoll_wait()系统调用,其原型如下:
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
epoll_wait()系统调用主要是用于收集在epoll中监控的就绪事件。epoll_wait()函数返回值表示的是获取到的就绪事件个数,epfd表示的epoll对象fd,第二个参数则是已经分配好内存的epoll_event结构体数组,用于给内核存放就绪事件的;第三个参数表示本次最多可以返回的就绪事件个数,这个通常和events数组的大小一样;第四个参数表示在没有检测到事件发生时epoll_wait()的阻塞时长。epoll_wait()系统调用在内核对应的实现如下:
1 | SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events, |
从epoll_wait()对应的内核实现我们可以看到,epoll_wait()首先是根据epfd获取到epoll对象eventpoll然后再调用ep_poll()获取就绪事件,也就是说ep_poll()函数是真正完成就绪事件获取工作的,其实现如下:
1 | static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, |
从ep_poll()函数的实现可以看到,如果有就绪事件发生,则调用ep_send_events()函数做进一步处理,在ep_send_events()函数中又会调用ep_scan_ready_list()函数获取epoll对象eventpoll中的rdllist链表。由于在我们扫描处理eventpoll中的rdllist链表的时候可能同时会有就绪事件发生,这个时候为了保证数据的一致性,在这个时间段内发生的就绪事件会临时存放在eventpoll对象的ovflist链表成员中,待rdllist处理完毕之后,再将ovflist中的内容移动到rdllist链表中,等待下次epoll_wait()的调用。
当ep_scan_ready_list()函数获取到rdllist链表中的内容之后,会调用ep_send_events_proc()进行扫描处理,即遍历rdllist链表中的epitem对象,针对每一个epitem对象调用ep_item_poll()函数去获取就绪事件的掩码,如果掩码不为0,说明该epitem对象对应的事件发生了,那么就将其对应的struct epoll_event类型的对象拷贝到用户态指定的内存中;如果掩码为0,则直接处理下一个epitem。注意此时在调用ep_item_poll()函数的时候没有设置poll_table的_qproc回调函数成员,所以只会尝试去获取就绪事件的掩码,该函数的实现如下:
1 | static int ep_send_events_proc(struct eventpoll *ep, struct list_head *head, |
我们知道,epoll还有另外一个重要特性就是其支持边缘触发(ET)和水平触发(LT)两种工作模式;以网络套接字为例,对于边缘触发的工作模式,当一个新的事件到来的时候,应用程序可以通过epoll_wait()系统调用获取到这个就绪事件,但是如果用户态应用程序没有一次性处理完这个事件对应的套接字缓冲区的话,那么在这个套接字没有新事件到来之前,epoll_wait()都不会在返回这个事件了;而在水平触发的工作模式下,只要某个事件对应的套接字缓冲区中还有数据没有处理完,那么在调用epoll_wait()的时候总能获取到这个就绪事件,那么在epoll中是如何实现水平触发和边缘触发这两种工作模式的呢?epoll中的实现方式十分简洁,就是在ep_send_events_proc()函数扫描rdllist链表的时候,对于每一个有就绪事件发生的epitem对象,epoll都会判断该epitem对象中存放的用户态传递进来的事件掩码是否包含了EPOLLET位,如果没有包含这个EPOLLET位,那么epoll就会将epitem对象再次加入到rdllist链表中。这样用户态应用程序再次调用epoll_wait()的时候,就又可以在rdllist链表中获取到这个epitem对象了,如果在两次调用epoll_wait()的时间内,用户态应用程序没有处理完这个事件对应的套接字缓冲区中的内容,那么后面那次调用epoll_wait()的时候,就又可以通过调用ep_item_poll()函数获取到epitem对象对应的就绪事件了,这就是水平触发工作模式的原理。
参考:
1、 EPOLL Linux内核源代码实现原理分析