揭秘epoll:高并发服务器的终极武器
摘要:epoll是Linux内核2.6引入的高效I/O多路复用机制,相比select/poll具有显著优势。其核心采用红黑树管理描述符(O(1)操作)和就绪队列(回调触发),通过epoll_create、epoll_ctl、epoll_wait三个API实现。epoll支持LT(水平触发)和ET(边缘触发)两种模式,后者需配合非阻塞I/O使用。相比select/poll的轮询机制,epoll避免了
引言
在前两篇文章中,我们分别学习了 select 和 poll。它们都能实现 I/O 多路复用,但存在共同的性能瓶颈:
-
每次调用都需要将描述符集合完整拷贝到内核,当描述符数量成千上万时开销巨大
-
内核采用轮询机制逐个检查描述符,时间复杂度 O(n)
-
返回后用户仍需遍历所有描述符才能确定哪些就绪
epoll 是 Linux 内核 2.6 引入的终极 I/O 多路复用方案,它通过红黑树 + 就绪队列 + 回调机制彻底解决了上述问题,是构建高并发服务器的核心技术。

第一部分:epoll 的核心原理
一、三大核心数据结构

二、三个核心 API
| API | 作用 | 关键点 |
|---|---|---|
epoll_create |
创建内核事件表 | 返回 epoll 文件描述符 |
epoll_ctl |
管理红黑树中的描述符 | 添加/删除/修改 |
epoll_wait |
获取就绪事件 | 直接从就绪队列返回 |
三、与 select/poll 的核心对比
| 对比项 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 + 就绪队列 |
| 最大描述符数 | 1024 | 无限制 | 无限制(受系统限制) |
| 内核检测方式 | 轮询 O(n) | 轮询 O(n) | 回调 O(1) |
| 返回方式 | 只返回数量 | 只返回数量 | 直接返回就绪列表 |
| 每次调用拷贝 | 拷贝整个集合 | 拷贝整个数组 | 无需拷贝(只注册一次) |
| 用户遍历开销 | O(n) | O(n) | O(k)(k 为就绪数) |
第二部分:核心 API 详解
一、epoll_create — 创建内核事件表
#include <sys/epoll.h>
int epoll_create(int size);
// size:历史遗留参数,大于 0 即可,内核会忽略
// 返回值:epoll 文件描述符(epfd),失败返回 -1
// 或使用更现代的版本
int epoll_create1(int flags);
// flags:0 或 EPOLL_CLOEXEC(设置 close-on-exec 标志)
关键理解:epoll_create 在内核中分配一块空间,包含:
-
红黑树:存储所有被监控的描述符
-
就绪队列(双向链表):存储已经就绪的描述符
int epfd = epoll_create(1); // 参数 1 只是兼容旧版,实际被忽略
if (epfd == -1) {
perror("epoll_create error");
exit(1);
}
二、epoll_ctl — 管理监控的描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数详解:
| 参数 | 类型 | 说明 |
|---|---|---|
| epfd | int | epoll 实例的文件描述符 |
| op | int | 操作类型 |
| fd | int | 要操作的目标文件描述符 |
| event | struct epoll_event* | 事件配置 |
操作类型 (op):
| 宏 | 作用 |
|---|---|
EPOLL_CTL_ADD |
向红黑树中添加描述符 |
EPOLL_CTL_MOD |
修改已注册描述符的事件 |
EPOLL_CTL_DEL |
从红黑树中删除描述符 |
struct epoll_event 结构体:
struct epoll_event {
uint32_t events; // 事件类型(EPOLLIN、EPOLLOUT 等)
epoll_data_t data; // 用户数据(联合体)
};
typedef union epoll_data {
void *ptr; // 可存储任意指针
int fd; // 存储文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
常用事件类型:
| 事件宏 | 含义 |
|---|---|
EPOLLIN |
数据可读(包括新连接到达) |
EPOLLOUT |
数据可写 |
EPOLLERR |
错误事件(自动检测,无需设置) |
EPOLLHUP |
连接挂起(自动检测) |
EPOLLRDHUP |
对端关闭连接或关闭写端 |
EPOLLET |
边缘触发模式(Edge Triggered) |
添加描述符示例:
struct epoll_event ev;
ev.events = EPOLLIN; // 关注读事件
ev.data.fd = socket_fd; // 存储描述符(返回时使用)
if (epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev) == -1) {
perror("epoll_ctl add error");
}
删除描述符示例:
if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) == -1) {
perror("epoll_ctl del error");
}
三、epoll_wait — 获取就绪事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数详解:
| 参数 | 类型 | 说明 |
|---|---|---|
| epfd | int | epoll 实例的文件描述符 |
| events | struct epoll_event* | 输出数组,存放就绪事件 |
| maxevents | int | 最多返回的事件数量 |
| timeout | int | 超时时间(毫秒,-1 永久阻塞,0 立即返回) |
返回值:
| 返回值 | 含义 |
|---|---|
| >0 | 就绪事件的数量(数组前 n 个元素有效) |
| =0 | 超时,没有就绪事件 |
| =-1 | 失败 |
使用示例:
struct epoll_event evs[10]; // 存放就绪事件
int n = epoll_wait(epfd, evs, 10, 5000); // 最多返回10个,超时5秒
if (n == -1) {
perror("epoll_wait error");
} else if (n == 0) {
printf("timeout\n");
} else {
// 处理前 n 个就绪事件
for (int i = 0; i < n; i++) {
int fd = evs[i].data.fd;
if (evs[i].events & EPOLLIN) {
// 读事件就绪
}
}
}
第三部分:完整服务器实现
一、头文件与宏定义
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define PORT 6000
#define MAX_EVENTS 10
#define BUFFER_SIZE 128
二、创建监听套接字
int create_listen_socket() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
perror("socket error");
return -1;
}
// 端口复用
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind error");
close(fd);
return -1;
}
if (listen(fd, 5) == -1) {
perror("listen error");
close(fd);
return -1;
}
printf("epoll 服务器启动,端口:%d\n", PORT);
return fd;
}
三、epoll 事件管理辅助函数
// 向 epoll 添加描述符
void epoll_add(int epfd, int fd) {
struct epoll_event ev;
ev.events = EPOLLIN; // 关注读事件
ev.data.fd = fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
perror("epoll_ctl add error");
}
}
// 从 epoll 删除描述符
void epoll_del(int epfd, int fd) {
if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) == -1) {
perror("epoll_ctl del error");
}
}
四、主函数
int main() {
// 1. 创建监听套接字
int listen_fd = create_listen_socket();
if (listen_fd == -1) exit(1);
// 2. 创建内核事件表
int epfd = epoll_create(1);
if (epfd == -1) {
perror("epoll_create error");
exit(1);
}
// 3. 将监听套接字加入事件表
epoll_add(epfd, listen_fd);
struct epoll_event evs[MAX_EVENTS];
while (1) {
// 4. 等待事件就绪
int n = epoll_wait(epfd, evs, MAX_EVENTS, 5000);
if (n == -1) {
perror("epoll_wait error");
break;
} else if (n == 0) {
printf("timeout\n");
continue;
}
// 5. 处理就绪事件
for (int i = 0; i < n; i++) {
int fd = evs[i].data.fd;
if (fd == listen_fd) {
// 监听套接字:有新连接
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int client_fd = accept(listen_fd,
(struct sockaddr*)&client_addr, &len);
if (client_fd != -1) {
printf("新连接: fd=%d\n", client_fd);
epoll_add(epfd, client_fd); // 监控新连接
}
} else {
// 客户端连接:有数据或断开
char buffer[BUFFER_SIZE];
int ret = recv(fd, buffer, BUFFER_SIZE - 1, 0);
if (ret <= 0) {
// 客户端断开
printf("断开: fd=%d\n", fd);
epoll_del(epfd, fd);
close(fd);
} else {
buffer[ret] = '\0';
printf("收到 fd=%d: %s\n", fd, buffer);
send(fd, "OK", 2, 0);
}
}
}
}
close(listen_fd);
close(epfd);
return 0;
}
第四部分:LT 模式 vs ET 模式
一、两种触发模式对比

二、核心区别总结
| 对比项 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| 触发方式 | 缓冲区有数据就通知 | 只在状态变化时通知一次 |
| 读取要求 | 可以分多次读 | 必须一次读完 |
| 阻塞 I/O | 可以使用 | 必须使用非阻塞 I/O |
| 编程复杂度 | 简单(默认模式) | 较高 |
| 事件频率 | 可能多次触发 | 触发次数少 |
| 设置方式 | 默认行为 | 设置 EPOLLET 标志 |
三、ET 模式代码示例
// 设置 ET 模式 + 非阻塞
void epoll_add_et(int epfd, int fd) {
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
}
// ET 模式下的读取(必须循环读到 EAGAIN)
void handle_et_read(int fd) {
char buffer[128];
while (1) {
int n = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
buffer[n] = '\0';
printf("收到: %s\n", buffer);
} else if (n == 0) {
// 对端关闭
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据读完
break;
}
// 其他错误
close(fd);
break;
}
}
}
第五部分:epoll 的三个面试高频考点
一、select/poll/epoll 的区别
| 问题 | 答案要点 |
|---|---|
| 描述符限制 | select=1024,poll/epoll 无限制 |
| 内核检测方式 | select/poll 轮询 O(n),epoll 回调 O(1) |
| 数据结构拷贝 | select/poll 每次拷贝,epoll 只注册一次 |
| 返回结果 | select/poll 返回数量需遍历,epoll 直接返回就绪列表 |
二、ET 和 LT 的区别
| 问题 | 答案要点 |
|---|---|
| 触发次数 | LT 多次触发,ET 只触发一次 |
| 读取方式 | LT 可分次读,ET 必须一次读完 |
| 阻塞要求 | LT 无要求,ET 必须非阻塞 |
| 适用场景 | LT 简单场景,ET 高并发场景 |
三、为什么 epoll 高效
-
只注册一次:描述符通过红黑树管理,无需每次拷贝
-
回调机制:描述符就绪时主动通知,不需要轮询
-
直接返回:epoll_wait 直接返回就绪列表,不需要遍历全部描述符
总结
一、epoll 使用流程
1. epoll_create() → 创建内核事件表(红黑树+就绪队列)
2. epoll_ctl(ADD) → 将描述符注册到红黑树
3. epoll_wait() → 等待事件,从就绪队列获取
4. 处理就绪事件
5. epoll_ctl(DEL) → 连接关闭时从红黑树删除
6. 回到步骤 3
二、三种 IO 多路复用方案选择
| 场景 | 推荐方案 |
|---|---|
| 描述符数 < 1024,简单场景 | select(最跨平台) |
| 描述符数 > 1024,简单场景 | poll |
| 高并发服务器 | epoll |
| 追求极致性能 | epoll + ET 模式 |
三、一句话记忆
epoll 用红黑树管理全部描述符(只注册一次),用就绪队列存放已就绪的描述符(回调机制),epoll_wait 直接从就绪队列获取结果,时间复杂度从 select/poll 的 O(n) 降为 O(1)。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)