【Linux】使用 epoll 实现 异步IO 多路复用
一篇为防止遗忘而写的总结。从最基础的 API,到 eventfd 唤醒模式,再到生产级 IO 线程的事件循环设计,完整记录一下
一、为什么需要 epoll
假设要写一个服务器,同时处理 10000 个客户端连接。传统做法有几种:
- 每连接一线程:10000 个线程,上下文切换爆炸,内存占用巨大
- select / poll:每次调用都要把所有 fd 数组从用户态拷到内核态,内核还要线性扫描整个数组找出就绪的,O(N) 复杂度,fd 一多就崩
- epoll:fd 注册一次就常驻内核,内核用红黑树管理,事件就绪时直接挂到就绪链表,O(1) 拿到结果
epoll 的核心思想:把"我们关心哪些 fd"这件事告诉内核一次,之后内核主动通知我"哪些 fd 现在可读/可写"。
二、三个核心系统调用
#include <sys/epoll.h>
// 1. 创建一个 epoll 实例(返回一个 fd)
int epoll_create1(int flags);
// 2. 增/删/改要监听的 fd 和事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 阻塞等待事件就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_ctl 的 op 参数:
| 宏 | 含义 |
|---|---|
EPOLL_CTL_ADD |
注册新 fd |
EPOLL_CTL_MOD |
修改已注册 fd 的事件 |
EPOLL_CTL_DEL |
移除 fd |
常用的事件标志:
| 宏 | 含义 |
|---|---|
EPOLLIN |
可读 |
EPOLLOUT |
可写 |
EPOLLRDHUP |
对端关闭连接 |
EPOLLERR |
发生错误 |
EPOLLHUP |
fd 挂起 |
EPOLLET |
边缘触发(默认水平触发 LT) |
EPOLLONESHOT |
触发一次后自动禁用,需要重新 EPOLL_CTL_MOD |
这些宏全部定义在 <sys/epoll.h>,实际源头是内核的 <linux/eventpoll.h>,用下面的指令就能看到:
echo '#include <sys/epoll.h>' | gcc -E -dM -x c - | grep EPOLL
三、epoll_event 结构:
struct epoll_event {
uint32_t events; // 事件位掩码(EPOLLIN | EPOLLOUT | ...)
epoll_data_t data; // 用户自定义数据(union)
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
data 字段是整个 epoll 编程的灵魂,必须理解清楚:
- 它是一个 union,可以选择存
ptr、fd、u32或u64中的任意一种 - 内核完全不解释这个字段,只是在事件就绪时原样回传给开发者
- 它本质上是一个"路由标签"——把 fd 和我们的处理逻辑绑定起来
// 注册时存什么,事件返回时就拿到什么
ev.data.u32 = 42;
epoll_ctl(epfd, EPOLL_CTL_ADD, some_fd, &ev);
// ...
// epoll_wait 返回后
events[i].data.u32 // == 42
常见用法:
- 存 fd:
ev.data.fd = sock_fd;— fd 少时最直观 - 存 u32 标签:
ev.data.u32 = kEventTimer;— 适合 switch 分发 - 存指针:
ev.data.ptr = connection;— 大型服务器最常用,直接拿到连接上下文
四、最小可运行示例:TCP echo 服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAX_EVENTS 64
#define PORT 8080
static int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
// 1. 创建监听 socket(非阻塞)
int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, SOMAXCONN);
// 2. 创建 epoll 实例
int epfd = epoll_create1(EPOLL_CLOEXEC);
// 3. 注册监听 socket(边缘触发)
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
// 4. 事件循环
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait"); break;
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t evs = events[i].events;
// 错误或对端关闭
if (evs & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
continue;
}
if (fd == listen_fd) {
// ET 模式必须循环 accept 到 EAGAIN
while (1) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) {
if (errno == EAGAIN) break;
perror("accept"); break;
}
set_nonblocking(conn_fd);
struct epoll_event cev;
cev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
cev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
}
} else if (evs & EPOLLIN) {
// 客户端数据:echo 回去
char buf[4096];
while (1) {
ssize_t cnt = read(fd, buf, sizeof(buf));
if (cnt > 0) {
write(fd, buf, cnt);
} else if (cnt == 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
} else {
if (errno == EAGAIN) break;
if (errno == EINTR) continue;
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
}
}
}
}
return 0;
}
编译运行:gcc -o server server.c && ./server,用 nc localhost 8080 测试。
五、对比 水平触发 (LT) 和 边缘触发 (ET)
水平触发 LT(默认)
只要 fd 仍处于就绪状态,每次 epoll_wait 都会返回它。
举例:socket 收到 100 字节,但只读了 50 字节。下次 epoll_wait 还会返回这个 fd,因为还有 50 字节没读完。
优点:编程简单,类似 select/poll 的行为
缺点:如果一直不处理,会被反复通知,浪费 CPU
边缘触发 ET
只在 fd 状态发生变化时通知一次:从"不可读"变成"可读"那一刻通知,之后就算还有数据,也不会再通知,直到你把数据全部读完,再有新数据到来时才会再次通知;
该方案相比LT, 通知次数少,性能更高,但是需要配合以下使用要点:
- 必须配合非阻塞 fd 使用
- 必须循环 read/write 直到返回
EAGAIN,否则会丢事件
// ET 模式正确写法
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { /* 处理数据 */ }
else if (n == 0) { /* 对端关闭 */ break; }
else {
if (errno == EAGAIN) break; // ← 关键:读到这里才能停
if (errno == EINTR) continue;
/* 其他错误处理 */
break;
}
}
适用场景
- 简单应用、连接数不多 → LT
- 高性能服务器、追求最低 syscall 开销 → ET
- nginx、redis、libevent 默认都是 ET
用epoll + 某fd实现特定工具
eventfd + epoll 实现线程间唤醒
问题场景
当我们的 IO 线程正阻塞在 epoll_wait 上。这时另一个线程下发了一条命令,希望 IO 线程立刻处理,而不是等到下一个定时器超时。怎么办?
答案:用 eventfd 当作"门铃",把 eventfd 注册进 epoll。其他线程"按门铃",IO 线程就被唤醒
eventfd 是什么
#include <sys/eventfd.h>
int eventfd(unsigned int initval, int flags);
eventfd 是内核提供的一种计数器 fd:
- 内核里维护一个
uint64_t计数器 write(fd, &v, 8)把v加到计数器上read(fd, &v, 8)读出当前值并清零- 只要计数器 > 0,fd 就处于
EPOLLIN可读状态
常用 flags:
EFD_NONBLOCK:非阻塞模式EFD_CLOEXEC:exec 时自动关闭EFD_SEMAPHORE:信号量语义(每次 read 减 1 而不是清零)
完整唤醒流程
// 创建并注册
int event_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.u32 = kEventWake; // ← 自定义标签
epoll_ctl(epfd, EPOLL_CTL_ADD, event_fd, &ev);
// IO 线程的事件循环
for (;;) {
int n = epoll_wait(epfd, events, MAX, -1);
for (int i = 0; i < n; i++) {
switch (events[i].data.u32) {
case kEventWake:
handle_wake(); // 处理唤醒
break;
// 其他事件...
}
}
}
// 处理函数:必须读空 eventfd,否则下次 epoll_wait 会立即再次返回
void handle_wake() {
uint64_t value;
while (read(event_fd, &value, sizeof(value)) == sizeof(value)) {
// 循环读直到 EAGAIN
}
do_some_work();
}
// 其他线程调用
void wake() {
uint64_t value = 1;
write(event_fd, &value, sizeof(value)); // 一行代码叫醒 IO 线程
}
整个唤醒链路图解
[其他线程] [IO 线程]
阻塞在 epoll_wait()
│
wake() │
│ │
└─► write(event_fd, 1) │
│ │
▼ │
[内核 eventfd 计数器: 0 → 1] │
│ │
└─► fd 变为可读 │
│ │
└─► epoll 把 event_fd 加入就绪链表
│ │
└─────唤醒─────────►│
▼
epoll_wait 返回 n=1
events[0].data.u32 == kEventWake
│
▼
switch → case kEventWake
│
▼
handle_wake()
├─ read 清零计数器
└─ 执行业务逻辑
关于 write 写入值的几个小坑
写入的 value 只要 > 0 就能唤醒,业界惯例写 1。但 eventfd 对 write 有几条硬规则:
| 写入情况 | 结果 |
|---|---|
写 0 |
系统调用成功,但计数器没变化,不唤醒 |
写 1 ~ 0xFFFFFFFFFFFFFFFE |
累加到计数器,正常工作 |
写 UINT64_MAX (0xFFFFFFFFFFFFFFFF) |
返回 EINVAL(内核硬限制) |
| 字节数不是 8 字节 | 返回 EINVAL(必须按 uint64_t 读写) |
为什么大家写 1:最简、意图清晰、绝不会触发计数器溢出。
定时器:timerfd + epoll
类似地,timerfd 把定时器变成一个 fd,到点了 fd 变可读,epoll 就能统一管理"周期任务"和"IO 事件"。
#include <sys/timerfd.h>
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
struct itimerspec spec = {0};
spec.it_interval.tv_sec = 0;
spec.it_interval.tv_nsec = 5 * 1000 * 1000; // 5ms 周期
spec.it_value = spec.it_interval; // 5ms 后第一次触发
timerfd_settime(tfd, 0, &spec, NULL);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.u32 = kEventTimer;
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);
// 定时器触发时:read 出"过期次数",不读的话 fd 会一直可读
uint64_t expirations;
read(tfd, &expirations, sizeof(expirations));
产品常用的结构:统一事件循环
把 timerfd(周期任务)+ eventfd(异步唤醒)+ socket fd(数据 IO)全部塞进一个 epoll,是 Linux 实时控制、网络服务、消息中间件的经典架构。下面是一个抽象出来的通用模板:
class EventLoop {
public:
// 事件源标签
enum EventTag : uint32_t {
kTagTimer = 1, // 周期任务
kTagWake = 2, // 外部唤醒
kTagBusA = 3, // 业务 IO 通道 A
kTagBusB = 4, // 业务 IO 通道 B
};
bool setup() {
epoll_fd_ = ::epoll_create1(EPOLL_CLOEXEC);
timer_fd_ = ::timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
event_fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
// 注册周期定时器(如 5ms 一次)
arm_timer(timer_fd_, std::chrono::milliseconds(5));
add_fd(timer_fd_, kTagTimer, EPOLLIN);
// 注册唤醒 fd
add_fd(event_fd_, kTagWake, EPOLLIN);
// 注册业务 IO fd(边缘触发,同时监听收发)
add_fd(bus_a_fd_, kTagBusA, EPOLLIN | EPOLLOUT | EPOLLET);
add_fd(bus_b_fd_, kTagBusB, EPOLLIN | EPOLLOUT | EPOLLET);
return true;
}
void run() {
while (running_.load(std::memory_order_acquire)) {
epoll_event events[8]{};
int n = ::epoll_wait(epoll_fd_, events, 8, /*timeout_ms=*/50);
if (n < 0) {
if (errno == EINTR) continue;
break;
}
for (int i = 0; i < n; ++i) {
dispatch(events[i]);
}
}
}
// 其他线程调用此方法立即唤醒事件循环
void notify() {
uint64_t v = 1;
::write(event_fd_, &v, sizeof(v));
}
private:
bool add_fd(int fd, uint32_t tag, uint32_t events) {
epoll_event ev{};
ev.events = events;
ev.data.u32 = tag;
return ::epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev) == 0;
}
void dispatch(const epoll_event& ev) {
switch (ev.data.u32) {
case kTagTimer:
handle_timer(); // 周期任务:心跳、状态检查、超时清理
break;
case kTagWake:
handle_wake(); // 外部唤醒:处理命令队列
break;
case kTagBusA:
if (ev.events & EPOLLIN) handle_rx(bus_a_fd_);
if (ev.events & EPOLLOUT) handle_tx(bus_a_fd_);
break;
case kTagBusB:
if (ev.events & EPOLLIN) handle_rx(bus_b_fd_);
if (ev.events & EPOLLOUT) handle_tx(bus_b_fd_);
break;
}
}
int epoll_fd_{-1}, timer_fd_{-1}, event_fd_{-1};
int bus_a_fd_{-1}, bus_b_fd_{-1};
std::atomic<bool> running_{true};
};
设计要点:
- 三类事件源统一调度:定时器周期任务、异步命令唤醒、底层 IO 收发,全部归一到一个事件循环里,避免多线程竞争
data.u32当路由标签:用枚举常量区分事件源,switch 分发清晰- ET 模式 + 非阻塞 IO:性能敏感的数据通道用 ET,搭配
EPOLLIN | EPOLLOUT同时监听收发 - eventfd 实现低延迟响应:外部线程下发命令后立刻
notify(),IO 线程不必等到下一个定时周期才响应 - 超时兜底:
epoll_wait给个 50ms 超时,即使没事件也能定期循环检查running_标志,便于优雅停止
这个骨架可以套用在很多场景:网络服务器(accept fd + client fd + 控制 fd)、设备驱动用户态守护进程(device fd + timer + signalfd)、消息队列消费者(socket + 定时刷新 + 命令通道)等。
九、常见坑总结
| 坑 | 解决方案 |
|---|---|
| ET 模式只读一次,剩下数据再也收不到 | 必须 while (read() != EAGAIN) 循环读空 |
| ET 模式用阻塞 fd 卡死 | ET 必须配合 O_NONBLOCK |
| eventfd 写完不读,CPU 100% 空转 | handle_event 里必须 read 清空计数器 |
close(fd) 后忘记 EPOLL_CTL_DEL |
一般 close 自动移除,但 fd 被 dup 过时需要手动删 |
信号中断导致 epoll_wait 返回 -1 |
检查 errno == EINTR 后 continue |
| 多线程同时操作同一个 epoll fd | epoll 本身线程安全,但要注意事件分发的竞争 |
EPOLLOUT 一直触发导致 CPU 空转 |
socket 一旦可写就会一直触发 EPOLLOUT;没数据要发时应该 EPOLL_CTL_MOD 取消 EPOLLOUT |
整体架构回顾
┌──────────────────────────┐
│ epoll 实例 (epoll_fd) │
│ │
│ 红黑树管理注册的 fd │
│ ┌──────────────┐ │
│ │ timer_fd │──┐ │
│ │ event_fd │──┤ │
│ │ socket_fd_1 │──┼─► 就绪链表 (rdllist)
│ │ socket_fd_2 │──┤ │
│ │ ... │──┘ │
│ └──────────────┘ │
└──────────────┬───────────┘
│
epoll_wait() 取就绪
│
▼
┌──────────────────────────┐
│ events[N] │
│ ├─ events (位掩码) │
│ └─ data.u32 (路由标签) │
└──────────────┬───────────┘
│
▼
switch (data.u32) {
case kEventTimer: ...
case kEventWake: ...
case kEventSocket: ...
}
相关文档
man 7 epoll— 系统级权威文档man 2 eventfd/man 2 timerfd_create- Linux 内核源码:
fs/eventpoll.c - libuv、libevent、asio 的 epoll 后端实现
核心:epoll 是一个"事件总线",所有能变成 fd 的东西(socket、定时器、信号、用户事件、文件、inotify、signalfd…)都可以塞进来统一调度,其中的fd正好是linux编程的精髓
更多推荐



所有评论(0)