9、I/O多路复用(epoll):从原理到实战
聊到高性能网络编程,epoll 是绕不开的话题。我记得刚入行那会儿,还在用 select 写并发服务器,连接数一上千,CPU 就飙得厉害。后来一位老前辈甩给我一句话:「小子,去学学 epoll 吧。」嗯,从那以后,我的服务器才算真正「站起来了」。
这一讲,咱们就彻底搞懂 epoll。说白了,它就是 Linux 下最高效的 I/O 多路复用机制。没有之一。
9.1 为什么是 epoll?
先问个问题:select 和 poll 有什么毛病?
- 每次调用都要传全部 fd 集合——内核和用户态之间来回拷贝,连接一多就扛不住。
- 线性扫描所有 fd——O(n) 复杂度,连接数 10 万,每次都要扫 10 万次。
- fd 数量有限制——select 默认 1024,改大了也治标不治本。
epoll 怎么解决的?三个字:事件驱动。它只关心「有事件发生的 fd」,而不是傻乎乎地遍历所有 fd。复杂度从 O(n) 降到了 O(1)。
核心思想: epoll 在内核中维护了一个事件表,应用程序只需告诉内核「我要关注哪些 fd 的哪些事件」,然后等着内核通知就行了。不需要每次都重新传一遍。
9.2 三大核心函数
epoll 的使用就三个函数,记住它们,你就掌握了 80%。
9.2.1 epoll_create —— 创建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);
// 返回值:成功返回 epoll 文件描述符,失败返回 -1
这个 size 参数,在 2.6.8 之后的内核中其实被忽略了,但建议还是传一个大于 0 的数,比如 1024。我个人习惯传 1,因为内核会动态扩展,但传个正数显得专业嘛。
小提示: epoll_create 返回的 fd 记得用 close() 关闭,否则会泄漏。我在项目中见过有人忘了关,结果 /proc/sys/fs/epoll/max_user_instances 被撑爆了。
9.2.2 epoll_ctl —— 控制事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 返回值:成功返回 0,失败返回 -1
三个操作:
EPOLL_CTL_ADD:注册新 fd 到 epoll 实例EPOLL_CTL_MOD:修改已注册 fd 的事件EPOLL_CTL_DEL:从 epoll 实例中删除 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 | 一次性事件 |
注意: 我曾经在项目中犯过一个低级错误——用 EPOLL_CTL_ADD 添加一个已经存在的 fd,结果返回 -1,errno 是 EEXIST。正确的做法是先 MOD 再 ADD,或者用 DEL 清理后再 ADD。
9.2.3 epoll_wait —— 等待事件
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
// 返回值:就绪的 fd 数量,超时返回 0,出错返回 -1
timeout 参数:
- -1:阻塞等待,直到有事件发生
- 0:立即返回,不管有没有事件
- >0:等待指定毫秒数
我个人习惯用 -1,配合非阻塞 I/O 使用。为什么?你想想看,如果 timeout 设成 100ms,高并发下每次都要多等 100ms,延迟就上去了。
9.3 LT 模式 vs ET 模式
这是 epoll 最让人头疼的地方,也是面试最爱问的。我尽量说清楚。
9.3.1 LT(水平触发)—— 默认模式
只要 fd 上有数据没读完,epoll_wait 就会一直通知你。说白了,就是「有剩饭就喊你」。优点是不会丢事件,缺点是可能重复通知。
举个例子:
// 假设 socket 收到 100 字节
// 你只读了 50 字节
// 下次 epoll_wait 还会通知你 EPOLLIN
// 直到你把 100 字节全部读完
9.3.2 ET(边缘触发)—— 高效模式
只有状态发生变化时才通知。比如从「无数据」变成「有数据」的那一刻,通知一次。之后就算你没读完,也不会再通知了。
这就意味着:你必须一次性把数据读完,否则就丢了。
ET 模式的使用铁律:
- fd 必须设置为非阻塞
- 读操作必须循环调用 read() 直到返回 EAGAIN
- 写操作同理,直到写缓冲区满返回 EAGAIN
我在项目中遇到过一个问题:用 ET 模式,但忘了设置非阻塞。结果 read 阻塞在最后一次调用上,整个线程卡死了。排查了半天才发现,嗯,血的教训。
9.4 epoll 事件驱动模型
epoll 的事件驱动,说白了就是「有事件才干活,没事件就睡觉」。配合非阻塞 I/O,可以实现单线程处理成千上万个连接。
核心流程:
- 创建 epoll 实例
- 将监听 socket 注册到 epoll,关注 EPOLLIN
- 循环调用 epoll_wait
- 收到事件后,判断是监听 socket 还是已连接 socket
- 监听 socket:调用 accept,将新连接注册到 epoll
- 已连接 socket:读取数据,处理业务逻辑
下面这张图帮你理清思路:
9.5 epoll 服务器完整实现
光说不练假把式。下面是一个完整的 epoll 服务器骨架,LT 模式,支持多客户端连接。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define PORT 8080
#define BUFFER_SIZE 4096
// 设置非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd, epoll_fd, nfds;
struct sockaddr_in addr;
struct epoll_event ev, events[MAX_EVENTS];
// 1. 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 监听
if (listen(listen_fd, SOMAXCONN) == -1) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 2. 创建 epoll 实例
epoll_fd = epoll_create(1);
if (epoll_fd == -1) {
perror("epoll_create");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 3. 将监听 socket 注册到 epoll
ev.events = EPOLLIN; // LT 模式,默认
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl: listen_fd");
close(listen_fd);
close(epoll_fd);
exit(EXIT_FAILURE);
}
printf("Epoll server listening on port %d...\n", PORT);
// 4. 事件循环
while (1) {
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd,
(struct sockaddr*)&client_addr, &client_len);
if (conn_fd == -1) {
perror("accept");
continue;
}
set_nonblocking(conn_fd);
ev.events = EPOLLIN | EPOLLET; // 这里用 ET 模式试试
ev.data.fd = conn_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
perror("epoll_ctl: conn_fd");
close(conn_fd);
}
printf("New client connected, fd=%d\n", conn_fd);
} else {
// 客户端数据
int fd = events[i].data.fd;
char buffer[BUFFER_SIZE];
int n;
// ET 模式必须循环读
while ((n = read(fd, buffer, sizeof(buffer) - 1)) > 0) {
buffer[n] = '\0';
printf("Received from fd %d: %s", fd, buffer);
// 回显
write(fd, buffer, n);
}
if (n == 0) {
// 客户端关闭
printf("Client fd %d disconnected\n", fd);
close(fd);
} else if (n == -1 && errno != EAGAIN) {
// 出错
perror("read");
close(fd);
}
// n == -1 && errno == EAGAIN 表示数据读完了,正常
}
}
}
close(listen_fd);
close(epoll_fd);
return 0;
}
关于 ET 模式的一点经验: 上面代码中,我把客户端连接注册成了 EPOLLET。如果你改成默认的 LT 模式,while 循环读可以去掉,但效率会低一些。我个人建议:新手上路先用 LT,等把 epoll 玩熟了再切 ET。ET 虽然高效,但坑也多。
9.6 避坑指南
最后,分享几个我踩过的坑:
- ET 模式忘记设置非阻塞——read 最后一次调用会阻塞,整个线程卡死。我曾经因为这个,线上服务挂了 10 分钟才被发现。
- epoll_ctl 的 EPOLL_CTL_DEL 忘记调用——关闭 fd 后,epoll 实例里还残留着这个 fd 的记录。虽然内核会清理,但显式删除是好习惯。
- epoll_wait 的 events 数组太小——如果同时有大量事件触发,数组装不下,事件就丢了。建议设成和 maxevents 一样大。
- 忘记处理 EPOLLERR 和 EPOLLHUP——这两个事件不处理,epoll_wait 会一直返回,导致 CPU 空转。
嗯,epoll 的内容就到这里。记住三个函数、两种模式、一个循环,你就能写出高性能的网络服务器了。多写多练,慢慢就有感觉了。
公众号:蓝海资料掘金营,微信deep3321