3. epoll 核心机制:从原理到实战
epoll 这东西,我刚开始接触的时候也觉得挺玄乎。说白了,它就是 Linux 上处理大量并发连接的一把利器。今天咱们就把 epoll 的三个核心函数——epoll_create、epoll_ctl、epoll_wait——掰开揉碎了讲清楚。顺便把 LT 和 ET 模式的区别也聊透。
3.1 epoll_create:创建 epoll 实例
这个函数很简单,就是创建一个 epoll 实例。你给它传一个 size 参数,但说实话,从 Linux 2.6.8 以后,这个 size 就被忽略了。内核会动态调整大小。
#include <sys/epoll.h>
int epoll_create(int size);
// 返回一个文件描述符,指向新创建的 epoll 实例
我个人习惯用 epoll_create1(0) 替代它,因为 epoll_create1 可以传入 EPOLL_CLOEXEC 标志,避免子进程继承这个 fd。嗯,这里要注意:返回的 fd 记得用 close() 关闭,否则会泄漏。
3.2 epoll_ctl:控制事件注册
这个函数负责往 epoll 实例里添加、修改或删除要监控的文件描述符。它的原型长这样:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数 op 有三个选项:
EPOLL_CTL_ADD:添加一个新的 fd 到监控列表EPOLL_CTL_MOD:修改已注册 fd 的事件类型EPOLL_CTL_DEL:从监控列表中删除一个 fd
而 struct epoll_event 结构体长这样:
struct epoll_event {
uint32_t events; // 事件类型,如 EPOLLIN、EPOLLOUT、EPOLLET
epoll_data_t data; // 用户数据,通常存 fd 或指针
};
我在项目中遇到过一个问题:有人反复调用 EPOLL_CTL_ADD 添加同一个 fd,结果返回 EEXIST 错误。正确的做法是先检查 fd 是否已注册,或者直接用 EPOLL_CTL_MOD 修改。
events 字段,结果 epoll 一直不触发事件。排查了半天才发现是 events 默认值为 0。所以每次添加时,一定要显式指定事件类型。
3.3 epoll_wait:等待事件发生
这是 epoll 的核心——阻塞等待事件发生。它的原型:
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
events:输出参数,内核会把就绪的事件填到这里maxevents:最多返回多少个事件timeout:超时时间,-1 表示无限等待,0 表示立即返回
返回值是就绪事件的数量。如果返回 0,说明超时了,没有事件发生。
你想想看,epoll 为什么比 select/poll 快?因为它只返回就绪的 fd,不需要遍历整个监控列表。而且它用红黑树管理 fd,增删改查都是 O(log n) 的复杂度。
3.4 水平触发(LT)与边缘触发(ET)
这是 epoll 最让人困惑的地方,也是面试高频题。我尽量用大白话讲清楚。
水平触发(Level-Triggered, LT)
LT 是默认模式。它的特点是:只要 fd 上有数据可读,epoll_wait 就会一直返回这个事件。哪怕你只读了一部分数据,下次调用 epoll_wait 还会再通知你。
说白了,LT 模式比较「啰嗦」。它适合那种一次读不完,需要分多次读取的场景。比如从 socket 读取不定长的消息。
边缘触发(Edge-Triggered, ET)
ET 模式正好相反。它只在状态发生变化时通知一次。比如 fd 从不可读变成可读,它通知你一次。如果你没读完,它不会再通知你,直到下一次状态变化。
ET 模式要求你:必须一次性把数据读完。否则剩下的数据就「烂」在缓冲区里了,直到对方发来新数据才会再次触发。
- LT:只要条件满足,就一直通知。适合简单场景,不容易漏事件。
- ET:只在状态变化时通知一次。效率更高,但编程复杂度也更高。
3.5 LT 与 ET 实战对比
咱们直接上代码对比。假设我们要从一个 socket 读取数据。
LT 模式示例
// LT 模式:可以分多次读取
struct epoll_event ev;
ev.events = EPOLLIN; // 默认就是 LT
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 事件循环
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == sockfd) {
char buf[1024];
int len = read(sockfd, buf, sizeof(buf));
// 即使只读了 100 字节,下次 epoll_wait 还会通知
}
}
}
ET 模式示例
// ET 模式:必须一次性读完
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 加上 EPOLLET 标志
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 事件循环
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == sockfd) {
// 必须循环读取,直到 EAGAIN
char buf[1024];
while (1) {
int len = read(sockfd, buf, sizeof(buf));
if (len == -1) {
if (errno == EAGAIN) {
break; // 数据读完了
}
// 真正的错误
break;
}
if (len == 0) {
// 对方关闭连接
break;
}
// 处理数据...
}
}
}
}
看到区别了吗?ET 模式里,你必须用 while 循环读到 EAGAIN 为止。否则就会丢数据。我在项目中就吃过这个亏——第一次用 ET 时没加循环,结果客户端发来的大包只读了一半,另一半永远收不到了。
3.6 知识体系图
下面这张图展示了 epoll 的核心流程和 LT/ET 的区别:
3.7 性能对比总结
| 对比维度 | LT 模式 | ET 模式 |
|---|---|---|
| 触发方式 | 条件满足持续触发 | 状态变化触发一次 |
| 读取要求 | 可以分多次读取 | 必须一次性读完 |
| 编程复杂度 | 低,适合新手 | 高,容易漏事件 |
| 系统调用次数 | 可能较多 | 较少,效率更高 |
| 适用场景 | 大多数通用场景 | 高并发、大流量场景 |
read 时,如果数据刚好读完,线程会卡在 read 上,导致整个事件循环阻塞。我曾经因为这个原因,线上服务出现间歇性卡顿,排查了好久才发现是阻塞 I/O 惹的祸。
好了,epoll 的核心机制就聊到这里。记住三个函数、两种模式,再加上实战中的那些坑,你就能写出健壮的网络程序了。