epoll底层原理与select-poll对比
本文深入浅出地解释了epoll的高效原理及其底层实现。通过快递柜的类比,形象说明了epoll相比select/poll的优势在于"注册制+回调通知"机制,避免了无效轮询。文章详细剖析了epoll的三个核心数据结构(红黑树、就绪链表、等待队列)的协作流程,对比了三种I/O多路复用技术的性能差异,并重点讲解了ET/LT模式的区别与使用场景。最后提供了epoll服务器的实现代码框架和
epoll 到底快在哪?从快递柜讲到底层实现
本文适合写过 socket 编程、用过或听说过 select/poll,但不太清楚 epoll 底层为什么快的新手后端开发者。读完你能理解 epoll 的三数据结构设计、回调机制、ET/LT 区别,并手写一个能跑的 epoll 服务器。预计 20 分钟。
一、痛点:当你有 10000 个连接,但每秒只有 5 个活跃
你写了一个聊天服务器,用 select 管理所有客户端连接。最开始 10 个用户时一切正常,CPU 使用率 2%。后来用户涨到 5000,CPU 飙到 80%——但明明只有十几个人在同时发消息。
问题出在哪?
select 每次调用都要把全部 5000 个 fd 从用户态拷贝到内核态,内核再挨个遍历 5000 个 fd 去检查"你有没有数据?"——哪怕其中 4990 个根本没动静。
poll 好一点,没有 1024 的数量上限,但本质上一样:O(n) 遍历 + 每次重新拷贝。
这就是 epoll 要解决的问题:如何在大量空闲连接中,只关注少数活跃的那几个?
二、核心思想:从"挨家挨户敲门"到"快递柜"
一个类比讲清楚三种方案
想象你是快递员,负责 1000 个客户,每天早上要知道"谁今天有包裹要寄"。
| 方案 | 做法 | 工作量 |
|---|---|---|
| select | 每天骑车从 1 号到 1000 号,挨家敲门问"你今天要寄吗?" | 与总客户数成正比 |
| poll | 换了大车(没了数量上限),但还是挨家敲门问 | 与总客户数成正比 |
| epoll | 在小区门口放快递柜,客户要寄自己放进去,你只去看柜子里有没有 | 只与有包裹的客户数成正比 |
回到技术:epoll 用"注册制 + 回调通知"替代了"轮询"。你把要盯的 fd 注册一次,数据到了内核主动通知你——你不用反复去问。
三、底层三件套:红黑树、就绪链表、等待队列
epoll 在内核里由一个 eventpoll 结构体管着,里面三样东西各司其职:
struct eventpoll {
红黑树 rbr; // "花名册"——存所有被监听的 fd
就绪链表 rdllist; // "待取清单"——只存放已经有数据的 fd
等待队列 wq; // "闹钟"——没事件时,epoll_wait 的进程睡在这
};
它们是怎么协作的?
第一步:epoll_ctl(ADD) — 登记客户
你把 fd=3, 4, 5, 6 注册给 epoll。内核做的事:
- 每个 fd 做成一个
epitem(登记条目),插入红黑树(增删查 O(log n)) - 在对应 socket 上挂一个回调函数
ep_poll_callback——这步最关键 - 检查这个 socket 现在有没有数据?有就立刻挂进就绪链表;没有就等着
第二步:数据到达 — 触发回调
网卡收到数据包 → 硬中断 → TCP 协议栈 → 数据进 socket 接收缓冲区 → 内核调用之前注册的 ep_poll_callback:
ep_poll_callback(fd 对应的 epitem):
1. 把 epitem 挂到 eventpoll 的就绪链表上
2. 唤醒 eventpoll 等待队列上睡觉的进程(如果有的话)
第三步:epoll_wait — 只取就绪的
epoll_wait():
检查就绪链表
有东西 → 拷给用户,立刻返回(O(就绪数))
空的 → 当前进程挂到等待队列上,睡觉,等回调来叫醒
一张图总结
epoll_ctl(ADD) 数据来了 epoll_wait()
───────────── ────────── ─────────────
把 fd 插进 触发回调 检查就绪链表
红黑树 ↓ ↓
┌──────┐ 注册回调 ┌──────────┐ 有数据 ┌──────────┐
│ 红黑树 │ ───────→ │ socket │ ───────→ │ 就绪链表 │ ←── 直接从这里取 fd
│fd3,4,5,6│ │ 等待队列 │ │ fd=5 │
└──────┘ └──────────┘ └──────────┘
│
没就绪事件时 → 你睡在 → ┌──────────┐
│ 等待队列 │
│ (你的进程) │
└──────────┘
核心要点:红黑树管"盯谁",就绪链表管"谁有数据",回调函数管"数据到了叫我"。epoll_wait 直接从就绪链表取结果,不碰红黑树。
四、select / poll / epoll 三兄弟对比
| select | poll | epoll | |
|---|---|---|---|
| fd 数量上限 | 1024(FD_SETSIZE) | 无上限 | 无上限 |
| 每次调用的复杂度 | O(n) 遍历全部 | O(n) 遍历全部 | O(就绪数) |
| 内核态拷贝 | 每次调都拷整个 fd_set | 每次调都拷整个 pollfd 数组 | 只注册时拷一次,fd 常住内核 |
| 返回结果 | 全部 fd(你自己筛选谁就绪) | 全部 fd(你自己筛选) | 只返回就绪的 fd |
| 数据结构 | 位图 | 数组 | 红黑树 + 就绪链表 |
| 适用场景 | fd 少且大部分活跃 | fd 中等 | fd 多且大部分空闲 |
一句话:fd 少的场景三者差别不大,上万连接且大部分空闲时,epoll 的优势是指数级的。
五、ET vs LT:边缘触发和水平触发
这是 epoll 面试的必考题。两者区别一句话:通知的时机和次数不同。
| LT(水平触发,默认) | ET(边缘触发) | |
|---|---|---|
| 通知规则 | 缓冲区有数据就叫你 | 只在"从无到有"的瞬间叫一次 |
| 比喻 | 快递柜屏幕一直显示"有包裹" | 柜子只发一条短信,你不取就没了 |
| 对 read 的要求 | 读一次就行,下次还会叫你 | 必须循环读到 EAGAIN,否则那个 fd 再也没人叫 |
| 优点 | 简单安全,不容易丢事件 | 减少系统调用次数,吞吐更高 |
| 谁在用 | 默认模式 | Nginx、Redis |
ET 模式的黄金法则:fd 必须设非阻塞 + read 必须循环读到返回 EAGAIN + accept 也要循环到 EAGAIN。少一步都会丢事件。
六、动手写一个 epoll 服务器
完整代码在这里
// epoll_demo.cpp — 简易 epoll TCP 回显服务器
// 用法: ./epoll_demo [lt|et] [port]
// 然后用 telnet 127.0.0.1 <port> 连接测试,输入任意字符会回显
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <vector>
const int MAX_EVENTS = 64;
const int BUFFER_SIZE = 4096;
// 工具函数:设置 fd 为非阻塞
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 工具函数:创建监听 socket
int create_listen_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
bind(fd, (sockaddr*)&addr, sizeof(addr));
listen(fd, SOMAXCONN);
return fd;
}
// ========== 水平触发(LT)模式:默认行为 ==========
void run_lt(int listen_fd) {
printf("=== 水平触发 (LT) 模式 ===\n");
printf("特点: 只要缓冲区有数据,每次 epoll_wait 都会通知你\n\n");
int epfd = epoll_create1(0); // ① 创建 epoll 实例
// ② 把监听 socket 注册进去
epoll_event ev{};
ev.events = EPOLLIN; // LT 是默认的,不用写 EPOLLLT
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event ready[MAX_EVENTS];
while (true) {
// ③ 等待事件(-1 = 一直等到有事件)
int n = epoll_wait(epfd, ready, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = ready[i].data.fd;
if (fd == listen_fd) {
// = 新连接来了 =
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = accept(fd, (sockaddr*)&client_addr, &len);
set_nonblocking(client_fd); // 必须设非阻塞
epoll_event ev2{};
ev2.events = EPOLLIN; // LT
ev2.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev2);
printf("[LT] 新连接: fd=%d 来自 %s:%d\n",
client_fd, inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
}
else {
// = 客户端发来数据 =
char buf[BUFFER_SIZE];
int len = read(fd, buf, sizeof(buf));
if (len <= 0) {
// 连接关闭
printf("[LT] 连接关闭: fd=%d\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
else {
buf[len] = '\0';
printf("[LT] fd=%d 收到 %d 字节: %s", fd, len, buf);
// 回显
write(fd, buf, len);
// LT 关键:这里只读了一次。如果还有数据,
// 下次 epoll_wait 会继续通知,所以不会丢数据
}
}
}
}
}
// ========== 边缘触发(ET)模式:高性能模式 ==========
void run_et(int listen_fd) {
printf("=== 边缘触发 (ET) 模式 ===\n");
printf("特点: 只在'无→有'的瞬间通知一次,你必须循环读到 EAGAIN\n\n");
int epfd = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET; // ← 关键:加 EPOLLET 标志
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event ready[MAX_EVENTS];
while (true) {
int n = epoll_wait(epfd, ready, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = ready[i].data.fd;
if (fd == listen_fd) {
// = 新连接:ET 模式下 accept 也要循环! =
while (true) {
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = accept(fd, (sockaddr*)&client_addr, &len);
if (client_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 所有新连接都 accept 完了
}
perror("accept");
break;
}
set_nonblocking(client_fd);
epoll_event ev2{};
ev2.events = EPOLLIN | EPOLLET; // ET
ev2.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev2);
printf("[ET] 新连接: fd=%d 来自 %s:%d\n",
client_fd, inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
}
}
else {
// = 客户端数据:ET 模式必须循环读到 EAGAIN =
char buf[BUFFER_SIZE];
while (true) {
int len = read(fd, buf, sizeof(buf));
if (len == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 读完了,没有更多数据
}
perror("read");
break;
}
else if (len == 0) {
// 对端关闭连接
printf("[ET] 连接关闭: fd=%d\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
goto next_event; // fd 已关闭,跳出外层循环
}
else {
buf[len] = '\0';
printf("[ET] fd=%d 收到 %d 字节: %s", fd, len, buf);
write(fd, buf, len);
}
}
next_event:;
}
}
}
}
// ========== 对比总结 ==========
// LT: 简单安全,每次读一次就行,下次 wait 还会通知
// ET: 必须非阻塞 + 循环读到 EAGAIN,但减少系统调用次数,吞吐更高
int main(int argc, char* argv[]) {
const char* mode = (argc > 1) ? argv[1] : "lt";
int port = (argc > 2) ? atoi(argv[2]) : 8080;
int listen_fd = create_listen_socket(port);
set_nonblocking(listen_fd);
printf("epoll 演示服务器已启动: 端口 %d\n", port);
printf("测试方式: 打开多个终端,运行: telnet 127.0.0.1 %d\n", port);
printf("输入任意文字 + 回车,服务器会回显\n\n");
if (strcmp(mode, "et") == 0) {
run_et(listen_fd);
} else {
run_lt(listen_fd);
}
}
先看核心骨架——就三步:
// 1. 创建
int epfd = epoll_create1(0);
// 2. 注册要盯的 fd
struct epoll_event ev;
ev.events = EPOLLIN; // LT 默认;ET 写 EPOLLIN | EPOLLET
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
// 3. 等待
struct epoll_event ready[64];
int n = epoll_wait(epfd, ready, 64, -1);
for (int i = 0; i < n; i++) {
int fd = ready[i].data.fd; // 只遍历就绪的!
// 处理 fd...
}
LT 和 ET 的核心区别在代码层面只有两处:
// === LT:读一次就停 ===
int len = read(fd, buf, sizeof(buf));
if (len > 0) write(fd, buf, len); // 回显
// 下次 epoll_wait 如果还有数据,照样通知你
// === ET:必须循环读到 EAGAIN ===
while (true) {
int len = read(fd, buf, sizeof(buf));
if (len == -1 && errno == EAGAIN) break; // 读完了
if (len <= 0) { /* 连接关闭 */ break; }
write(fd, buf, len);
}
完整代码已给
epoll_demo.cpp,编译运行方式:
# 编译
g++ -std=c++17 -o epoll_demo epoll_demo.cpp
# 运行 LT 模式
./epoll_demo lt 8080
# 运行 ET 模式
./epoll_demo et 8080
然后用 telnet 127.0.0.1 8080 连接测试,多开几个窗口看多连接效果。
七、翻车现场:两个你一定踩的坑
坑 1:ET 模式只读了一次,数据丢了
// 错:ET 下只读一次
int len = read(fd, buf, sizeof(buf));
write(fd, buf, len);
// 如果缓冲区还有数据,epoll 不会再通知你——丢了
修复:循环读到 errno == EAGAIN。
坑 2:ET 下 fd 忘了设非阻塞
// 错:fd 是阻塞模式
int fd = accept(listen_fd, ...); // 没设 O_NONBLOCK
// ET 下循环 read,最后一次 read 会永远阻塞
修复:fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
八、面试速查表
| 面试问题 | 回答要点 |
|---|---|
| epoll 为什么快? | 注册制 + 回调通知,只返回就绪 fd,O(就绪数) 而非 O(总数) |
| 三个核心数据结构? | 红黑树存所有 fd,就绪链表存有数据的 fd,等待队列存阻塞的进程 |
| 回调函数怎么工作的? | epoll_ctl 时注册到 socket 上,数据到了协议栈触发,把 fd 挂到就绪链表 |
| LT 和 ET 区别? | LT 有数据就通知,ET 只在无→有瞬间通知一次 |
| ET 两个要求? | fd 非阻塞 + 循环读到 EAGAIN |
| 和 select/poll 的核心区别? | select/poll 每次 O(n) 遍历 + 每次拷贝全部 fd;epoll 只注册时拷贝一次,事件驱动 |
| epoll 有啥缺点? | 系统调用多(create/ctl/wait),短连接场景ctl开销大,高并发下有锁竞争 |
九、接下来做什么?
思考题:epoll 用红黑树存 fd,为什么不选择哈希表?提示:想想 epoll_ctl 支持哪些操作,以及 100 万个 fd 时哈希表的扩容代价。
动手实验:修改桌面上的 epoll_demo.cpp,加一个统计功能——每 10 秒打印一次"当前管理的连接数"和"最近 10 秒处理的请求数"。你会发现连接数涨了但请求数不涨时,CPU 几乎不变——这就是 epoll 的魅力。
延伸阅读:
- Linux 内核源码
fs/eventpoll.c——ep_poll_callback函数不到 100 行,注释清晰 - The C10K Problem(Dan Kegel, 1999)——epoll 诞生的背景论文,理解"为什么要解决万级并发"
- io_uring 入门——epoll 解决了"谁就绪",但读写还是得你自己调两个系统调用。io_uring 把等和读写全异步化了
📋 全文回顾 —— 你能不能回答这几个问题?
- 为什么快递柜的比喻里,"快递柜"对应 epoll,"挨家敲门"对应 select/poll?
- 就绪链表和红黑树的角色能不能互换?(想想看)
- 如果你写的 ET 模式服务器偶尔丢消息,第一反应应该检查什么?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)