在中文网站上找epoll相关资料,都没有找到特别好的。有些讲的太浅,搞不懂其原理以及在内核层面是如何实现的。有些则太深,陷入源码之中,少了一些对整个流程的提炼。在英文网站上找到一篇不错的资料,图文并茂,讲的也很好,在此对其翻译记录一下。尽量翻译,可能有误,有问题时请以原文为准
正文
epoll代表event poll,是linux中的一个特殊数据结构。它可以使一个进程同时监控许多文件描述符,并且当io就绪时得到通知。epoll支持边缘触发(edge-trigged)和水平触发(level-trigged)模式,在讲解epoll前,先来看epoll的语法。
epoll的语法
与poll不同,epoll不是一个系统调用。它是一个内核数据结构,允许一个线程进行io多路复用。
epoll可以通过三个系统调用来被创建,修改和删除。
epoll_create
epoll的实例通过epoll_create
系统调用来创建,返回一个epoll结构的文件描述符,epoll_create
的函数签名如下
1 |
|
size 参数用于通知内核一个进程想要监听有多少文件描述符,从而使内核可以判断epoll结构的大小。从Linux 2.6.8开始,这个参数被忽略,因为采用了红黑树结构来组织文件描述符,因此可以动态的调整大小
epoll_create 系统调用返回新创建的epoll结构的文件描述符,调用它的进程可以使用此文件描述符来添加,删除或修改想要监听的io
还存在另一个 epoll_create1 系统调用
int epoll_create1(int flags);
flags字段可以是0 或者 EPOLL_CLOEXEC. 为0时,epoll_create1 和 epoll_create一样。为 EPOLL_CLOEXEC 时,fork当前进程的子进程在exec之前会关闭对应的epoll描述符,子进程不再能够访问该epoll的实例。
值得注意的是与epoll实例关联的文件描述符需要使用close()
系统调用来关闭。当使用fork并且没有使用 EPOLL_CLOEXEC 时,对应的子进程会拥有epoll描述符的拷贝,当所有进程都对描述符进行了释放,内核才会删除epoll实例。
epoll_ctl
一个进程可以通过epoll_ctl
添加希望监听的文件描述符到epoll实例。所有被注册到epoll的文件描述符称为epoll set 或者是 interest list。
上图中,进程483注册了fd1,fd2,fd3,fd4,fd5到epoll实例,这就是该实例对应的epoll set 或 interest list。随后,当任一描述符就绪时,就被认为在ready list中。ready list 是 interest list 的子集。
epoll_ctl
函数的签名如下
1 |
|
各参数的解释如下:
- epfd——epfd是epoll_create返回的epoll实例的描述符
- fd——fd是进程希望添加至 interest list 的描述符
op——op代表对要对给定fd进行的操作,支持三种操作
注册fd到epoll实例(EPOLL_CTL_ADD),在fd上发生事件时得到通知
从epoll实例删除fd(EPOLL_CTL_DEL),这意味着在fd上发生事件时进程不再得到通知。当一个文件描述符被关闭时,会从所有它已经注册的epoll实例上被删除
修改fd关心的事件(EPOLL_CTL_MOD)
event——一个指向epoll_event的指针,代表在fd上关心的事件
epoll_event 的第一个成员是一个掩码,指示了fd要监听的事件类型
例如,如果fd是一个socket,我们会希望监听有新的数据到达(EPOLLIN),我们也希望使用et模式,那么我们就可以使用(EPOLLIN 位或 EPOLLET)。所有可能的选项可以参考All possible flgs
epoll_event的第二个成员是一个union字段
epoll_wait
一个线程可以通过epoll_wait系统调用来在interest set中有事件发生时得到通知,在没有事件发生时阻塞。epoll_wait的签名如下
1 | #include <sys/epoll.h> |
- epfd——epoll实例的描述符
- evlist——一个epoll_event 的数组,evlist由调用线程分配,当epoll_wait返回时,此数组被修改来指示哪些在interest list中的描述符就绪(即ready list)
- maxevents——evlist的长度
- timeout——指示了epoll_wait会阻塞多长时间
- 设置为0时,epoll_wait不阻塞,在确认interest list中的哪些描述符就绪后立即返回
- 设置为-1,epoll_wait永远阻塞,直到1个或更多个interest list中的描述符就绪或者被中断。在阻塞过程中,内核可能会使进程进入睡眠。
- 设置为非负非零数,epoll_wait会阻塞直到直到1)1个或更多个interest list中的描述符就绪。2)被中断。3)超时
epoll_wait的返回值为
- 出现错误 (EBADF or EINTR or EFAULT or EINVAL) ,返回-1
- 超时,返回0
- 如果1个或更多描述符就绪,返回值为evlist中的描述符数量,通过检查evlist,可以确定在哪个文件描述符上发生了什么事件
epoll的关键
要理解epoll,需要先理解文件描述符是怎么工作的。
一个进程通过描述符来引用一个io流,每个进程都维护了一个描述符表,用于记录它需要访问的描述符,表中的每一行有两个字段
- flags——控制文件描述符操作的标志(唯一的标志是 close on exec )
- 一个指向内核中数据结构的指针
文件描述符要么被显示的创建(open, pipe, socket)等或者是从父进程中继承,通过dup/dup2系统调用也可以复制文件描述符
当以下情况时,描述符被释放:
- 进程退出
- close系统调用
- 当一个进程fork时,所有的文件描述符被“复制”给子进程,如果这些描述符被标记了close-on-exec,那么在fork之后,exec之前会被关闭。父进程仍可使用这些文件描述符,但是子进程在exec之后则不可用
假设上图A进程中fd3被标记为clos-on-exec。如果A fork了 B,当fork后的一瞬间,A和B是相同的。B可以“访问”fd0,fd1,fd2,fd3。
但是fd3被标记为 close-on-exec,那么在B exec之前,这个描述符会被标记为“不活跃”,因此B也无法访问它
要真正理解这是什么意思,需要理解描述符只是属于各进程的一个指针,指向了内核中的叫做文件描述( file description)的数据结构(太拗口,后文简称文件好了)
内核维护了一个表用于管理所有打开的文件(file description),叫做打开文件表(open file table)
假设fd3是通过在fd0上调用dup或者fcntl得到的,那么fd0和fd3都指向内核中同一个文件。如果进程A fork 进程B,并且fd3标记为close-on-exec,那么B会继承A所有的文件描述符但是无法使用fd3。需要注意的是B中的fd0指向了内核中的同一个文件。
现在我们有进程A中的fd0,fd3,和进程B中的fd0,都指向了内核中同一个打开的文件,记住这个,这是理解epoll的关键。其他文件描述符在图中被省略了。
注意-不仅只有父子进程共享文件描述符,如果一个文件描述符通过unix socket被分享给另外一个进程,那么这两个进程也同时拥有指向内核中同一个打开文件的描述符
最终,重要的是要理解file description结构中inode指针字段的作用。
inode是文件系统的数据结构,包含了一个文件系统对象(文件或目录)的信息,包括
- 文件或目录 块的位置
- 文件或目录 的属性
- 其他的元信息,包括访问时间,owner(不知道怎么翻译了),权限等信息
文件系统中每一个文件(和目录)都有一个inode entry,是一个代表文件的数字,也叫inode number。在很多操作系统上,inode的最大数量有上限,也就是文件总数有上限。
在磁盘中有一个inode table,用于维护inode number和实际inode结构的映射。为了知道文件的具体位置或相关信息,内核文件系统必须访问inode table
假设在进程A fork了进程B之后,A又创建了两个文件描述符fd4和fd5,这两个文件描述符没有被B复制。假设fd5是A通过open
以读方式打开abc.txt得到的,B则以写方式同样打开abc.txt,返回给B的描述符是fd10。这样,A的fd5和B的fd10指向了打开文件表中的不同文件描述,但是却指向同一个inode(即同一个文件)
这有两个非常重要的信息
A和B中的fd0指向同一个文件描述,意味着共享文件偏移量(file offset),如果A修改了偏移量(通过read(),write(),lseek()),那么偏移量的修改对B也可见。(对fd3来说也是,因为A的fd3和B的fd0指向同一文件描述)
这也适用于一个进程对打开文件状态的修改(O_ASYNC, O_NONBLOCK, O_APPEND)如果B使用fcntl对fd0设置了
O_NONBLOCK
,那么对于A的fd0,fd3来说会观察到非阻塞的行为。
epoll的核心
假设A有两个打开的文件描述符fd0和fd1,在打开文件表中会有两个文件描述,假设这两个文件描述指向不同的inode
epoll_create创建一个新的inode entry 和一个新的文件描述,返回给进程一个文件描述符,指向对应的文件描述
当使用epoll_ctl添加文件描述符(比如fd0)到epoll实例的interest list时,我们其实是把fd0的文件描述添加到epoll的interest list(这句不是很看的懂,感觉语法有问题,原文为 we’re actually fd0’s underlying file description to the epoll instance’s interest list.)
因此epoll的实例会自动监视底层的文件描述,而非属于进程的文件描述符。这有几个有趣的提示
- 如果进程A fork 了子进程B,B继承所有A的描述符,包括了fd9,epoll实例的描述符。然而,B的fd0,fd1,fd9仍然指向同一个内核结构(说的应该是file description),B的epoll实例与A共享interest list
- 如果A fork之后,创建了新的描述符fd8(没有被B继承),通过epoll_ctl添加至interest list,那么当调用epoll_wait的时候,不止有A收到fd8事件的通知
如果B也调用了epoll_wait,那么B也会收到fd8的通知。这也同样适用于通过dup/dup2或者unix socket共享epoll 描述符的情况。
假设B通过open打开fd8指向的文件,得到新的文件描述符fd15,假设A关闭了fd8。有人可能会认为既然A已经关闭了fd8,那么在调用epoll_wait的时候就不会在收到fd8的消息了。其实不是这样的。因为interest list监听了打开的文件描述(open file description),既然fd15与fd8指向相同的文件描述,A会收到fd15的通知。只要一个文件描述符被注册到了epoll实例,在底层的文件描述仍然被至少一个其他文件描述符引用的情况下(可以是其他进程)那么这个进程将一直接收到该文件描述符的消息,即使它已经关闭了对应的文件描述符。
为什么epoll比select 和 poll性能更好
如之前的文章所述,select/poll的时间复杂度是O(N),意味着在N很大时,每一次select/poll被调用,即使只有少量的事件发生,内核仍然需要扫描所有的文件描述符
因为epoll监听了底层的文件描述,每次该文件io就绪时,内核将其添加到ready list中,而不需要等待进程调用epoll_wait。当进程调用epoll_wait时,内核不需要做任何额外的工作,只是将其维护的ready list返回即可。
此外,每次调用select/poll时,需要向内核传递我们需要监听的描述符信息(从函数签名就可以看出),内核返回各描述符对应的信息,进程需要扫描所有描述符来判断哪些描述符io就绪。
在epoll中,一旦我们使用epoll_ctl添加了文件描述符到interest list中,在epoll_wait时我们就不必再向内核传输哪些我们关心描述符,内核也只返回就绪的描述符,而不是像select/poll一样返回所有传进来的描述符。
结果就是,epoll的复杂度是O(事件发生的数量)而不是O(监听的描述符数量)
边缘触发
默认的,epoll提供水平触发,每一次epoll_wait调用只返回interest list中的一个子集(ready list)
如果我们有四个文件描述符,调用epoll时只有fd2,fd3就绪了,只有这两个描述符的信息会被返回。
值得一提的是在lt模式下,描述符的属性(阻塞或非阻塞)不会影响epoll_wait的结果,因为epoll只在描述符变为就绪时更新ready list
有些时候我们只想知道一个描述符的状态(比如fd1),而不管它是否就绪。epoll允许我们使用边缘触发。如果我们希望知道从上一次epoll_wait调用(或者从描述符创建以来)以来是否有io活动,我们可以在调用epoll_ctl注册文件描述符时或上EPOLLET标志位。
1 | function Poller:register(fd, r, w) |
或许用一个例子来解释会更好。假设一个进程已经注册了4个描述符至epoll,fd3是socket。
假设在t1时刻,fd3对应的socket有数据到达
在t4时刻,进程调用了epoll_wait,如果此时fd2和fd3就绪,那么epoll_wait将报告fd2和fd3就绪
假设在t6时刻又调用了epoll_wait,并且fd1就绪,假设t4至t6之间fd3无数据到达
在水平触发模式下,epoll_wait调用会返回fd1,因为fd1是唯一就绪的。然而在边缘触发下,调用将会阻塞,因为在t4到t6之间fd3无新数据到达