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 真正的灵魂,是「回调机制」。我当年第一次看内核源码时,看到这块才恍然大悟——原来不是轮询,是回调。
流程是这样的:
- 你调用
epoll_ctl注册一个 fd 时,内核会为这个 fd 设置一个回调函数ep_poll_callback。 - 当这个 fd 上有数据到达(比如网卡中断触发),驱动层会调用这个回调函数。
- 回调函数把该 fd 对应的
epitem添加到就绪链表的尾部。 - 同时,如果有进程正在
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_ctl注册 fd 到内核的红黑树 - 当 fd 上发生 I/O 事件,内核驱动层调用回调函数
- 回调函数将就绪的 fd 加入就绪链表
- 应用程序调用
epoll_wait,从就绪链表中取出事件 - 应用程序处理就绪的 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 你就掌握了。