引言
epoll 是 Linux 特有的结构,它允许一个进程监听多个文件描述符,并在 I/O 就绪时获取到通知。epoll 有 ET(edge-triggered) 跟 LT(level-triggered) 两种对文件描述符的操作模式,默认为 LT。在我们深入了解它之前,让我们先看看它的语法。
epll 语法
与 poll 不同的是,epoll 本身并不是一个系统调用。它是一个允许进程在多个文件描述符上复用 I/O 的内核数据结构。
该数据结构通过以下三个系统调用创建、修改、删除。
epoll_create
epoll_create 用于创建 epoll 实例,并返回一个文件描述符给 epoll 实例。方法签名如下:
#include
int epoll_create(int size);
size 参数用来告知内核进程想要监听的文件描述符数量,以此来辅助内核推算出 epoll 实例的大小。但是,从 Linux 2.6.8 起,epoll 数据结构可以随着文件描述符的添加、删除动态调整,所以该参数就可忽略了。
epoll_create 返回一个文件描述符给新创建的 epll 内核数据结构。调用进程可以通过该描述进行添加、删除、修改它想要监听 I/O 的其他描述符 给 epoll 实例。
另一个 epoll_create1 的系统调用签名如下:
int epoll_create1(int flags);
flag 参数既可以是 0 也可以是 EPOLL_CLOEXEC。
当 flag 为 0 的时候,该函数的行为跟 epoll_create 一致。
当 flag 为 EPOLL_CLOEXEC 时,当前进程 fork 出来的任何子进程在执行前都会关闭 epoll 描述符。也因此,子进程不能够访问 epoll 实例。
Tips
有一点需要特别注意,关联 epoll 实例的文件描述符需要通过 close() 系统调用来释放。多个进程可能持有同一个 epoll 实例的文件描述符(如:当 EPOLL_CLOEXEC 标记没有指定时,fork 出来的子进程会复制该文件描述符)。当所有的进程都不再使用该描述符时(通过调用 close() 或者退出),内核才会销毁 epoll 实例。
epoll_ctl
进程可以通过 epoll_ctl 来添加它想要监听的描述符给 epoll 实例。所有注册到 epoll 实例的文件描述符统称为 epoll set 或者 interest list。
上图中,pid 为 483 的进程在 epoll 实例中注册了 FD1,FD2,FD3,FD4,FD5 文件描述符。以上就是 epoll 实例的 interest list 或者 epoll set。随后,当任何文件描述符已经准备好 I/O 时,它们就会放到 ready list 中。
ready list 是 interest list 的子集,如图所示:
epoll_ctl 的签名如下:
#include
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd 由 epoll_create 返回的 epoll 文件描述符,指向内核中 epoll 实例。
fd 需要注册到 epoll list 或者 interest list 中的文件描述符。
op 对以上文件描述符 fd 要执行的操作,一般支持以下三个操作:
EPOLL_CTL_ADD 向 epoll 实例中注册该文件描述符,当指定事件发生时,获取到通知。
EPOLL_CTL_DEL 将该文件描述符从 eopll 实例中删除,意味着进程不会收到任何发生在该文件描述符上事件的通知。
-
EPOLL_CTL_MOD 修改该文件描述符上的监听事件。
event 指向存储想要在该文件描述符监听事件的 epoll_event 结构的指针。
以下是 epoll_event 结构体:
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
}
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_event 结构的第一个字段 events 是一个位掩码,表示 fd 上哪些事件正在监听。
比如:如果 fd 是一个 socket 描述符,我们可能想要监听是否有新的数据到达 socket buffer,那我们可以设置 EPOLLIN 事件。如果我们想要获取 fd 上 edge-triggered 触发的通知,可以设置 EPOLLET 按位或 EPOLLIN等等。
events 可以由以下几种宏表示:
- EPOLLIN 表示对应的文件描述符可以读(包括对端 socket 正常关闭)。
- EPOLLOUT 表示对应的文件描述符可以写。
- EPOLLPRI 表示对应的文件描述符有紧急的数据可。
- EPOLLERR 表示对应的文件描述符发生错误;
- EPOLLHUP 表示对应的文件描述符被挂断;
- EPOLLET 将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 epoll 队列里
更多的用法可以从【man page】 中获取。
epoll_event 的第二个字段是一个联合类型。
epoll_wait
通过 epoll_wait 系统调用通知线程 epoll set/ interest list 上事件的发生,该调用会一直阻塞,直到任何一个被监听的描述符准备好 I/O。
该函数签名如下:
#include
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
epfd epoll_create 返回的 epoll 文件描述符。
evlist epoll_event 结构数组。evlist 由调用进程分配,当 epoll_wait 返回时,该数组存放处于 interest list 中就绪状态(ready list)的描述符及对应的事件信息。
maxevents — evlist 数组的长度。
timeout — 该参数的行为跟 poll 以及 select 一致,指定 epoll_wait 调用等待时长。
- 当 timeout 为 0 时,epoll_wait 调用在检查 epfd 的 interest list 中哪些文件描述符准备就绪之后立即返回,不会阻塞。
- 当 timeout 为 -1 时,eopll_wait 会一直阻塞(此时内核会将该进程休眠,直到调用返回)直到一个或者多个处于 interest list 中的描述符变成就绪状态或者该调用被信号处理程序中断。
- 当 timeout 为一个正值时,epoll_wait 调用会等待 timeout 毫秒数,除非有描述符就绪或者被信号中断。
epoll_wait 有以下返回值:
- -1:当调用发生错误(EBADF or EINTR or EFAULT or EINVAL)。
- 0:调用超时。
- 返回就绪的描述符的数量,即 evlist 数组的长度
epoll 中的陷阱
为了充分了解 epoll,我们需要了解文件描述符背后的工作原理是怎样的。
每个进程维护一套它能访问到的文件描述符表,表中每个条目都包含两个字段:
- flags 用来控制对描述符的操作(唯一相同的 flag 是 close-on-exec)
- ptr 指向底层内核数据结构的指针
描述符既可以被 open、pipe、socket 等系统调用显示地创建,也可以被父进程 fork 出来的子进程继承,或者被 dup/dup2 这样的系统调用“复制”。
以下场景会释放描述符:
- 进程退出
- close 系统调用
- 进程调用 fork 派生出来的子进程会继承下来所有父进程的描述符。如果任何在父进程调用 fork 之后,子进程 execs 之前标记为 close-on-exec 的描述符,子进程都不可用,但是父进程仍然可以继续使用这些描述符
假如上图中进程 A 描述符 3 被标记为 close-on-exec,当进程 A fork 出进程 B 之后,两者完全相同,进程 B 拥有了继承下来的描述符 0,1,2,3。
但是,由于描述符 3 被标记为 close-on-exec,所以在进程 B execs 之前,该描述符会被标记为“inactive”,进程 B 也就不能够再访问它了。
为了弄清它,我们需要知道描述符只是一个指向被称为文件描述符的底层内核数据结构的进程指针。
内核维护一个包含所有打开的文件描述符表叫做打开文件表(open file table)。
我们假设进程 A 的描述符 fd3 是由 fd0 通过 dup 或者 fcntl 的系统调用创建的。原始的描述符 fd0 以及“复制”出来的描述符 fd3 都指向了内核中同一个文件描述符。
如果进程 A 接着 fork 出进程 B 而 fd3 被标记了 close-on-exec,那么子进程 B 继承下 A 的所有描述符中,fd3 是不能使用的。
有一点需要特别注意的是,子进程中的 fd0 也指向了内核打开文件表中同一个打开的文件描述符。
现在有三个描述符,分别是进程 A 中的 fd0 和 fd3,以及进程 B 中的 fd0,它们指向了底层同一个文件描述符。为了简单化,我们忽略掉进程 A 跟进程 B 的其他所有描述符(它们也都分别指向了打开文件表中的一个条目)。
Tips
注意,文件描述符会在进程以及 fork 出的子进程中共享。如果一个进程通过 Unix Domain Socke 套接字将文件描述符传递给另一个进程,那么两个进程同样会指向底层同一个内核打开的文件描述符。
最后,我们需要了解另一个比较重要的概念— 文件描述符中的结点指针。
结点就是一种文件系统数据结构,它包含有关文件系统对象(如:文件、路径)的相关信息,比如:
- 文件或者路径数据在磁盘上存储的位置
- 文件或路径属性
- 访问时间、所有者、权限等关于文件或者路径的元数据信息
文件系统中的每个文件或者路径都有一个结点条目,该条目代表着文件的编号(也叫结点编号),在很多文件系统上,可分配的结点数是有上限的。
在磁盘上有一个用于维护结点编号到磁盘上实际结点数据结构映射的结点表。为了获取文件位置或者有关文件的元信息,大部分文件系统使用内核提供的文件驱动通过结点编号来访问它。
假如进程 A fork 出子进程 B 之后,进程 A 又创建了 fd4 跟 fd5(这两个描述符不会被 B 继承)。
假如 fd5 是进程 A 为了读取 abc.txt 文件通过调用 open 来创建的,而进程 B 为了向 abc.txt 文件中写数据通过 open 调用返回的描述符是 fd10。
那么进程 A 的 fd5 以及进程 B 的 fd10 指向了打开文件表中不同的文件描述符,但是它们指向的却是同一个节点表中的条目(也就是说,它们指向了同一个文件)。
有两点值得注意的地方:
由于进程 A 以及进程 B 中的 fd0 指向了同一个文件描述符,那么它们就会共享文件偏移量,也就是说,如果进程 A 通过调用 open()、write() 或者 lseek() 等向前移动了偏移量,那么进程 B 中的偏移量同样会发生变化。对于进程 A 的 fd3 也同样如此,因为 fd3 也指向了与 fd0 一样的文件描述符。
以上规则对于其中一个进程修改了文件状态标记(O_ASYNC, O_NONBLOCK, O_APPEND)同样适用。所以,如果进程 B 通过 fd0 使用 fcntl 系统调用将文件描述符改为非阻塞模式,那么进程 A 中的 fd0 跟 fd3 也同样变为非阻塞模式。
剖析 epoll
假如进程 A 有两个指向不同结点的文件描述符 fd0 跟 fd1。
epoll_create 在内核中创建一个新的结点条目(epoll 实例)以及一个新的打开文件描述符,并把指向描述符的 fd9 返回给调用者。
当我们通过 epoll_ctl 把 fd0 添加到 epoll 实例的 interest list 中是,实际上是把 fd0 对应的底层文件描述符(打开文件描述符)放到了 epoll 实例的 interest list 中。
因此,epoll 实例实际上真正监控的是底层的文件描述符,并不是每个进程的文件描述符,这里有个有趣的现象:
如果进程 A fork 出进程 B,B 就拥有了与 A 一样的描述符,也包括 fd9。不仅如此,进程 B 的 epoll 描述符 fd9 也跟进程 A 一样,拥有相同的 interest list。
如果进程 A 在 fork 之后,创建了一个新的描述符 fd8 (并不会被进程 B 拥有),并通过 epoll_ctl 添加到 interest list 中。当 fd8 上的事件发生时,不仅仅是进程 A 会收到相关的通知,进程 B 也会收到。当通过调用 dup/dup2 复制 epoll 描述符或者通过 Unix Domain Socket 将 epoll 描述符从一个进程传递到另一个进程时,该现象同样会发生。
如果进程 B 通过 open 打开一个 df8 指向的文件,其描述符为 fd15,然后进程 A 关闭 fd8。你可能觉得既然 fd8 已经被关闭,那么肯定不会再收到 fd8 上相关的事件了。但是,实际上并非如此,因为 interest list 还在监听着打开的文件描述符。由于 fd15 跟 fd8 指向了相同的描述符,进程 A 得到的通知其实是关于 fd15 的。肯定的是,如果一个进程将它关注的描述符关闭之后还陆续收到该描述符相关的事件,那么该文件描述符对应的底层描述符肯定还被其他至少一个属于该进程或者来自于其他进程的描述引用。
为什么 epoll 性能强于 select 跟 poll
select/poll 时间复杂度为 O(N),如果 N 比较大,那么每次 select/poll 被调用时即使就绪的描述符很少,内核也需要扫描集合中的每个描述符。
epoll 监听的是底层的文件描述符,每当文件描述符 IO 就绪时,内核就会把它们添加到 ready list 中,此过程无需进程调用 epoll_wait 来实现。当进程调用 epoll_wait 等待事件发生时,内核除了将它维护的 ready list 返回给调用方外,无需做任何其他额外的工作。
此外,每次调用 select/poll 时,都需要将进程想要监听的描述符信息传递给内核。而内核返回描述符信息时,进程都需要再一次扫描所有的描述符来检查哪个描述符已经就绪了。
对于 epoll,只要我们通过 epoll_ctl 将我们想要监听的描述符添加到 epoll 实例的 interest list 中,我们就不需要将来调用 epoll_wait 时,向内核传递我们想要监听就绪信息的描述符了(select/poll 需要每次调用时传递)。
Tips
epoll 时间复杂度是 O(就绪的描述符数量) 并非 O(所要监听的描述符数量)。
边缘触发模式 ET
默认情况下,epoll 提供了水平触发通知模式。每个 epoll_wait 调用只是返回 interest list 中就绪的描述符。
假如我们注册了 fd1、fd2、fd3、fd4 四个描述符,在我们调用 epoll_wait 时,只有 fd2、fd3 已经就绪了,那么返回的也就只有这两个描述符信息。
值得注意的是,在默认的水平触发模式下,由于 epoll 只是在底层描述符就绪时才会更新 ready list,所以 interest list 中的描述符性质(阻塞与非阻塞)并不会影响 epoll_wait 调用的结果。
有时我们只是想查看 interest list 中任何一个描述符的状态,不管它是否已经就绪。边缘触发模式允许我们查看任何特定的描述符(即时在调用 epoll_wait 时还没有就绪)是否 I/O 可用。如果我们想要知道自从上次调用 epoll_wait 以来,文件描述符上是否有任何 I/O 活动发生或者描述符就绪时,epoll_wait 并没有调用,我们可以调用 epoll_ctl 时对 EPOLLET 按位或将描述符注册到 epoll 实例以得到边缘出发模式。
代码示例:
function Poller:register(fd, r, w)
local ev = self.ev[0]
// 事件注册
ev.events = bit.bor(C.EPOLLET, C.EPOLLERR, C.EPOLLHUP)
// 读事件
if r then
ev.events = bit.bor(ev.events, C.EPOLLIN)
end
if w then
// 写事件
ev.events = bit.bor(ev.events, C.EPOLLOUT)
end
// 将文件描述符设置到事件对象中
ev.data.u64 = fd
// 调用 epoll_ctl 注册
local rc = C.epoll_ctl(self.fd, C.EPOLL_CTL_ADD, fd, ev)
if rc < 0 then errors.get(rc):abort() end
end
我们以图表的方式来说明边缘触发模式的工作原理。我们采用先前的例子进行说明,进程 A 注册了 4 个描述符到 epoll 实例,其中 fd3 是 socket 描述符。
假如 t1 时刻,输出字节流到到达 fd3 引用的 socket 描述符。
假定 t4 时,进程将调用 epoll_wait。
如果 t4 时,fd2、fd3 已经就绪,epoll_wait 调用返回 fd2、fd3 已经就绪。
假定进程 t6 时,再一次调用 epoll_wait。
此时 fd1 已经就绪,而 fd3 对应的 socket 描述符在 t4 跟 t6 之间没有数据到达。
在水平触发模式下,调用 epoll_wait 将返回 fd1 给进程,因为此时 fd1 是唯一一个就绪的描述符。然而在边缘触发模式下,该调用将会阻塞,因为在 t4 跟 t6 之间没有 socket 数据到达。
参考
- 【The method to epoll’s madness】
- 【epoll.h】