Table of contents
Open Table of contents
- TL;DR
- 1. Why:select/poll 为什么不够
- 2. 内核实现原理
- 3. 核心 API
- 4. 事件标志(events flags)
- 5. LT vs ET(核心章节)
- 6. 惊群问题(Thundering Herd)
- 7. C++17 可运行代码示例
- 8. Pitfalls(深度版)
- 8.1 ET 下忘记循环 read 到 EAGAIN(数据卡住)
- 8.2 ET 下用阻塞 fd(死锁线程)
- 8.3 EPOLLOUT 忘了取消注册(CPU 100%)
- 8.4 只监听 EPOLLIN 不监听 EPOLLRDHUP(关闭检测延迟)
- 8.5 EPOLLERR / EPOLLHUP 不处理(fd 泄漏 + CPU 爆炸)
- 8.6 close 前没 DEL,且 fd 被 dup 过(最精微的坑)
- 8.7 多线程竞争 handler 状态(UAF)
- 8.8 data.fd 和 data.ptr 混用
- 8.9 LT 下忘了 MOD 取消不再关心的事件
- 8.10 timeout 精度
- 8.11 普通文件不支持 epoll(EPERM)
- 8.12
epoll_wait返回 == maxevents 暗示可能还有更多
- 9. 与 select / poll / kqueue / io_uring 对比
- 10. 生产 Checklist
- 参考
TL;DR
epoll 把 select/poll 的 O(n) 轮询重构为「注册一次 + 设备就绪时回调插入链表」的事件驱动模型;正确使用的核心是 ET 必须配非阻塞 + 循环到 EAGAIN、EPOLLERR/EPOLLHUP 无需请求但必须处理、close 前必须显式 EPOLL_CTL_DEL(防 dup 场景的悬垂监听)。
常见误解澄清:epoll 性能好不是因为 mmap 共享内存。
epoll_wait使用copy_to_user把就绪事件拷贝给用户态,和普通 syscall 一致。
1. Why:select/poll 为什么不够
select(nfds, fd_set*, ...) 的签名注定它无法扩展:
- 每次调用都传全量 fd 集合 — 用户态到内核态 O(n) 拷贝
- 内核内部轮询所有 fd —
for (每个 fd) { f_op->poll(fd) },即便只有 1 个就绪也要扫完 - 返回后用户态还要 O(n) 扫 fd_set —
FD_ISSET查每个 fd FD_SETSIZE = 1024硬上限(glibc 默认)
C10K 的本质:n 很大但稀疏就绪(10000 连接,每秒 100 个活动)时,O(n) 扫描浪费了 99% 的 CPU。
epoll 的重构不是把 O(n) 改成 O(log n),而是从轮询模型转向回调驱动:
epoll_ctl(ADD/MOD/DEL)注册阶段——一次性告诉内核关心哪些 fd,存进红黑树epoll_wait查询阶段——只读就绪链表,fd 就绪是由设备驱动通过 wakeup callback 主动推进来的
借 Jonathan Corbet 在 LWN 的话:“epoll is not a faster poll; it’s a different beast.”
2. 内核实现原理
2.1 struct eventpoll 的核心字段
每个 epoll 实例对应内核 fs/eventpoll.c 中的 struct eventpoll:
| 字段 | 作用 |
|---|---|
rbr(红黑树) | 所有被注册的 fd → epitem 节点 |
rdllist(就绪链表) | 当前处于就绪状态的 epitem |
wq(等待队列) | 调用 epoll_wait 阻塞的进程 |
ovflist | 扫描 rdllist 时新到事件的临时溢出链 |
每个被监听的 fd 对应一个 epitem,同时挂在 rbr 和(就绪时)rdllist 上。
2.2 为什么 epoll_wait 是 O(1)
关键:epoll_wait 只扫 rdllist,不扫 rbr。
rbr存所有监听的 fd(10000 个)rdllist只存当前就绪的 fd(比如 3 个)- 扫描复杂度 =
|rdllist|,与|rbr|无关
对比 select/poll:必须扫描全部注册的 fd(相当于 epoll 的 rbr)。
2.3 wakeup callback 机制
注册阶段(ep_insert → ep_ptable_queue_proc):
- epoll 调用目标 fd 的
f_op->poll(file, &pt) - 这个 poll 方法内部
poll_wait(file, &fd 的等待队列, pt) - epoll 提供的
pt->_qproc回调把一个eppoll_entry(回调函数 =ep_poll_callback)挂到目标 fd 的等待队列
触发阶段(数据到达):
- 网卡中断 → 协议栈 → TCP 收到新数据 → 唤醒 socket 等待队列
- 内核遍历队列里的 entry,调用它们的 wakeup 函数
- epoll 的 entry 调用
ep_poll_callback:把 epitem 挂进rdllist、唤醒wq中阻塞的进程
没有任何轮询,纯粹由 I/O 事件驱动。这就是 epoll 相对 select/poll 的真正价值。
2.4 mmap 是常见误解
网上大量中文资料说”epoll 快是因为 epoll_wait 用 mmap 共享内存”——错的。查 fs/eventpoll.c 的 ep_send_events:使用 __put_user / copy_to_user 把 events 从内核拷到用户提供的数组,用户数组是普通内存。
epoll 快的真正原因只有两个:O(1) 就绪链表 + 回调驱动。
2.5 epoll 实例也是 fd(嵌套)
epoll_create1() 返回的 fd 有自己的 f_op->poll(ep_eventpoll_poll),所以可以被另一个 epoll 监听。内核限制嵌套深度 EP_MAX_NESTS = 4,epoll_ctl 时通过 ep_loop_check 检测环路。
3. 核心 API
3.1 创建 / 销毁
#include <sys/epoll.h>
int epoll_create1(int flags); // Linux 2.6.27+
// 老接口(size 自 2.6.8 起被忽略):
int epoll_create(int size);
必须用 epoll_create1(EPOLL_CLOEXEC),在启动新程序的子进程场景防 fd 泄漏。
3.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
EPOLL_CTL_ADD— 注册;fd 已存在返EEXISTEPOLL_CTL_MOD— 修改 events / data;未注册返ENOENTEPOLL_CTL_DEL— 移除;event 参数忽略(2.6.9 之前不能为 NULL)
struct epoll_event {
uint32_t events;
epoll_data_t data; // union { void *ptr; int fd; uint32_t u32; uint64_t u64; }
};
为什么 struct epoll_event 在 Linux 下是 __attribute__((packed)):在 x86_64 下如不 packed,uint32_t events 后的 union 会有 4 字节对齐填充让 struct 大小为 16 字节;而 i386 下只有 12 字节。packed 让二者一致为 12 字节,保证 x86_64 内核处理 32-bit 程序调用时 ABI 兼容。
重要 errno:EPERM — 目标 fd 不支持 epoll。最典型是 regular file(见第 8 节 pitfall)。
3.3 epoll_wait / epoll_pwait / epoll_pwait2
int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);
int epoll_pwait (int epfd, struct epoll_event *events, int maxevents, int timeout,
const sigset_t *sigmask);
int epoll_pwait2(int epfd, struct epoll_event *events, int maxevents,
const struct timespec *timeout, const sigset_t *sigmask); // 5.11+
timeout 语义:
-1永久阻塞0立即返回(非阻塞 poll)> 0毫秒超时,上限INT_MAXms ≈ 24.8 天
epoll_pwait2(5.11+) 使用 timespec 提供纳秒精度,解决 int 毫秒的精度问题。
返回值:> 0 就绪数,0 超时,-1 错误(EINTR 必须循环重试)。
4. 事件标志(events flags)
| 标志 | 方向 | 需显式请求 | 最低内核 | 关键点 |
|---|---|---|---|---|
EPOLLIN | 输入 | 是 | 2.6 | 可读 |
EPOLLOUT | 输出 | 是 | 2.6 | 可写 |
EPOLLRDHUP | 输入 | 是 | 2.6.17 | 对端 close 或 shutdown(SHUT_WR) |
EPOLLPRI | 输入 | 是 | 2.6 | TCP OOB |
EPOLLERR | — | 否,始终上报 | 2.6 | fd 错误 |
EPOLLHUP | — | 否,始终上报 | 2.6 | fd 双向挂起 |
EPOLLET | 修饰 | 是 | 2.6 | 边缘触发 |
EPOLLONESHOT | 修饰 | 是 | 2.6.2 | 触发一次后禁用,需 MOD 重武装 |
EPOLLEXCLUSIVE | 修饰 | 是 | 4.5+ | 多 waiter 惊群:只唤一个;仅 ADD |
EPOLLWAKEUP | 修饰 | 是 | 3.5+ | 阻止系统 suspend,需 CAP_BLOCK_SUSPEND |
EPOLLRDHUP 的精确语义
man epoll(7):“Stream socket peer closed connection, or shut down writing half of connection.”
- 对端
close()→EPOLLRDHUP + EPOLLIN(read 返回 0)+ 可能EPOLLHUP - 对端
shutdown(SHUT_WR)→EPOLLRDHUP + EPOLLIN,但本端仍可 write,不会有EPOLLHUP
关键差异:EPOLLHUP = 双向都挂;EPOLLRDHUP = 对端不再写。
EPOLLERR / EPOLLHUP 的致命陷阱
这两个事件无需请求也会返回。事件循环里如果只判 events & EPOLLIN 不兜底,错误 fd 会每次 epoll_wait 都返回就绪,CPU 原地爆炸、fd 永远泄漏。
所有生产代码事件循环第一步必须先判 EPOLLERR | EPOLLHUP。
5. LT vs ET(核心章节)
5.1 本质定义
| 模式 | 触发条件 |
|---|---|
| LT (Level Triggered) | 只要 fd 处于就绪状态,每次 epoll_wait 都返回它 |
| ET (Edge Triggered) | 只在 fd 从「未就绪」变为「就绪」的那一刻通知一次 |
ET 是”状态变化”,不是”新数据到达”——这是最常见的误解。
5.2 为什么 ET 必须配非阻塞 I/O
阻塞 fd + ET:
epoll_wait返回 EPOLLIN- 你
read(fd, buf, N)读到 N 字节 - 缓冲区里还有数据,但你以为读完了,调
epoll_wait - 没有”未就绪 → 就绪”的状态变化,ET 不再通知
- 下次主动
read,缓冲区空 → 阻塞 fd 永久卡死线程
LT 没这个问题,因为只要缓冲区有数据它就一直通知。
5.3 ET 的”状态不变”陷阱(最精微)
T1: 缓冲区 100B,fd 变为就绪 → ET 通知
T2: 应用读 50B,缓冲区还剩 50B
T3: 对端又发 100B,缓冲区变 150B
T3 时 fd 的就绪状态没变(一直”有数据可读”),ET 不会再次通知。应用如果 T2 停手等 epoll,就永远卡住。
所以 ET 下 read 必须循环到 EAGAIN——把缓冲区读空让状态变为”未就绪”,之后新数据才会触发”未就绪 → 就绪”。
5.4 ET 下的正确写法
accept 循环:
while (true) {
int client = accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (client == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
perror("accept4"); break;
}
register_client(client);
}
read 循环:
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { process(buf, n); continue; }
if (n == 0) { close_conn(fd); return; } // 对端关
if (errno == EAGAIN) break; // 读空
if (errno == EINTR) continue;
close_conn(fd); return; // 真错误
}
write 循环 + EPOLLOUT 动态注册(关键):
// 首次 write
ssize_t n = write(fd, buf, len);
if (n < (ssize_t)len) {
save_pending(fd, buf + n, len - n);
epoll_mod(fd, EPOLLIN | EPOLLOUT | EPOLLET); // 挂 EPOLLOUT
}
// EPOLLOUT 触发时消费 pending
while (has_pending(fd)) {
ssize_t n = write(fd, pending.data(), pending.size());
if (n < 0 && errno == EAGAIN) return;
consume_pending(fd, n);
}
// 写完必须取消 EPOLLOUT,否则 LT 下 CPU 100%
epoll_mod(fd, EPOLLIN | EPOLLET);
5.5 EPOLLONESHOT vs ET 在多线程的取舍
| ET | EPOLLONESHOT | |
|---|---|---|
| 通知次数 | 状态变化时一次 | 触发一次后禁用 |
| 多线程安全性 | 多个线程 epoll_wait 同一 epoll 时仍可能重复收到同一事件 | 内核保证同一 fd 不会被多个 waiter 同时拿到 |
| 重武装 | 不需要 | 必须 EPOLL_CTL_MOD 重武装 |
多线程 worker pool 标准模式:主线程 epoll_wait → dispatch 给 worker → worker 处理完 EPOLL_CTL_MOD(EPOLLIN | EPOLLONESHOT) 重武装。保证同一 fd 任意时刻只被一个 worker 持有,比 ET + 手动加锁更安全。
6. 惊群问题(Thundering Herd)
两种惊群
- accept 惊群(多进程 accept 同一 listen fd):Linux 2.6 起 accept 系统调用层面已不惊群
- epoll 惊群(多 waiter epoll_wait 同一 epoll):listen fd 就绪时所有等待者被唤醒,但只有一个能 accept 成功——2.6 没解决
三方案对比
| 方案 | 版本 | 机制 | 负载均衡 | 限制 |
|---|---|---|---|---|
| EPOLLEXCLUSIVE | 4.5+ | 内核:fd 就绪只唤一个 waiter | 一般(按唤醒顺序) | 仅 ADD,不能 MOD;不能嵌套 epoll |
| SO_REUSEPORT | 3.9+ | 每个 worker 独立 listen fd,内核按四元组 hash 分发 | 优秀 | worker 数固定 |
| accept_mutex(Nginx 老做法) | 任意 | 应用层 mutex | 较差(饥饿风险) | 纯用户态,延迟高 |
实战推荐:Linux 4.5+ 优先 SO_REUSEPORT——每 worker 独立 listen fd 和独立 epoll,无锁、无惊群、CPU 亲和性好。Nginx 从 1.9.1 起默认支持 reuseport。
7. C++17 可运行代码示例
7.1 FdGuard(所有示例共享的 RAII)
// fd_guard.hpp
#pragma once
#include <unistd.h>
#include <utility>
class FdGuard {
int fd_{-1};
public:
FdGuard() = default;
explicit FdGuard(int fd) noexcept : fd_(fd) {}
~FdGuard() { if (fd_ >= 0) ::close(fd_); }
FdGuard(const FdGuard&) = delete;
FdGuard& operator=(const FdGuard&) = delete;
FdGuard(FdGuard&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
FdGuard& operator=(FdGuard&& o) noexcept {
if (this != &o) { if (fd_ >= 0) ::close(fd_); fd_ = std::exchange(o.fd_, -1); }
return *this;
}
int get() const noexcept { return fd_; }
int release() noexcept { return std::exchange(fd_, -1); }
explicit operator bool() const noexcept { return fd_ >= 0; }
};
7.2 LT 模式 echo server(最小可用)
编译:g++ -std=c++17 -Wall -Wextra -O2 echo_lt.cpp -o echo_lt
#include "fd_guard.hpp"
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
constexpr int PORT = 9001;
constexpr int MAX_EVENTS = 64;
static void die(const char* m) { perror(m); std::exit(1); }
int main() {
FdGuard listen_fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0));
if (!listen_fd) die("socket");
int yes = 1;
::setsockopt(listen_fd.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (::bind(listen_fd.get(), (sockaddr*)&addr, sizeof(addr)) < 0) die("bind");
if (::listen(listen_fd.get(), SOMAXCONN) < 0) die("listen");
FdGuard epfd(::epoll_create1(EPOLL_CLOEXEC));
if (!epfd) die("epoll_create1");
epoll_event ev{};
ev.events = EPOLLIN; // LT 模式
ev.data.fd = listen_fd.get();
if (::epoll_ctl(epfd.get(), EPOLL_CTL_ADD, listen_fd.get(), &ev) < 0) die("epoll_ctl");
epoll_event events[MAX_EVENTS];
char buf[4096];
for (;;) {
int n = ::epoll_wait(epfd.get(), events, MAX_EVENTS, -1);
if (n < 0) { if (errno == EINTR) continue; die("epoll_wait"); }
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
uint32_t e = events[i].events;
// 必须先处理错误
if (e & (EPOLLERR | EPOLLHUP)) {
::epoll_ctl(epfd.get(), EPOLL_CTL_DEL, fd, nullptr);
::close(fd);
continue;
}
if (fd == listen_fd.get()) {
// 即便 LT 也建议循环 accept 清空 backlog
for (;;) {
int c = ::accept4(listen_fd.get(), nullptr, nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (c < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
perror("accept4"); break;
}
epoll_event cev{};
cev.events = EPOLLIN | EPOLLRDHUP;
cev.data.fd = c;
if (::epoll_ctl(epfd.get(), EPOLL_CTL_ADD, c, &cev) < 0) {
perror("epoll_ctl"); ::close(c);
}
}
} else if (e & (EPOLLIN | EPOLLRDHUP)) {
ssize_t r = ::read(fd, buf, sizeof(buf));
if (r > 0) {
ssize_t off = 0;
while (off < r) {
ssize_t w = ::write(fd, buf + off, r - off);
if (w < 0) { if (errno == EINTR) continue; break; }
off += w;
}
} else {
::epoll_ctl(epfd.get(), EPOLL_CTL_DEL, fd, nullptr);
::close(fd);
}
}
}
}
}
7.3 ET 模式的正确实现(带写缓冲管理)
编译:g++ -std=c++17 -Wall -Wextra -O2 echo_et.cpp -o echo_et
#include "fd_guard.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <deque>
#include <unordered_map>
#include <vector>
struct Conn {
int fd;
std::deque<std::vector<char>> out; // 待发送
bool writable = true;
};
static std::unordered_map<int, Conn> g_conns;
static int g_epfd = -1;
static void mod_events(int fd, uint32_t ev) {
epoll_event e{};
e.events = ev;
e.data.fd = fd;
::epoll_ctl(g_epfd, EPOLL_CTL_MOD, fd, &e);
}
static void close_conn(int fd) {
::epoll_ctl(g_epfd, EPOLL_CTL_DEL, fd, nullptr); // 务必先 DEL 再 close
::close(fd);
g_conns.erase(fd);
}
static void try_write(Conn& c) {
while (!c.out.empty()) {
auto& front = c.out.front();
ssize_t n = ::write(c.fd, front.data(), front.size());
if (n < 0) {
if (errno == EAGAIN) { c.writable = false; return; }
if (errno == EINTR) continue;
close_conn(c.fd); return;
}
if ((size_t)n < front.size()) {
front.erase(front.begin(), front.begin() + n);
c.writable = false;
return;
}
c.out.pop_front();
}
mod_events(c.fd, EPOLLIN | EPOLLRDHUP | EPOLLET); // 写完取消 EPOLLOUT
}
static void handle_read(Conn& c) {
char buf[4096];
for (;;) {
ssize_t r = ::read(c.fd, buf, sizeof(buf));
if (r > 0) { c.out.emplace_back(buf, buf + r); continue; }
if (r == 0) { close_conn(c.fd); return; }
if (errno == EAGAIN) break;
if (errno == EINTR) continue;
close_conn(c.fd); return;
}
if (!c.out.empty()) {
if (c.writable) try_write(c);
if (!c.out.empty()) mod_events(c.fd, EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET);
}
}
int main() {
FdGuard lfd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0));
int yes = 1;
::setsockopt(lfd.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_ANY);
a.sin_port = htons(9002);
::bind(lfd.get(), (sockaddr*)&a, sizeof(a));
::listen(lfd.get(), SOMAXCONN);
FdGuard ep(::epoll_create1(EPOLL_CLOEXEC));
g_epfd = ep.get();
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET; // listen fd 也用 ET
ev.data.fd = lfd.get();
::epoll_ctl(g_epfd, EPOLL_CTL_ADD, lfd.get(), &ev);
epoll_event events[128];
for (;;) {
int n = ::epoll_wait(g_epfd, events, 128, -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 e = events[i].events;
if (fd == lfd.get()) {
// ET 下必须循环 accept
for (;;) {
int c = ::accept4(lfd.get(), nullptr, nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (c < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
perror("accept4"); break;
}
g_conns.emplace(c, Conn{c});
epoll_event cev{};
cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
cev.data.fd = c;
::epoll_ctl(g_epfd, EPOLL_CTL_ADD, c, &cev);
}
continue;
}
auto it = g_conns.find(fd);
if (it == g_conns.end()) continue;
Conn& c = it->second;
if (e & (EPOLLERR | EPOLLHUP)) { close_conn(fd); continue; }
if (e & EPOLLOUT) { c.writable = true; try_write(c); }
if (e & (EPOLLIN | EPOLLRDHUP)) handle_read(c);
}
}
}
7.4 EpollReactor:用 data.ptr 做类型安全分发
// epoll_reactor.hpp
#pragma once
#include "fd_guard.hpp"
#include <sys/epoll.h>
#include <cerrno>
#include <functional>
#include <memory>
#include <system_error>
#include <unordered_map>
#include <vector>
class EpollReactor {
public:
using Handler = std::function<void(uint32_t events)>;
EpollReactor() : ep_(::epoll_create1(EPOLL_CLOEXEC)) {
if (!ep_) throw std::system_error(errno, std::system_category(), "epoll_create1");
}
void add(int fd, uint32_t events, Handler h) {
auto hp = std::make_shared<Handler>(std::move(h));
epoll_event ev{};
ev.events = events;
ev.data.ptr = hp.get();
if (::epoll_ctl(ep_.get(), EPOLL_CTL_ADD, fd, &ev) < 0)
throw std::system_error(errno, std::system_category(), "EPOLL_CTL_ADD");
handlers_[fd] = std::move(hp);
}
void mod(int fd, uint32_t events) {
auto it = handlers_.find(fd);
if (it == handlers_.end()) throw std::runtime_error("mod: fd not registered");
epoll_event ev{};
ev.events = events;
ev.data.ptr = it->second.get();
if (::epoll_ctl(ep_.get(), EPOLL_CTL_MOD, fd, &ev) < 0)
throw std::system_error(errno, std::system_category(), "EPOLL_CTL_MOD");
}
void del(int fd) {
::epoll_ctl(ep_.get(), EPOLL_CTL_DEL, fd, nullptr); // 必须 DEL 再 close
handlers_.erase(fd);
}
int run_once(int timeout_ms, int maxevents = 64) {
std::vector<epoll_event> evs(maxevents);
int n = ::epoll_wait(ep_.get(), evs.data(), maxevents, timeout_ms);
if (n < 0) {
if (errno == EINTR) return 0;
throw std::system_error(errno, std::system_category(), "epoll_wait");
}
for (int i = 0; i < n; ++i) {
auto* h = static_cast<Handler*>(evs[i].data.ptr);
if (h && *h) (*h)(evs[i].events);
}
return n;
}
private:
FdGuard ep_;
std::unordered_map<int, std::shared_ptr<Handler>> handlers_;
};
8. Pitfalls(深度版)
8.1 ET 下忘记循环 read 到 EAGAIN(数据卡住)
只读一次会留残余数据。状态一直是”就绪”,ET 不再通知;新数据到达时状态仍然没”未就绪→就绪”,也不触发。
8.2 ET 下用阻塞 fd(死锁线程)
见 5.2:read 读空后第二次 read 阻塞整个事件循环。
8.3 EPOLLOUT 忘了取消注册(CPU 100%)
socket 写缓冲通常空闲可写。LT 模式下注册 EPOLLOUT 但没数据要发、又不 MOD 掉,epoll_wait 立刻返回,循环变忙转。有数据才注册 EPOLLOUT,发完立刻 MOD 取消。
8.4 只监听 EPOLLIN 不监听 EPOLLRDHUP(关闭检测延迟)
对端 close 时仅 EPOLLIN 只能靠下次 read 返回 0 检测,中间可能已发送无用响应。加上 EPOLLRDHUP 能立即判定。
8.5 EPOLLERR / EPOLLHUP 不处理(fd 泄漏 + CPU 爆炸)
这俩无需请求即始终上报。事件循环不处理 → 错误 fd 每轮都返回 → CPU 100% + fd 永远泄漏。事件循环第一步必须先判这两个。
8.6 close 前没 DEL,且 fd 被 dup 过(最精微的坑)
Linux close(fd) 会自动从所有 epoll 移除 fd——前提是这是最后一个指向该 struct file 的 fd。如果 fd 被 dup / fork 过,close 只减引用计数,底层 file 还活着,epoll 仍持有监听:
- 你以为删了,实际没删
- 同一 file 的事件继续触发
- 用户态 handler 可能已释放 → 悬垂回调 → 崩溃
铁律:永远在 close 之前显式 EPOLL_CTL_DEL。
8.7 多线程竞争 handler 状态(UAF)
epoll_ctl 和 epoll_wait 自身线程安全,但:
- A 线程 DEL fd → 释放 handler
- B 线程刚从 epoll_wait 拿到该 fd → handler 已释放 → UAF
解决:shared_ptr 管理(见 7.4),或 EPOLLONESHOT + 单主线程 epoll_wait + worker 只消费任务。
8.8 data.fd 和 data.ptr 混用
epoll_data_t 是 union:设了 data.ptr 再读 data.fd 会把指针低 32 位当 fd 用。一个 epoll 实例里统一用一种,封装到 Reactor 屏蔽。
8.9 LT 下忘了 MOD 取消不再关心的事件
读完 HTTP request 进入”写响应”状态时,若不 MOD 掉 EPOLLIN,对端 pipeline 发来新请求会触发 EPOLLIN,状态机错乱。
8.10 timeout 精度
int 毫秒最大 24.8 天。纳秒精度或超长超时用 epoll_pwait2(5.11+)。
8.11 普通文件不支持 epoll(EPERM)
Regular file 对 poll 永远返回”就绪”(read 永远不阻塞,即便 EOF 也立即返回 0)。内核对此返回 EPERM。
能被 epoll 监听的 fd:socket、pipe、FIFO、eventfd、signalfd、timerfd、inotify fd、另一个 epoll fd、tty、部分 character device。
想监听日志文件变化 → 用 inotify_init1() 得到 inotify fd,再把这个 fd 加入 epoll。
8.12 epoll_wait 返回 == maxevents 暗示可能还有更多
内核每次最多返回 maxevents 个。传 1 则最多 1 个。生产设 64 或 128。返回值等于 maxevents 时下一轮 wait(timeout=0) 可立即取剩余。
9. 与 select / poll / kqueue / io_uring 对比
| 维度 | select | poll | epoll | kqueue | io_uring |
|---|---|---|---|---|---|
| 系统 | POSIX | POSIX | Linux 2.6+ | BSD/macOS | Linux 5.1+ |
| 注册/查询分离 | 否 | 否 | 是 | 是 | 是 |
| 时间复杂度 | O(n) | O(n) | O(1) 就绪扫描 | O(1) | O(1) |
| fd 上限 | 1024 (FD_SETSIZE) | 无硬上限 | RLIMIT_NOFILE | 无硬上限 | 无硬上限 |
| 触发模式 | LT | LT | LT + ET | LT + ET | N/A |
| regular file | 是(立即就绪) | 是 | 否(EPERM) | 是 | 是(真正异步) |
| 异步 I/O | 否 | 否 | 否(仅通知) | 否 | 是 |
| 典型场景 | 兼容性 | 兼容性 | Linux C10K+ 主流 | BSD C10K+ | 磁盘 + 网络真正异步 |
要点:
- epoll 的价值是”就绪通知”不是”异步 I/O”。它告诉你”可以 read 了”,但 read 本身仍是同步的。
- io_uring 才是 Linux 真正的异步 I/O(把 read/write/accept 本身异步化),但 API 复杂度显著更高,5.x 早期多个 CVE,生产部署要评估。
- epoll 至今仍是 Linux 网络服务的主流(Nginx、Envoy、Redis、Node.js 的 libuv)。
10. 生产 Checklist
- 事件循环第一步处理
EPOLLERR | EPOLLHUP - 监听
EPOLLRDHUP,不靠 read 返 0 延迟检测 - ET 模式下所有 I/O 非阻塞(listen/accept/read/write)
- ET 模式下所有 read/accept while until EAGAIN
- write 没写完才注册 EPOLLOUT;写完 MOD 取消
- fd close 顺序严格
EPOLL_CTL_DEL→close(防 dup 场景) - Linux ≥ 4.5 惊群场景:
EPOLLEXCLUSIVE;Linux ≥ 3.9 优先SO_REUSEPORT多 worker 独立 listen -
epoll_create1(EPOLL_CLOEXEC)/socket(... | SOCK_NONBLOCK | SOCK_CLOEXEC)/accept4(... SOCK_NONBLOCK | SOCK_CLOEXEC) -
data.ptr指向的 handler 生命周期显式绑定 fd 生命周期(shared_ptr 或 arena) - 多线程:主线程独占 epoll_wait + worker pool,或 EPOLLONESHOT 保互斥
-
maxevents设 64~128;返回值 == maxevents 时立即再 wait 一次 - 被监听的 fd 不是 regular file(要监听文件变化用 inotify)
- timeout > 24 天或需纳秒精度:用
epoll_pwait2(Linux 5.11+) -
epoll_wait返 -1 且errno == EINTR时 continue
参考
- epoll(7) — LT/ET 权威定义与示例
- epoll_create1(2) / epoll_ctl(2) / epoll_wait(2) / epoll_pwait2(2)
- Linux 内核源码
fs/eventpoll.c - LWN.net: “Epoll Scalability Improvements”、“The new epoll_pwait2() system call”(2021)
- 内核 commit:
EPOLLEXCLUSIVE(4.5,df0108c5da56)、epoll_pwait2(5.11,c857ab640c64)
socket / TCP 层行为见 C++ socket 编程手册。