8、I/O多路复用之epoll(上):epoll的三大函数与LT/ET模式详解
聊到高性能网络编程,epoll 是个绕不开的话题。我记得刚入行那会儿,还在用 select 写并发服务器,连接数一上千,CPU 就飙得厉害。后来换了 epoll,整个世界清静了。今天咱们就来拆解 epoll 的核心机制。
epoll 是 Linux 下最高效的 I/O 多路复用方案。它解决了 select/poll 的三大痛点:
- 每次调用都要传递全量 fd 集合
- 内核需要线性扫描所有 fd
- fd 数量有上限(默认 1024)
说白了,epoll 就是「事件驱动」的典范——只关注那些真正「有事」的连接。
8.1 epoll_create —— 创建 epoll 实例
这是第一步。你调用 epoll_create,内核帮你创建一个 epoll 实例,返回一个文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
// 返回:成功返回 fd,失败返回 -1
嗯,这里要注意。在 2.6.8 之后的内核中,size 参数其实被忽略了。内核会动态调整内部数据结构的大小。但为了兼容性,我建议你传一个大于 0 的值,比如 1024 或 65535。
还有一个变体:
int epoll_create1(int flags);
// flags 可以传 0 或 EPOLL_CLOEXEC
EPOLL_CLOEXEC 是个好东西。它会在执行 exec 时自动关闭这个 fd,防止子进程意外继承。我在写守护进程时吃过这个亏——子进程拿着父进程的 epoll fd 乱搞,排查了半天。
8.2 epoll_ctl —— 控制事件注册
创建完实例,接下来就是往里面添加、修改或删除 fd 了。这就是 epoll_ctl 的活。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 返回:成功返回 0,失败返回 -1
op 有三个选项:
EPOLL_CTL_ADD—— 添加一个新的 fdEPOLL_CTL_MOD—— 修改已注册 fd 的事件EPOLL_CTL_DEL—— 删除一个 fd
核心数据结构是 struct epoll_event:
struct epoll_event {
uint32_t events; // 感兴趣的事件
epoll_data_t data; // 用户数据
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events 可以组合以下宏:
| 事件宏 | 含义 |
|---|---|
| EPOLLIN | 可读事件 |
| EPOLLOUT | 可写事件 |
| EPOLLERR | 错误事件 |
| EPOLLHUP | 挂起事件(对端关闭) |
| EPOLLET | 设置为边缘触发模式 |
| EPOLLONESHOT | 一次性触发,触发后自动移除 |
一个典型的注册代码:
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发 + 可读
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
8.3 epoll_wait —— 等待事件发生
这是 epoll 的核心。你调用它,内核告诉你哪些 fd 就绪了。
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
// 返回:就绪的 fd 数量,超时返回 0,出错返回 -1
参数说明:
events—— 输出参数,内核把就绪事件写到这里maxevents—— 最多返回多少个事件timeout—— 超时时间(毫秒),-1 表示阻塞等待
为什么 epoll 快?因为它只返回「就绪」的 fd。select 要遍历所有 fd,epoll 直接给你结果。时间复杂度 O(1)。
一个典型的事件循环:
struct epoll_event events[128];
while (1) {
int n = epoll_wait(epfd, events, 128, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 处理可读事件
handle_read(events[i].data.fd);
}
if (events[i].events & EPOLLOUT) {
// 处理可写事件
handle_write(events[i].data.fd);
}
}
}
8.4 LT 模式 vs ET 模式
这是 epoll 最让人困惑的地方。我当年也绕了很久。
LT(水平触发,Level Triggered):
- 只要 fd 还有数据可读,epoll_wait 就会一直返回它
- 你不用一次性读完,下次还能继续读
- 简单、安全,不容易丢事件
ET(边缘触发,Edge Triggered):
- 只有状态发生变化时才通知一次
- 你必须一次性把数据读完(或写完)
- 效率更高,但容易踩坑
你想想看,为什么 ET 效率高?因为 LT 模式下,内核要一直记住「这个 fd 还有数据」,每次 epoll_wait 都要检查。ET 模式只管「变化」,不管「存量」。
ET 模式的使用要点:
- fd 必须是非阻塞的
- 读事件:循环 read 直到返回 EAGAIN
- 写事件:循环 write 直到返回 EAGAIN 或写完
一个 ET 模式的读处理:
void handle_read_et(int fd) {
char buf[4096];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭
close(fd);
break;
} else {
if (errno == EAGAIN) {
break; // 数据读完了
}
// 真正的错误
break;
}
}
}
LT 模式就简单多了:
void handle_read_lt(int fd) {
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据,没读完下次还会通知
} else if (n == 0) {
close(fd);
}
}
LT 模式不用循环,读一次就行。没读完的数据,下次 epoll_wait 还会触发。简单吧?
8.5 如何选择?
我个人建议:
- 新手或简单场景:用 LT。不容易出错,代码好写。
- 高性能场景:用 ET。减少 epoll_wait 的触发次数,CPU 占用更低。
- 混合使用:同一个 epoll 实例可以同时注册 LT 和 ET 的 fd,互不影响。
我记得在做一个网关项目时,内部通信用 LT,外部连接用 ET。LT 处理管理连接,ET 处理数据连接。各取所长,效果很好。
好了,epoll 的三大函数和两种模式就聊到这儿。这些是 epoll 的基石,理解了它们,后面的高性能服务器设计才能展开。下一节我们继续深入 epoll 的内部实现和高级用法。
公众号:蓝海资料掘金营,微信 deep3321