主题
第 5 章 · epoll 深度解析 ⭐⭐⭐⭐⭐
学完本章你将知道:epoll 凭什么把 select/poll 的 O(n) 干到 O(1)?红黑树 + 就绪链表是怎么协同工作的?水平触发(LT)和边沿触发(ET)该怎么选?为什么 ET 必须配非阻塞 socket?这是面试重灾区,也是工业界事实标准。
1. 一句话开场
epoll 是 2002 年 Linux 2.5.44 引入的多路复用接口。 它通过「注册一次、长期复用」+「内核维护就绪链表」两个杀招,把 select/poll 每次 O(n) 的开销降到了 O(就绪 fd 数)——这才是真正撑起 Nginx、Redis 百万并发的发动机。
生活类比:
- select / poll:每次问前台"我这一摞 1 万张登记卡里,谁有动静?"——前台每次都要把 1 万张卡翻一遍回答你。
- epoll:你先把所有卡贴墙上(注册到红黑树),再告诉前台"出事了你主动把卡放到这个就绪盒子里"。你之后每次只要看就绪盒子——盒子里有几张拿几张,不用再翻墙了!
2. epoll 三剑客:三个系统调用
2.1 函数签名
c
#include <sys/epoll.h>
// ① 创建 epoll 实例,返回 fd
int epoll_create1(int flags); // flags = 0 或 EPOLL_CLOEXEC
// ② 注册 / 修改 / 删除监听项
int epoll_ctl(int epfd,
int op, // EPOLL_CTL_ADD / MOD / DEL
int fd,
struct epoll_event *event);
// ③ 等待事件
int epoll_wait(int epfd,
struct epoll_event *events,
int maxevents,
int timeout);
// 事件结构体
struct epoll_event {
uint32_t events; // 事件类型 (EPOLLIN | EPOLLOUT | EPOLLET | ...)
epoll_data_t data; // 用户自定义数据 (union)
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;2.2 三个调用的分工
| 调用 | 频率 | 作用 | 类比 |
|---|---|---|---|
epoll_create1 | 一次(启动时) | 在内核建一个 epoll 实例(红黑树 + 就绪链表 + 等待队列) | "开个事件中心" |
epoll_ctl(ADD/MOD/DEL) | 偶尔(连接来去时) | 增删改要监听的 fd | "贴/撕墙上的卡" |
epoll_wait | 频繁(事件循环每轮) | 拿走就绪 fd 列表 | "翻就绪盒子" |
核心思想差异:select/poll 是每次都告诉内核"这一万个 fd 你帮我看一下";epoll 是先把一万个 fd 贴上墙,之后只问"墙上的卡谁有动静"。
2.3 常用事件类型
| 宏 | 含义 |
|---|---|
EPOLLIN | 可读 |
EPOLLOUT | 可写 |
EPOLLRDHUP | 对端关闭连接(半关闭也算,强烈推荐监听) |
EPOLLPRI | 紧急数据 |
EPOLLERR | 错误(默认监听,不用显式加) |
EPOLLHUP | 挂断(默认监听) |
EPOLLET | 边沿触发模式(默认是水平触发) |
EPOLLONESHOT | 触发一次后自动从监听集移除 |
EPOLLEXCLUSIVE | Linux 4.5+,避免惊群 |
3. 内核数据结构:红黑树 + 就绪链表
3.1 epoll 实例的内部长什么样?
text
用户进程
│
epoll_create1()
│
▼
┌────────────────────┐
│ eventpoll (内核) │
│ ┌────────────────┐ │
│ │ ① 红黑树 RB │ │ ← 存所有"被监听的 fd" (key=fd)
│ │ epi₁ epi₂ │ │ epoll_ctl ADD/MOD/DEL 操作它
│ │ / \ / \ │ │
│ │ ... ... ... │ │
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ ② 就绪链表 RDL │ │ ← 存"已就绪的 fd"
│ │ epi_a → epi_b │ │ epoll_wait 从这里拿
│ └────────────────┘ │
│ ┌────────────────┐ │
│ │ ③ 等待队列 │ │ ← epoll_wait 的进程睡这里
│ └────────────────┘ │
└────────────────────┘
▲
│ 数据到达时回调
┌────────────────────┐
│ socket fd 等待队列 │
│ (内核网络栈触发) │
└────────────────────┘图解说明:
- 红黑树 RB:存"我关心哪些 fd"——为什么用红黑树?因为 add/del/find 都是 O(log n),比哈希更适合"既要快查又要遍历"
- 就绪链表 RDL:存"实际发生了事件的 fd"——这是 epoll 的杀手锏!数据来了内核主动放进来,不需要遍历
- 等待队列:调 epoll_wait 的进程在没事件时挂这里睡觉
3.2 整个流程的时序
关键洞察:
- select/poll 是用户问内核:"我这堆 fd 谁就绪了?"内核 O(n) 找
- epoll 是内核回调用户:每个 fd 早就被注册了回调,数据来了直接放进就绪链表——用户来取就行,O(就绪数)
3.3 ep_poll_callback:epoll 的灵魂
当 socket 收到数据时,内核网络栈会遍历这个 socket 的等待队列——所有挂在上面的进程都会被处理。如果发现 epoll 也挂在等待队列上,就调用 ep_poll_callback:
c
// 内核源码简化版(fs/eventpoll.c)
static int ep_poll_callback(...) {
struct epitem *epi = ...;
struct eventpoll *ep = epi->ep;
// 把这个 epi 放进就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
// 唤醒在 epoll_wait 上睡觉的进程
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq);
return 1;
}这就是 epoll 把 O(n) 干到 O(1) 的全部秘密——用回调把"主动找"变成"被动等"。
4. 标准用法模板
4.1 最小骨架(LT 模式,默认)
c
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = listen_fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[64];
while (1) {
int n = epoll_wait(epfd, events, 64, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
int conn = accept(listen_fd, NULL, NULL);
ev.events = EPOLLIN | EPOLLRDHUP;
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
} else {
char buf[1024];
ssize_t r = read(fd, buf, sizeof(buf));
if (r <= 0) {
close(fd);
/* 不需要 epoll_ctl(DEL) —— close fd 会自动从 epoll 摘除 */
} else {
write(fd, buf, r);
}
}
}
}细节 1:close fd 时会自动从 epoll 摘除——你不需要手动
EPOLL_CTL_DEL。 细节 2:events[i].data是 union——你可以塞 fd,也可以塞自己的连接结构体指针,这是事件循环里关联上下文的常用技巧。
4.2 ET 模式骨架(必须非阻塞 + 循环读到 EAGAIN)
c
/* 1. fd 必须设非阻塞 */
fcntl(conn, F_SETFL, fcntl(conn, F_GETFL) | O_NONBLOCK);
/* 2. 注册时加 EPOLLET */
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
/* 3. 触发时必须循环读到 EAGAIN */
while (1) {
char buf[1024];
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
process(buf, r);
continue;
}
if (r == 0) { close(fd); break; } // 对端关闭
if (r < 0 && errno == EAGAIN) break; // ET 必须读到这里
if (r < 0) { perror("read"); close(fd); break; }
}ET 模式三大铁律(违反任何一条都会卡死/丢数据):
- ✅ socket 必须设为非阻塞——否则最后一次 read 会卡死
- ✅ 必须循环读到 EAGAIN——否则下次 epoll_wait 不会再通知
- ✅ 写也要循环写到 EAGAIN——同理
5. LT vs ET:水平触发 vs 边沿触发 ⭐⭐⭐
5.1 概念辨析
| 模式 | 触发条件 | 类比 |
|---|---|---|
| LT (Level Triggered) 默认 | 只要 fd 缓冲区里还有数据,就一直通知 | 闹钟一直响到你按掉 |
| ET (Edge Triggered) | 状态变化的那一刻通知一次 | 门铃响一声你没听见就错过 |
5.2 用具体例子理解
场景:socket 缓冲区里有 10 字节数据。你 read 了 4 字节。
LT 模式:
text
事件 1: [10B 数据到] → epoll_wait 返回 EPOLLIN
你 read 4B → 缓冲区还剩 6B
下一次 epoll_wait 立刻返回 EPOLLIN ← 因为还有数据"高位"
你又 read 6B → 缓冲区空
下一次 epoll_wait 阻塞 ← 没数据了ET 模式:
text
事件 1: [10B 数据到] → epoll_wait 返回 EPOLLIN(这是状态变化点!)
你 read 4B → 缓冲区还剩 6B
下一次 epoll_wait 阻塞 ← 状态没"再变化"!🚨 6B 数据被你忘了!核心理解:
- LT = "只要有水就通知" → 你忘了喝也不怕
- ET = "水开始来的瞬间通知一次" → 你不喝完就完蛋
5.3 时序图对比
5.4 LT vs ET 优缺点对比
| 维度 | LT(默认) | ET |
|---|---|---|
| 写法难度 | ✅ 简单,少写一次也没事 | ❌ 必须循环到 EAGAIN,配非阻塞 |
| 唤醒次数 | 多(有数据就反复醒) | 少(只在状态变化时) |
| CPU 效率 | 一般 | 更高(少 syscall) |
| 是否易丢事件 | ❌ 不会 | ✅ 写错就丢 |
| 典型用户 | Redis、Java NIO 默认 | Nginx、libev、libuv |
5.5 该选谁?
| 场景 | 推荐 |
|---|---|
| 新手 / 简单业务 | LT |
| 极致性能(Nginx 级别) | ET |
| Redis / 缓存类(事件少且简单) | LT |
| 框架/库(用户感知不到) | ET(性能极致) |
业界数据:Nginx 用 ET 比 LT 大约少 30~40% syscall 次数——但代价是代码复杂度高很多。
6. EPOLLONESHOT:避免多线程并发问题
6.1 问题场景
多线程 worker 模型下,同一个 fd 可能被多个线程同时处理——线程 A 在 read,B 也来 read,数据顺序乱了。
6.2 解决方案
c
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// 触发后这个 fd 不会再触发,直到你重新 ARM
worker_thread() {
epoll_wait(...);
handle(fd);
// 处理完毕,重新挂回 epoll
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); // ⚠️ 必须 MOD 再 ARM
}加了
EPOLLONESHOT后,一个 fd 一次只会触发到一个 worker,避免线程竞争。
7. 内核版本演进与惊群修复
7.1 epoll 演进时间线
text
2.5.44 (2002) epoll 三剑客诞生
2.6.x 进入主线,性能优化
3.7 加入 EPOLLEXCLUSIVE 的雏形
4.5 (2016) 正式 EPOLLEXCLUSIVE → 单触发,避免惊群
5.1 (2019) io_uring 出现,开始威胁 epoll 的地位7.2 惊群问题与解决
惊群(Thundering Herd):多个进程/线程都在等同一个 fd(如 listen_fd),来一个连接所有人都被唤醒,但只有一个能 accept 成功——其他都白醒。
解决方案:
EPOLLEXCLUSIVE(4.5+):内核保证只唤醒一个等待者SO_REUSEPORT(3.9+):每个 worker 有独立 listen_fd,内核负责负载均衡- Nginx 默认用
accept_mutex锁强行串行化
8. 动手实践
请直接看本页底部 [💻 示例代码] 区,包含 4 个示例:
| 文件 | 内容 |
|---|---|
01_epoll_basic.c | 入门:epoll 三剑客最小演示 |
02_epoll_lt_server.c | LT 模式 echo 服务器 |
03_epoll_et_server.c | ET 模式 echo 服务器(非阻塞 + 循环读) |
04_epoll_oneshot.c | EPOLLONESHOT 多线程 worker 模板 |
实践 1:观察 LT/ET 触发差异
bash
# 编译
gcc 02_epoll_lt_server.c -o lt_srv
gcc 03_epoll_et_server.c -o et_srv
# 终端 1 跑 LT 模式(带打印)
./lt_srv 9001
# 终端 2 跑 ET 模式
./et_srv 9002
# 终端 3 用 nc 一次发大数据
yes "abc" | head -c 100000 | nc localhost 9001
yes "abc" | head -c 100000 | nc localhost 9002
# 对比两个服务器的"epoll_wait 返回次数"——ET 会显著少于 LT实践 2:strace 观察 epoll 系统调用
bash
strace -e trace=epoll_create1,epoll_ctl,epoll_wait ./lt_srv 9999
# 输出(简化):
# epoll_create1(EPOLL_CLOEXEC) = 4
# epoll_ctl(4, EPOLL_CTL_ADD, 3, ...) = 0 ← 只在新连接来时调
# epoll_wait(4, [...], 64, -1) = 1
# epoll_wait(4, [...], 64, -1) = 1
# ...对比 select 版的 strace——epoll_ctl 调用稀疏,epoll_wait 才是循环里的主调用。
9. 常见陷阱(必看)
🪤 陷阱 1:ET 模式 + 阻塞 socket = 死锁
c
// ❌ 致命错误
ev.events = EPOLLIN | EPOLLET;
// fd 是阻塞的(默认)
epoll_ctl(...);
// 触发后:
read(fd, ...); // 读完所有数据
read(fd, ...); // 没数据了,阻塞 socket 会卡住整个事件循环!修复:fd 必须
O_NONBLOCK。
🪤 陷阱 2:ET 模式只读一次 → 丢事件
c
// ❌ ET 模式下读完一次就完事
read(fd, buf, sizeof(buf));
// 缓冲区可能还有几 KB 数据,但下次 epoll_wait 不会再通知!修复:循环读到 EAGAIN。
🪤 陷阱 3:忘了监听 EPOLLRDHUP
c
// ❌ 只监 EPOLLIN
ev.events = EPOLLIN;
// 对端 close 后:
// LT 模式:read 返回 0,你能感知
// 但如果你是 ET:可能会有边界条件踩坑建议:永远加上
EPOLLRDHUP,明确感知半关闭。
🪤 陷阱 4:epoll_wait 数组开太小 → 饥饿
c
struct epoll_event events[10];
epoll_wait(epfd, events, 10, -1); // 一次最多取 10 个
// 如果有 1000 个就绪,剩下 990 个要等下一次循环
// 风险:某个 fd 一直没机会被处理 → 饥饿建议:开到 64~256 之间,按业务调整。
🪤 陷阱 5:close fd 后还用旧 fd 数字
c
close(fd);
// fd 数字可能立刻被新连接复用
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); // ❌ 操作到了新连接!建议:用
events[i].data.ptr存指向连接对象的指针,而不是裸 fd——你能验证对象是否还活着。
🪤 陷阱 6:忘了处理 EINTR
c
int n = epoll_wait(...);
if (n < 0) { perror("epoll_wait"); break; } // ❌ 信号打断也会失败修复:
c
int n = epoll_wait(...);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait");
break;
}10. 本章小结
text
epoll 关键事实清单(必背)
───────────────────────────────
✅ 三剑客:epoll_create1 / epoll_ctl / epoll_wait
✅ 数据结构:红黑树(监听集)+ 就绪链表 + 等待队列
✅ 性能:O(就绪 fd 数),与监听总数无关
✅ LT 模式:有数据就反复通知,简单不易错
✅ ET 模式:状态变化时只通知一次,必须非阻塞 + 循环到 EAGAIN
✅ EPOLLRDHUP:对端关闭事件,强烈推荐监听
✅ EPOLLONESHOT:多线程 worker 必备
✅ EPOLLEXCLUSIVE / SO_REUSEPORT:解决惊群11. 真实面试题(高频)
Q1:epoll 比 select / poll 快在哪?请从内核数据结构层面解释。
A:① select/poll 每次都把全部 fd 传进内核,O(n) 拷贝;epoll 用
epoll_ctl一次注册到红黑树,后续epoll_wait只传回就绪的;② select/poll 内核要 O(n) 遍历查就绪;epoll 内核网络栈在数据到达时通过回调ep_poll_callback把 fd 放进就绪链表,O(1) 拿到;③ select/poll 用户态还要再 O(n) 遍历找就绪 fd,epoll 直接拿到就绪数组。3 倍 O(n) → O(就绪数)。
Q2:epoll 的红黑树为什么用红黑树而不是哈希表?
A:① 红黑树插入/查找/删除都是 O(log n),worst case 稳定——哈希表最坏 O(n);② epoll 内部还需要"按 fd 顺序遍历或区间操作",红黑树天然有序;③ 红黑树不需要扩容,没有 rehash 卡顿。Redis 用跳表也是出于类似理由。
Q3:水平触发(LT)和边沿触发(ET)的区别是什么?为什么 ET 必须非阻塞?
A:LT 只要 fd 缓冲区有数据/有空间,就反复通知;ET 只在状态变化的瞬间通知一次。ET 必须非阻塞是因为——ET 通知一次后必须把数据全读完(否则丢事件),所以代码里要
while (read != EAGAIN),而循环里最后一次 read 在没数据时:阻塞 socket 会卡死,非阻塞 socket 才会返回 EAGAIN 让你跳出循环。
Q4:什么是惊群?epoll 怎么解决?
A:多个进程/线程都在 epoll_wait 同一个 listen_fd——一个连接来了全被唤醒,但只有一个能 accept 成功,其他白醒(开销)。解决方案:① Linux 4.5+ 用
EPOLLEXCLUSIVE标志,内核保证只唤醒一个;② 用SO_REUSEPORT让每个 worker 有独立 listen_fd,内核内置负载均衡;③ 应用层加锁(如 Nginx 的accept_mutex)。
Q5:close 一个 fd 后,需要 epoll_ctl(DEL) 吗?
A:不需要——内核在 fd 真正释放时会自动把它从所有 epoll 实例的红黑树中摘除。但有个例外:如果有 fork 后子进程持有这个 fd,父进程 close 不会真释放,这时 epoll 仍可能触发——所以多进程时要小心。
Q6:epoll_wait 的 maxevents 参数填多少合适?
A:业界经验值 64~256。太小会饥饿(一次取不完,慢的 fd 没机会处理);太大会一次性占用过多用户内存且让单次循环过长,影响延迟。Nginx 默认 512,Redis 默认 1024。
Q7:epoll 自己也是 fd 吗?能监听另一个 epoll 吗?
A:是——
epoll_create1返回的就是 fd(指向anon_inode:[eventpoll])。可以用一个 epoll 监听另一个 epoll(嵌套),libev 等库就用这个特性做层级化事件循环——但是嵌套深度有限制,一般不建议超过 5 层。
Q8:水平触发模式下,如果 EPOLLIN 一直不读,会怎么样?
A:每次 epoll_wait 都会立刻返回这个 fd——等于退化成"忙循环",CPU 100%。这种事件叫做 "starving"——必须要么读走数据,要么从 epoll 摘除(DEL 或 MOD 去掉 EPOLLIN)。
下一站 → 第 6 章:横向对比,把 select / poll / epoll 三者的所有维度做最终汇总,给出明确的选型决策。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
c
/*
* 01_epoll_basic.c —— epoll 三剑客最小演示
*
* 编译: gcc 01_epoll_basic.c -o epoll_basic
* 运行: ./epoll_basic
* 玩法: 5 秒内输入任意文字回车
*
* 仅监听 stdin,目的是看清 create/ctl/wait 的最小调用形态。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <errno.h>
int main(void) {
/* 1) 创建 epoll 实例(这本身也是个 fd,会随进程退出关闭) */
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) { perror("epoll_create1"); return 1; }
/* 2) 把 stdin 注册进去,关心 EPOLLIN 事件 */
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) < 0) {
perror("epoll_ctl"); return 1;
}
printf("⏰ 5 秒内输入任意文字...(epoll 等着)\n");
fflush(stdout);
/* 3) 等事件 */
struct epoll_event events[8];
int n = epoll_wait(epfd, events, 8, 5000); /* timeout = 5000 ms */
if (n < 0) perror("epoll_wait");
else if (n == 0) printf("😴 5 秒到了,啥也没等到\n");
else {
for (int i = 0; i < n; i++) {
if (events[i].data.fd == STDIN_FILENO) {
char buf[256];
ssize_t r = read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (r > 0) {
buf[r] = '\0';
printf("✅ epoll 通知 stdin 可读,读到: %s", buf);
}
}
}
}
close(epfd); /* 关闭 epoll 实例 */
return 0;
}c
/*
* 02_epoll_lt_server.c —— epoll LT (水平触发) 模式 echo 服务器
*
* 编译: gcc 02_epoll_lt_server.c -o lt_srv
* 运行: ./lt_srv 9001
* 测试: nc localhost 9001
*
* LT 模式特点:
* - 不需要 fd 设非阻塞
* - 不需要循环 read 到 EAGAIN(少读一次也没事,下次 epoll_wait 还会通知)
* - 写法接近 select/poll,最容易上手
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define MAX_EVENTS 64
#define BUF_SIZE 1024
static int make_listen_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr = {0};
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)) < 0 || listen(fd, 64) < 0) {
perror("bind/listen"); exit(1);
}
printf("🚀 epoll [LT] 服务器已启动,端口 %d\n", port);
return fd;
}
int main(int argc, char *argv[]) {
int port = (argc > 1) ? atoi(argv[1]) : 9001;
int lfd = make_listen_socket(port);
int epfd = epoll_create1(EPOLL_CLOEXEC);
/* 注册监听 socket(LT 模式不加 EPOLLET) */
struct epoll_event ev = {.events = EPOLLIN, .data.fd = lfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
long wait_count = 0;
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait"); break;
}
wait_count++;
printf("🔔 [LT] epoll_wait 第 %ld 次返回, 就绪 %d 个 fd\n", wait_count, n);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t evs = events[i].events;
if (fd == lfd) {
/* 新连接 */
struct sockaddr_in cli;
socklen_t cl = sizeof(cli);
int conn = accept(lfd, (struct sockaddr*)&cli, &cl);
if (conn >= 0) {
ev.events = EPOLLIN | EPOLLRDHUP; /* LT 模式 */
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
printf(" ➕ 新客户端 fd=%d, 来自 %s:%d\n",
conn, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port));
}
} else {
if (evs & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
printf(" 🗑 fd=%d 错误/挂断, 关闭\n", fd);
close(fd);
/* close 会自动从 epoll 摘除,无需 EPOLL_CTL_DEL */
continue;
}
if (evs & EPOLLIN) {
char buf[BUF_SIZE];
/* LT 模式:读一次就行,剩下的下次 epoll_wait 还会通知 */
ssize_t r = read(fd, buf, sizeof(buf));
if (r <= 0) {
if (r == 0) printf(" 👋 fd=%d 对端关闭\n", fd);
close(fd);
} else {
write(fd, buf, r);
printf(" 📨 fd=%d echo %ld 字节\n", fd, r);
}
}
}
}
}
close(epfd);
close(lfd);
return 0;
}c
/*
* 03_epoll_et_server.c —— epoll ET (边沿触发) 模式 echo 服务器
*
* 编译: gcc 03_epoll_et_server.c -o et_srv
* 运行: ./et_srv 9002
* 测试: yes "abc" | head -c 100000 | nc localhost 9002
*
* ET 模式三大铁律:
* 1) socket 必须 O_NONBLOCK
* 2) read/accept 必须循环到 EAGAIN
* 3) 否则丢事件 / 卡死
*
* 跟 LT 版对比"epoll_wait 调用次数",能直观看到 ET 节省 syscall。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 64
#define BUF_SIZE 1024
static void set_nonblock(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
static int make_listen_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
set_nonblock(fd); /* 监听 socket 也设非阻塞 */
struct sockaddr_in addr = {0};
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)) < 0 || listen(fd, 64) < 0) {
perror("bind/listen"); exit(1);
}
printf("🚀 epoll [ET] 服务器已启动,端口 %d\n", port);
return fd;
}
int main(int argc, char *argv[]) {
int port = (argc > 1) ? atoi(argv[1]) : 9002;
int lfd = make_listen_socket(port);
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = lfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
long wait_count = 0, total_bytes = 0;
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; }
wait_count++;
printf("🔔 [ET] epoll_wait 第 %ld 次返回, 就绪 %d 个 fd, 累计接收 %ld 字节\n",
wait_count, n, total_bytes);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t evs = events[i].events;
if (fd == lfd) {
/* ET 模式: 必须循环 accept 到 EAGAIN,否则会漏新连接 */
while (1) {
struct sockaddr_in cli;
socklen_t cl = sizeof(cli);
int conn = accept(lfd, (struct sockaddr*)&cli, &cl);
if (conn < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept"); break;
}
set_nonblock(conn);
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
printf(" ➕ 新客户端 fd=%d, 来自 %s:%d\n",
conn, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port));
}
} else {
if (evs & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
printf(" 🗑 fd=%d 关闭\n", fd);
close(fd);
continue;
}
if (evs & EPOLLIN) {
/* ET 模式: 必须循环 read 到 EAGAIN */
while (1) {
char buf[BUF_SIZE];
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
total_bytes += r;
write(fd, buf, r);
continue;
}
if (r == 0) {
printf(" 👋 fd=%d 对端关闭\n", fd);
close(fd);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 数据读完了,正常退出循环 */
break;
}
perror("read"); close(fd); break;
}
}
}
}
}
close(epfd);
close(lfd);
return 0;
}c
/*
* 04_epoll_oneshot.c —— EPOLLONESHOT + 多线程 worker 模板
*
* 编译: gcc 04_epoll_oneshot.c -o oneshot_srv -pthread
* 运行: ./oneshot_srv 9003
* 测试: 同时多个 nc localhost 9003,会被多个 worker 线程公平消费
*
* 思路:主线程 epoll_wait → 把就绪的 fd 派给一个 worker 线程
* EPOLLONESHOT 保证同一个 fd 一次只被一个 worker 处理
* worker 处理完必须重新 EPOLL_CTL_MOD 把它"挂回去"
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
#define MAX_EVENTS 64
#define WORKERS 4
#define BUF_SIZE 1024
static int epfd;
/* 简单的有锁队列 */
typedef struct {
int items[1024];
int head, tail, size;
pthread_mutex_t mu;
pthread_cond_t cv;
} job_queue_t;
static job_queue_t Q;
static void q_init() {
Q.head = Q.tail = Q.size = 0;
pthread_mutex_init(&Q.mu, NULL);
pthread_cond_init(&Q.cv, NULL);
}
static void q_push(int fd) {
pthread_mutex_lock(&Q.mu);
if (Q.size < 1024) {
Q.items[Q.tail] = fd;
Q.tail = (Q.tail + 1) % 1024;
Q.size++;
pthread_cond_signal(&Q.cv);
}
pthread_mutex_unlock(&Q.mu);
}
static int q_pop() {
pthread_mutex_lock(&Q.mu);
while (Q.size == 0) pthread_cond_wait(&Q.cv, &Q.mu);
int fd = Q.items[Q.head];
Q.head = (Q.head + 1) % 1024;
Q.size--;
pthread_mutex_unlock(&Q.mu);
return fd;
}
static void set_nonblock(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
static void rearm(int fd) {
/* 重新挂回 epoll,否则这个 fd 永远不会再触发 */
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET | EPOLLONESHOT | EPOLLRDHUP,
.data.fd = fd,
};
if (epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) < 0) {
if (errno != ENOENT) perror("EPOLL_CTL_MOD");
}
}
static void *worker(void *arg) {
long wid = (long)arg;
while (1) {
int fd = q_pop();
printf("[worker-%ld] 接到任务 fd=%d\n", wid, fd);
int closed = 0;
while (1) {
char buf[BUF_SIZE];
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r);
continue;
}
if (r == 0) { close(fd); closed = 1; break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
close(fd); closed = 1; break;
}
if (!closed) rearm(fd); /* 处理完后必须重新挂回 */
}
return NULL;
}
static int make_listen_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
set_nonblock(fd);
struct sockaddr_in addr = {0};
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)) < 0 || listen(fd, 64) < 0) {
perror("bind/listen"); exit(1);
}
printf("🚀 ONESHOT + 多线程 worker 服务器启动,端口 %d (workers=%d)\n", port, WORKERS);
return fd;
}
int main(int argc, char *argv[]) {
int port = (argc > 1) ? atoi(argv[1]) : 9003;
int lfd = make_listen_socket(port);
epfd = epoll_create1(EPOLL_CLOEXEC);
/* 监听 socket: 不加 ONESHOT,因为只有主线程操作它 */
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = lfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
q_init();
pthread_t tids[WORKERS];
for (long i = 0; i < WORKERS; i++) {
pthread_create(&tids[i], NULL, worker, (void*)i);
}
struct epoll_event events[MAX_EVENTS];
while (1) {
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 (fd == lfd) {
while (1) {
int conn = accept(lfd, NULL, NULL);
if (conn < 0) { break; }
set_nonblock(conn);
struct epoll_event cev = {
.events = EPOLLIN | EPOLLET | EPOLLONESHOT | EPOLLRDHUP,
.data.fd = conn,
};
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
printf("[main] 新连接 fd=%d (已加 ONESHOT)\n", conn);
}
} else {
/* 派给 worker 线程处理 */
if (evs & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
close(fd);
continue;
}
if (evs & EPOLLIN) {
q_push(fd);
}
}
}
}
return 0;
}01_epoll_basic.c ↗ · 02_epoll_lt_server.c ↗ · 03_epoll_et_server.c ↗ · 04_epoll_oneshot.c ↗