4. I/O 多路复用(下):epoll 模型详解,事件驱动机制,边缘触发与水平触发

上一章我们把 select 和 poll 的底裤都扒干净了。说实话,每次看到生产环境里还有人用 select 扛万级连接,我都替 CPU 捏把汗。今天聊的 epoll,才是 Linux 高并发服务器真正的亲儿子。

我个人习惯把 epoll 理解为「事件订阅中心」。你告诉内核:我对哪些 fd 的读事件感兴趣,对哪些 fd 的写事件感兴趣。内核说行,等这些 fd 就绪了,我主动通知你。整个过程不需要你轮询,不需要你拷贝整个 fd 集合。说白了,epoll 把「主动问」变成了「等着被通知」。

4.1 epoll 的核心数据结构

先看三个关键函数,它们构成了 epoll 的整个生命周期:

int epoll_create(int size);        // 创建一个 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  // 注册/修改/删除事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);  // 等待事件就绪

这里有个细节:epoll_create 的 size 参数在 2.6.8 之后其实被内核忽略了,但为了兼容性,传一个大于 0 的数就行。内核会动态调整内部数据结构的大小。

epoll 在内核里维护了两个关键结构:

  • 红黑树:用来存储所有注册的 fd 及其关注的事件。增删改查都是 O(log n),比 select 的线性扫描快得多。
  • 就绪链表:当某个 fd 上的事件就绪时,内核把对应的 epitem 节点挂到这个链表上。epoll_wait 只需要检查这个链表是否为空,不为空就直接把就绪事件拷贝到用户空间。

关键点:epoll 只返回「就绪的 fd」,而不是全部 fd。连接数 10 万,活跃连接只有 100 个,epoll 只拷贝 100 个事件到用户态。select 呢?10 万个位全部拷一遍。这就是本质差距。

4.2 事件驱动机制:从阻塞到回调

epoll 真正的灵魂,是「回调机制」。我当年第一次看内核源码时,看到这块才恍然大悟——原来不是轮询,是回调。

流程是这样的:

  1. 你调用 epoll_ctl 注册一个 fd 时,内核会为这个 fd 设置一个回调函数 ep_poll_callback。
  2. 当这个 fd 上有数据到达(比如网卡中断触发),驱动层会调用这个回调函数。
  3. 回调函数把该 fd 对应的 epitem 添加到就绪链表的尾部。
  4. 同时,如果有进程正在 epoll_wait 上睡眠,回调函数会唤醒它。

嗯,这里要注意:整个过程没有轮询,没有系统调用开销。数据到了,内核主动通知你。这就是事件驱动。

避坑指南:我曾经在生产环境遇到一个诡异问题——epoll_wait 偶尔会返回 EINTR 错误。排查了半天才发现,是某个监控脚本给进程发了 SIGSTOP/SIGCONT 信号。epoll_wait 被信号中断后会返回 -1,errno 设为 EINTR。正确的做法是:在 epoll_wait 外层加一个循环,遇到 EINTR 就重试。

4.3 水平触发(LT) vs 边缘触发(ET)

这是 epoll 面试必考题,也是实际开发中最容易踩坑的地方。我直接说结论:

特性 水平触发(LT) 边缘触发(ET)
通知时机 只要 fd 可读/可写,每次 epoll_wait 都通知 状态发生变化时通知一次
读取要求 可以不读完,下次还会通知 必须一次性读完,否则可能丢数据
编程难度 低,和 select 类似 高,需要配合非阻塞 I/O
性能 一般,可能重复通知 高,减少系统调用次数
默认模式 epoll 默认使用 LT 需要设置 EPOLLET 标志

举个例子你就明白了。假设一个 socket 接收缓冲区来了 2KB 数据:

  • LT 模式:epoll_wait 返回,告诉你这个 fd 可读。你读了 1KB,还剩 1KB。下次 epoll_wait 还会返回,继续通知你。直到你把数据读完。
  • ET 模式:epoll_wait 返回,告诉你这个 fd 可读。你读了 1KB,还剩 1KB。下次 epoll_wait 不会通知你了——因为状态没有变化(还是可读状态)。如果你不把剩下的 1KB 读完,它就永远留在缓冲区里。

警告:ET 模式下,必须使用非阻塞 I/O。为什么?因为 ET 要求你「读到 EAGAIN 为止」。如果用阻塞 I/O,最后一次 read 会阻塞住,整个线程就卡死了。我曾经见过一个新手把 ET 和阻塞 I/O 混用,结果连接一多,线程全部挂死在 read 上,服务直接雪崩。

4.4 ET 模式的正确写法

ET 模式虽然难用,但性能确实好。我分享一个我在项目中沉淀下来的模板:

// 设置非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// 注册 ET 模式事件
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 边缘触发 + 读事件
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

// 事件循环
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            // 循环读取,直到 EAGAIN
            while (1) {
                ssize_t bytes = read(events[i].data.fd, buf, sizeof(buf));
                if (bytes > 0) {
                    // 处理数据
                } else if (bytes == -1 && errno == EAGAIN) {
                    break;  // 数据读完了
                } else if (bytes == 0) {
                    // 对端关闭连接
                    close(events[i].data.fd);
                    break;
                } else {
                    // 真正的错误
                    break;
                }
            }
        }
    }
}

看到那个 while(1) 循环了吗?这就是 ET 的精髓——读到不能再读为止。每次 read 返回 EAGAIN,说明内核缓冲区已经空了,这时候才跳出循环。

4.5 我该用 LT 还是 ET?

这个问题没有标准答案,但我可以给你一些参考:

  • 新手或快速开发:用 LT。简单、安全、不容易出 bug。性能损失其实不大,因为大多数场景下,活跃连接数远小于总连接数。
  • 追求极致性能:用 ET。可以减少 epoll_wait 的返回次数,减少系统调用。但要做好充分的测试。
  • 混合使用:监听 socket 用 LT,已建立的连接用 ET。监听 socket 的事件频率很低,LT 足够;业务连接用 ET 提升吞吐。

我的经验:在 Redis、Nginx 这些顶级项目中,ET 是标配。但如果你写的是业务系统,连接数不超过 1 万,LT 完全够用。别为了炫技而用 ET,线上出 bug 的时候没人关心你用的是 LT 还是 ET,只关心服务挂了。

4.6 epoll 的完整工作流程

下面这张图展示了 epoll 从注册到通知的完整链路:

epoll 事件驱动工作流程 用户空间:应用程序 内核空间:epoll 实例 红黑树(注册的 fd) fd=100 EPOLLIN fd=200 EPOLLOUT fd=300 EPOLLIN|EPOLLET ... 就绪链表(就绪的 fd) → fd=100 (数据到达) → fd=300 (新数据) 回调函数 ep_poll_callback 当 fd 就绪时,内核驱动层自动调用 → 将 epitem 加入就绪链表 → 唤醒等待进程 数据到达 epoll_wait 返回 处理就绪事件

流程总结:

  1. 应用程序通过 epoll_ctl 注册 fd 到内核的红黑树
  2. 当 fd 上发生 I/O 事件,内核驱动层调用回调函数
  3. 回调函数将就绪的 fd 加入就绪链表
  4. 应用程序调用 epoll_wait,从就绪链表中取出事件
  5. 应用程序处理就绪的 fd

4.7 epoll 的局限与替代方案

epoll 很强,但不是万能的。我列几个实际场景中需要注意的点:

  • 惊群问题:多个线程/进程同时 epoll_wait 同一个 epoll fd,当有事件到达时,所有等待者都会被唤醒,但只有一个能处理。Linux 4.5 之后引入了 EPOLLEXCLUSIVE 标志来解决这个问题。
  • 跨平台问题:epoll 是 Linux 专属。macOS 用 kqueue,Windows 用 IOCP。如果你的服务需要跨平台,可以考虑 libevent、libuv 这些封装库。
  • 大量短连接:每次连接都要 epoll_ctl 添加/删除,频繁的系统调用开销不可忽视。我遇到过每秒 10 万次短连接的场景,epoll_ctl 本身成了瓶颈。

性能数据参考:在我之前的压测中,同样是 10 万连接、1000 活跃连接,select 的 CPU 占用率在 60% 以上,epoll(LT)在 15% 左右,epoll(ET)可以降到 10% 以下。差距主要在「就绪事件筛选」和「用户态/内核态数据拷贝」上。

好了,epoll 的核心内容就这些。记住三个关键词:红黑树存注册、回调通知就绪、就绪链表取事件。把这三件事想明白,epoll 你就掌握了。