3. epoll核心API精讲

epoll 的三个核心系统调用构成了整个事件驱动服务的骨架:epoll_create 负责在内核中创建一个 epoll 实例,epoll_ctl 负责向该实例增删改被监视的文件描述符,epoll_wait 负责等待并取出就绪事件。理解这三个调用的参数语义、返回值含义以及错误码,是写出稳定高性能网络服务的前提。本节围绕它们的真实调用形态展开,重点放在“参数怎么填、返回值怎么判、错误码怎么处理”上。

epoll_create 与 epoll_create1:创建内核事件表

epoll_create 的原型是 int epoll_create(int size)。它返回一个指向内核 epoll 实例的文件描述符,后续 epoll_ctl 和 epoll_wait 都通过这个 fd 操作该实例。参数 size 在 Linux 2.6.8 之后已被忽略,但必须传入一个大于 0 的值,否则返回 -1 并置 errno = EINVAL。历史上它用于提示内核事件表规模,现在内核改用动态数据结构,因此传 1 或 1024 在功能上没有区别。

更推荐使用 epoll_create1(int flags)。它把无意义的 size 换成了有实际意义的 flags,目前唯一有效的标志是 EPOLL_CLOEXEC,表示创建的 fd 在执行 exec 时自动关闭。这对多进程或调用外部程序的服务器很重要,可以避免 epoll fd 意外泄漏到子进程。若 flags 传入未定义位,返回 -1,errno = EINVAL。创建失败最常见的原因是进程打开文件数达到上限,此时 errno = EMFILE(进程级)或 ENFILE(系统级)。

int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) {
    perror("epoll_create1");
    return -1;
}

需要注意,epoll 实例本身也占用一个 fd,因此它会计入进程的 RLIMIT_NOFILE 配额。一个常见误区是认为 epoll 能监视“无限多”连接:真正限制并发量的是 fd 上限和内存,而不是 epoll 本身。

epoll_ctl:增删改监视项

epoll_ctl 的原型是 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)。四个参数分别表示:epoll 实例 fd、操作类型、目标文件描述符、事件配置结构体。操作类型有三种:

  • EPOLL_CTL_ADD:把 fd 注册到 epoll 实例,并绑定 event 中指定的事件。
  • EPOLL_CTL_MOD:修改已注册 fd 的事件配置,fd 必须已存在。
  • EPOLL_CTL_DEL:从 epoll 实例中移除 fd。在较新内核中 event 可传 NULL,但为兼容性通常仍传一个合法指针。

struct epoll_event 包含两个字段:events(事件掩码)和 data(用户数据联合体)。data 最常用的是 data.fd,也可以放指针 data.ptr,用于在事件返回时快速定位对应的连接对象。事件掩码中,EPOLLIN 表示可读,EPOLLOUT 表示可写,EPOLLERR 和 EPOLLHUP 由内核自动监视、无需显式设置。边沿触发需加 EPOLLET,一次性唤醒需加 EPOLLONESHOT。

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev) == -1) {
    perror("epoll_ctl ADD");
}

返回值方面,成功返回 0,失败返回 -1 并设置 errno。常见错误码包括:EBADF(epfd 或 fd 非法)、EEXIST(对同一 fd 重复 ADD)、ENOENT(对未注册的 fd 执行 MOD 或 DEL)、EPERM(目标 fd 不支持 epoll,例如普通文件)、EINVAL(epfd 不是 epoll fd,或 events 含非法位)、ENOMEM(内核内存不足)。其中 EEXIST 和 ENOENT 在连接复用、状态机切换时最容易出现,处理逻辑应明确区分“首次注册”和“修改事件”。

epoll_wait:等待并取出就绪事件

epoll_wait 的原型是 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)。它阻塞等待,直到有事件就绪、被信号打断或超时。返回值是本次就绪的事件个数,这些事件被填入 events 数组,最多 maxevents 个。若返回 0,表示超时且无事件;返回 -1 表示出错,需检查 errno。

参数 timeout 的单位是毫秒:-1 表示永久阻塞,0 表示立即返回(非阻塞轮询),正数表示最多等待该毫秒数。生产环境通常用 -1 配合事件驱动,或用一个较小的超时值来驱动定时任务。maxevents 必须大于 0,否则返回 EINVAL。它决定了单次调用最多返回多少事件,也决定了 events 数组的大小,通常设为 1024 或与预期并发量匹配。

struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
if (n == -1) {
    if (errno == EINTR) { /* 被信号打断,可重试 */ }
    else perror("epoll_wait");
}
for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    uint32_t ev = events[i].events;
    /* 根据 ev 处理可读/可写/错误 */
}

错误码中,EINTR 表示等待被信号处理函数打断,这不是致命错误,通常应重新调用 epoll_wait;EBADF 表示 epfd 非法;EFAULT 表示 events 数组地址不可访问;EINVAL 表示 epfd 不是 epoll fd 或 maxevents 不大于 0。特别要注意:epoll_wait 返回的事件数组是“就绪列表”,不是“全部注册项”,因此必须按返回值 n 遍历,不能遍历整个数组。

边沿触发(EPOLLET)下,epoll_wait 只在状态变化时通知一次。若一次没有把数据读干净,剩余数据可能不再触发新事件,因此必须配合非阻塞 fd 循环读取直到 EAGAIN。

三个调用的协作关系与典型错误处理

把三者串起来看:epoll_create1 建立实例,epoll_ctl 维护监视集合,epoll_wait 消费就绪事件。它们共享同一个 epfd,形成“创建—注册—等待—处理—再注册”的闭环。下图展示了这一数据流与关键错误码的对应位置。

epoll 三调用协作与错误码分布 epoll_create1(flags) 创建内核 epoll 实例 返回 epfd(≥0) 失败:EMFILE / ENFILE / EINVAL epoll_ctl(epfd,op,fd,ev) ADD / MOD / DEL 监视项 成功返回 0 失败:EEXIST / ENOENT / EPERM epoll_wait(epfd,ev,n,to) 等待并取出就绪事件 返回就绪个数 n 失败:EINTR / EFAULT / EINVAL 内核 epoll 实例(红黑树 + 就绪链表) 注册项存于红黑树,就绪项挂入就绪链表 事件循环:处理 fd → 按需 MOD/DEL 读/写完成后重新注册关注事件 回注监视集合 EINTR 非致命 应重试 epoll_wait EEXIST / ENOENT 区分首次注册与修改

实际编码时,建议把 epoll_ctl 的返回值统一封装:ADD 遇到 EEXIST 可转为 MOD,MOD 遇到 ENOENT 可转为 ADD,这样状态机在“连接建立—读写切换—关闭”过程中不必反复判断当前注册状态。对于 epoll_wait,必须把 EINTR 与真正的错误分开处理,否则信号一到服务就可能退出。最后,epoll_wait 返回的 events[i].events 可能同时包含 EPOLLIN 和 EPOLLOUT,也可能包含 EPOLLERR 或 EPOLLHUP,处理时应先判错误与挂断,再处理读写,避免对已失效连接继续操作。

不要忽略 epoll_ctl 的返回值。在连接数很高时,ENOMEM 或 EMFILE 可能偶发出现,若直接忽略,后续 epoll_wait 将永远等不到该 fd 的事件,表现为连接“静默卡死”。

掌握这三个调用的参数与错误码后,下一步才是把它们组织成完整的事件循环。本节的关键结论可以概括为:epoll_create1 用 EPOLL_CLOEXEC 创建实例并检查 fd 上限;epoll_ctl 用 ADD/MOD/DEL 维护监视集合,并针对 EEXIST、ENOENT、EPERM 做分支处理;epoll_wait 按返回值遍历就绪数组,把 EINTR 当作可重试信号,把 EPOLLERR/EPOLLHUP 优先处理。把这些细节落实,服务才能在大量并发连接下保持稳定。