从一个 200 行的 Demo 看懂 epoll ------ 原理与海量连接下的行为
本文基于一个可运行在 Android 上的最小 epoll 回显服务器(
epoll_server.c/epoll_client.c), 从代码入手,逐层拆解 epoll 的内部原理,并深入分析当客户端数量达到几十万、上百万时到底会发生什么。
目录
- [为什么需要 epoll ------ I/O 模型的演进](#为什么需要 epoll —— I/O 模型的演进 "#1-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-epoll--io-%E6%A8%A1%E5%9E%8B%E7%9A%84%E6%BC%94%E8%BF%9B")
- [Demo 代码回顾](#Demo 代码回顾 "#2-demo-%E4%BB%A3%E7%A0%81%E5%9B%9E%E9%A1%BE")
- [epoll 三兄弟 API 详解](#epoll 三兄弟 API 详解 "#3-epoll-%E4%B8%89%E5%85%84%E5%BC%9F-api-%E8%AF%A6%E8%A7%A3")
- [内核原理:红黑树 + 就绪链表 + 回调机制](#内核原理:红黑树 + 就绪链表 + 回调机制 "#4-%E5%86%85%E6%A0%B8%E5%8E%9F%E7%90%86%E7%BA%A2%E9%BB%91%E6%A0%91--%E5%B0%B1%E7%BB%AA%E9%93%BE%E8%A1%A8--%E5%9B%9E%E8%B0%83%E6%9C%BA%E5%88%B6")
- 一次完整的请求在内核里经历了什么
- [LT 与 ET 模式](#LT 与 ET 模式 "#6-lt--et-%E6%A8%A1%E5%BC%8F")
- 海量客户端时会发生什么
- [Demo 在高并发下的缺陷与工业级写法](#Demo 在高并发下的缺陷与工业级写法 "#8-demo-%E5%9C%A8%E9%AB%98%E5%B9%B6%E5%8F%91%E4%B8%8B%E7%9A%84%E7%BC%BA%E9%99%B7%E4%B8%8E%E5%B7%A5%E4%B8%9A%E7%BA%A7%E5%86%99%E6%B3%95")
- 常见坑清单
- 总结
1. 为什么需要 epoll ------ I/O 模型的演进
要理解 epoll 的价值,得先看它替代了什么。
1.1 阻塞 I/O + 每连接一个线程
最朴素的写法:
arduino
while (1) {
int conn_fd = accept(listen_fd, NULL, NULL); // 阻塞
pthread_create(&tid, NULL, handle_client, conn_fd); // 每个连接一个线程
}
问题:
- 每个线程占 8MB 左右的虚拟内存栈(默认)+ 内核调度实体;
- 1 万连接 = 1 万线程,上下文切换开销指数级恶化,CPU 大部分时间在"切换"而不是"干活";
- 线程创建/销毁本身就有成本。
核心矛盾:一个线程同一时刻只能阻塞在一个 read() 上,而 99% 的连接在 99% 的时间里是没有数据到来的。 我们需要的其实是一种"批量等待"的机制。
1.2 select / poll:轮询式多路复用
scss
FD_ZERO(&read_set);
FD_SET(listen_fd, &read_set);
for each conn: FD_SET(conn, &read_set);
select(max_fd + 1, &read_set, NULL, NULL, NULL); // 阻塞在这一个调用上
for (fd = 0; fd <= max_fd; fd++) // 返回后线性扫描
if (FD_ISSET(fd, &read_set)) { ... }
进步:一个线程可以等 N 个 fd 了。但每次调用都有两个致命的 O(n) 开销:
| 开销 | select | poll | epoll |
|---|---|---|---|
| 每次调用要把 整个 fd 集合从用户态拷贝到内核态 | 是(bitmask,且有 1024 个 fd 的硬上限 FD_SETSIZE) |
是(pollfd 数组,无上限) | 否(注册一次,常驻内核) |
| 内核要 线性遍历所有 fd 检查就绪状态 | 是,O(n) | 是,O(n) | 否(事件驱动,只返回就绪的) |
| 返回后用户还要 再遍历一次 找出哪些就绪 | 是 | 是 | 否(events 数组里直接就是答案) |
假设 10 万连接中只有 100 个活跃:select 每次都要传 10 万个 fd 进内核、内核逐个检查 10 万个、用户再逐个查 10 万个 ------ 97% 以上的工作是无用功 。而 epoll 的哲学是:你只告诉我谁就绪了就行。
1.3 epoll 的核心思想
把"注册监听"和"等待事件"两个动作分离。 fd 只需注册一次;之后每次
epoll_wait只返回"当前真正有事件"的 fd 列表。 通知的来源不是"遍历检查",而是内核在数据到达的路径上主动回调。
这就是 epoll 在高并发、低活跃度场景下接近 O(1) 的根本原因。
2. Demo 代码回顾
server
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#define PORT 8888
#define MAX_EVENTS 10
int main(void) {
/* 1. 创建监听 socket */
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1; /* 端口复用,方便重启 */
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
/* 2. 绑定 0.0.0.0:8888 */
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); return 1;
}
/* 3. 开始监听 */
if (listen(listen_fd, 5) < 0) { perror("listen"); return 1; }
/* 4. 创建 epoll 实例 */
int epfd = epoll_create1(0);
if (epfd < 0) { perror("epoll_create1"); return 1; }
/* 5. 把监听 socket 加入 epoll,关注"可读"事件 */
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
printf("server listening on port %d\n", PORT);
struct epoll_event events[MAX_EVENTS];
while (1) {
/* 6. 阻塞等待事件发生 */
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++) {
if (events[i].data.fd == listen_fd) {
/* 监听 fd 可读 => 有新客户端连接 */
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) { perror("accept"); continue; }
printf("client connected, fd=%d\n", conn_fd);
/* 新连接的 fd 也加入 epoll */
ev.events = EPOLLIN;
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
} else {
/* 客户端 fd 可读 => 收到数据,或对端关闭 */
int fd = events[i].data.fd;
char buf[1024];
ssize_t cnt = read(fd, buf, sizeof(buf));
if (cnt <= 0) {
/* read 返回 0 表示客户端断开连接 */
printf("client disconnected, fd=%d\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else {
/* 简单回显:收到什么就发回什么 */
write(fd, buf, cnt);
printf("echo %zd bytes to fd=%d\n", cnt, fd);
}
}
}
}
close(listen_fd);
close(epfd);
return 0;
}
client
c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define PORT 8888
int main(void) {
/* 1. 创建 socket */
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); return 1; }
/* 2. 连接 127.0.0.1:8888 */
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect"); return 1;
}
printf("connected to server\n");
/* 3. 发送一条消息 */
const char *msg = "hello epoll!";
if (write(fd, msg, strlen(msg)) < 0) {
perror("write"); return 1;
}
printf("sent: %s\n", msg);
/* 4. 读取服务器回显 */
char buf[1024] = {0};
ssize_t cnt = read(fd, buf, sizeof(buf) - 1);
if (cnt > 0) {
printf("recv: %s\n", buf);
} else {
printf("server closed\n");
}
/* 5. 关闭连接 */
close(fd);
return 0;
}
bp
css
cc_binary {
name: "epoll_server",
srcs: ["epoll_server.c"],
compile_multilib: "first",
}
cc_binary {
name: "epoll_client",
srcs: ["epoll_client.c"],
compile_multilib: "first",
}
输出:
ini
# epoll_server
server listening on port 8888
client connected, fd=5
echo 12 bytes to fd=5
client disconnected, fd=5
# epoll_client
connected to server
sent: hello epoll!
recv: hello epoll!
整个程序只有一个线程,却可以同时服务任意多个客户端。下面逐层拆开。
3. epoll 三兄弟 API 详解
3.1 epoll_create1(0) ------ 创建 epoll 实例
ini
int epfd = epoll_create1(0);
返回一个 fd,它代表内核中的一个 struct eventpoll 对象。注意两点:
- 它本身也是一个 fd,所以不用了要
close(epfd)(本 demo 是学习代码,死循环里没退出路径,真实程序要处理); - 自 Linux 2.6.9 起推荐用
epoll_create1替代旧的epoll_create(size),size参数早已被忽略(曾经是哈希表的初始大小提示,现在内核改用红黑树,不再需要"预估数量")。
3.2 epoll_ctl ------ 增删改监听项
scss
epoll_ctl(epfd, EPOLL_CTL_ADD / MOD / DEL, fd, &ev);
| 操作 | 作用 | demo 中的位置 |
|---|---|---|
ADD |
把 fd 和关心的事件注册进 epoll 实例 | listen_fd 注册、每个新 accept 的 conn_fd 注册 |
MOD |
修改已注册 fd 的事件掩码(如加 EPOLLOUT、切 EPOLLET) |
demo 未用到,生产代码常用 |
DEL |
移除监听(不 close fd) | 客户端断开时移除 |
struct epoll_event 的两个字段:
arduino
struct epoll_event {
uint32_t events; /* EPOLLIN / EPOLLOUT / EPOLLERR / EPOLLHUP / EPOLLET ... */
epoll_data_t data; /* 联合体:可放 fd / 指针 / u32 / u64 */
};
data 是纯粹的"搭车数据" :内核不解释它,事件发生时原样还给用户。所以你可以塞一个指向"连接上下文结构体"的指针,这是高性能服务器把事件和业务对象关联起来的标准手法:
ini
struct conn {
int fd;
buffer in, out;
...
};
struct epoll_event ev = { .events = EPOLLIN, .data.ptr = conn };
// 事件回来后:struct conn *c = events[i].data.ptr; ------ O(1) 拿到上下文
本 demo 用 data.fd 是为了简单,代价是每次事件还要自己判断"是 listen_fd 还是 conn_fd"。
3.3 epoll_wait ------ 收割就绪事件
ini
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
events:内核只把就绪的事件填进来 ,n是本次就绪数量;MAX_EVENTS:单次最多返回的条数(10),不是最大连接数!连接数上限是 fd 上限;-1:无限阻塞;0:非阻塞轮询;正数:毫秒级超时;- 返回后,
events[0..n-1]里的每一个都保证有对应事件发生(LT 语义下,详见第 6 节)。
EINTR 处理也是标准姿势------被信号打断时重试即可,demo 里直接 continue。
4. 内核原理:红黑树 + 就绪链表 + 回调机制
这是 epoll 真正的魔法所在。epfd 背后的核心结构(fs/eventpoll.c):
arduino
struct eventpoll {
struct rb_root_cached rbr; /* ① 红黑树:保存所有被监听的 epitem */
struct list_head rdllist; /* ② 就绪链表:当前有事件的 epitem */
wait_queue_head_t wq; /* ③ epoll_wait 在此睡眠/唤醒 */
...
};
struct epitem {
struct rb_node rbn; /* 挂在红黑树上 */
struct list_head rdllink; /* 挂在就绪链表上 */
struct epoll_event event; /* 用户注册的事件 + data */
struct file *file; /* 监听的目标 fd 对应的 file */
...
};
4.1 三个关键设计
① 注册表 = 红黑树(解决"存 N 个 fd"的问题)
每次 EPOLL_CTL_ADD,内核分配一个 epitem,插入 rbr 红黑树。查找/插入/删除都是 O(log n) 。 为什么不用哈希表?哈希表需要随规模扩容、内存局部性差;红黑树天然有序、内存紧凑,且 epoll 的增删频率(建连/断连)远低于数据到达频率,O(log n) 完全够用。10 万 fd 的树高只有约 17 层。
② 结果集 = 就绪链表(解决"返回就绪 fd"的问题)
rdllist 是一条普通双向链表,只挂当前有事件的 epitem 。epoll_wait 做的事极其简单:
若 rdllist 非空 → 把前 MAX_EVENTS 个摘下来拷给用户,返回个数;
若 rdollist 为空 → 把自己挂在 wq 上睡眠,等待被唤醒。
注意这里没有任何"遍历所有 fd 检查状态"的动作------这是与 select/poll 的本质区别。
③ 通知机制 = 回调(解决"谁往链表里放东西"的问题)
这是 epoll 灵魂。每个 socket 在内核里都有一个等待队列 (sk->sk_wq)。注册 fd 时(ep_insert)会执行:
perl
ep_item_poll(epi, ...) → 调用该文件描述符的 ->poll 方法(对 socket 即 tcp_poll)
→ 把一个带回调函数 ep_poll_callback 的等待项挂到 socket 的等待队列上
从此,数据到达的路径上,内核会顺带通知 epoll:
perl
网卡收到数据 → 硬中断/软中断 → TCP 处理报文,数据放入 socket 接收缓冲区
→ 检查该 socket 的等待队列不为空 → 逐个调用其中的回调
→ ep_poll_callback(epi) 执行:
1. 把这个 epitem 挂到 eventpoll->rdllist 尾部 ← 就绪链表有了内容
2. 检查 epoll_wait 是否正睡在 wq 上 → 是则唤醒它
→ epoll_wait 返回,用户拿到就绪 fd 列表
一句话总结:select/poll 是"每次都去问所有人好了没",epoll 是"每个 fd 好了会自己举手,内核把它记在小本本(就绪链表)上,你只管来收名单"。
代价当然也有:注册需要系统调用、每个被监听 fd 多一个 epitem(约 128 字节)的内存开销。所以连接数少、且几乎全员活跃 时,epoll 相比 poll 并无明显优势;它的主场是连接多、活跃比例低的场景------而这正是长连接服务器的真实画像。
5. 一次完整的请求在内核里经历了什么
以 demo 为例,追踪 epoll_client 发送 "hello epoll!" 后的完整链路:
perl
【连接建立】
client: connect()
└─ 三次握手完成 → 服务端 listen_fd 的全连接队列出现一个新连接
└─ listen_fd 变为"可读" → 其等待队列上的 ep_poll_callback 被触发
└─ listen_fd 的 epitem 进入就绪链表 → epoll_wait 被唤醒
└─ 用户态: events[i].data.fd == listen_fd → 调用 accept()
└─ accept 从全连接队列取出连接,返回新的 conn_fd
└─ EPOLL_CTL_ADD 把 conn_fd 的 epitem 插入红黑树,
并把 ep_poll_callback 挂到 conn_fd 的等待队列上
【数据到达】
client: write(fd, "hello epoll!", 12)
└─ 数据经网络到达 → 内核协议栈处理 → 12 字节进入 conn_fd 的接收缓冲区
└─ conn_fd 可读 → ep_poll_callback → conn_fd 的 epitem 进入就绪链表
└─ epoll_wait 唤醒 → 用户态: read(fd, buf, 1024) 从内核缓冲区拷出 12 字节
【回显】
server: write(fd, buf, 12)
└─ 若发送缓冲区有空间 → 立即拷入,TCP 异步发出
└─ 若发送缓冲区满了 → write 返回 -1/EAGAIN(非阻塞)或阻塞(阻塞 fd)→ 见第 8 节
【断开】
client: close(fd)
└─ 服务端收到 FIN → conn_fd 变为"可读"且 read() 返回 0
└─ epoll_wait 报告 EPOLLIN(通常伴随 EPOLLRDHUP)
└─ demo 判定 cnt == 0 → DEL + close
可以看到, "连接、数据、断开"在内核看来全是同一种东西:fd 状态变化 → 触发回调 → 进入就绪链表。这就是多路复用的统一抽象。
6. LT 与 ET 模式
6.1 LT(Level Triggered,水平触发,默认)
只要 fd 的缓冲区里仍有 数据可读(或仍有空间可写),每次
epoll_wait都会报告它。
- 你
read只读了一半,下次epoll_wait还会提醒你------绝不会丢事件; - 允许阻塞 fd、允许一次读不完,容错性极高;
- 缺点:一个"磨蹭"的 fd 会被反复提醒,理论上有活干的 fd 会拖累循环效率。
demo 用的就是 LT,所以代码才敢这么随意:阻塞式 read、一次只 accept 一个、buf 读不完也无所谓。
6.2 ET(Edge Triggered,边缘触发,EPOLLET)
只在状态发生跳变时(无数据 → 有数据)报告一次。之后哪怕缓冲区还剩数据,也不会再提醒。
配套的强制纪律:
- fd 必须非阻塞 (
fcntl(fd, F_SETFL, O_NONBLOCK)); - 读必须循环读到
EAGAIN:
ini
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { 处理数据; continue; }
if (n < 0 && errno == EAGAIN) break; /* 读干净了,正常退出 */
if (n < 0 && errno == EINTR) continue;
出错或对端关闭; break;
}
- accept 也要循环 到
EAGAIN(尤其在惊群/高连接速率场景,一次事件可能对应多个排队连接)。
ET 的意义:每个事件只通知一次,减少 epoll 就绪链表的操作次数和重复唤醒,配合非阻塞 fd 在极端吞吐场景下效率略高。nginx、Redis 内部用的就是 ET。
经典事故:ET 模式下 read 只读一次,缓冲区还剩 5 字节,对端不再发新数据 → 这 5 字节永远读不出来,连接"卡死"。这就是 ET 必须读穿的原因。
7. 海量客户端时会发生什么
假设把 demo 推到 10 万、100 万 TCP 长连接,逐项分析。
7.1 内存:epoll 自身的开销极小
- 每个 fd 一个
epitem≈ 128B(内核空间,不可换出); - 100 万连接 ≈ 128MB 内核内存用于 epoll 结构 ------ 完全可接受;
- 用户侧
events数组只需按"单批就绪数"分配(如 1024),与总连接数无关。
对比 select:bitmask 本身虽小(1024/8=128B),但每次调用都要整块拷进内核 ,且 1024 上限直接出局;poll 的 pollfd 8B×100万 = 8MB,每次调用拷一次,系统调用内存带宽先被打爆。
真正的内存大头在别处:每个 TCP 连接的内核 socket 缓冲区(收/发各默认可达几十上百 KB,按需扩张),加上用户态为每个连接保存的上下文。100 万空闲连接轻松占用数 GB 内存------这属于"连接本身"的成本,与用不用 epoll 无关。
7.2 CPU:活跃连接数才是决定性变量
epoll 的每次 epoll_wait 成本 ≈ O(本次就绪事件数) ,与总连接数无关:
- 100 万连接、1 万活跃:每轮处理 1 万个事件,开销约等于只服务 1 万连接的服务器。这就是 C1000K 可行的原因;
- 100 万连接、100 万同时活跃:epoll 退化为"高效的事件分发器",瓶颈转移到协议栈处理、业务逻辑、网卡中断(此时该上 多线程 Reactor + SO_REUSEPORT 多队列 RSS 等手段了)。
select/poll 则相反:成本永远 O(总连接数),100 万连接里挑 1 万个就绪,每轮白扫 99 万个 fd。
7.3 accept 风暴与"就绪链表积压"
海量客户端同时建连(比如 100 万客户端同一秒启动)时:
- 每个完成三次握手的连接都触发 listen_fd 的回调,但就绪链表上 listen_fd 的 epitem 只有一份 (挂过就不会重复挂,内核用
epi->pwqlist上的标志去重)------所以 100 万次握手在 epoll 视角只是"listen_fd 就绪"这一个事实; epoll_wait返回后,demo 只accept一次,队列里剩下的 999999 个连接要等下一轮循环;- 若全连接队列(accept 队列)被填满,内核会丢弃 SYN 或拒连 (视
tcp_abort_on_overflow而定),客户端表现为连接失败或超时重试; - LT 模式下 listen_fd 会持续被报告,所以 demo 最终能消化完(每轮 1 个,配合 backlog=5 很勉强);ET 模式下必须循环 accept 到 EAGAIN,否则同样卡死。
demo 里 listen(listen_fd, 5) 的 backlog=5 在高并发下就是个笑话,生产至少 1024 起步(内核实际取 min(backlog, somaxconn),现代内核 somaxconn 默认 4096)。
7.4 惊群(Thundering Herd)
多个线程/进程各自 epoll_wait 同一个 epfd 时:一个事件到达,可能唤醒所有等待者,但只有一个能拿到事件,其余白白惊醒、空转一次。
- 同一 epfd 被
fork共享(如传统 prefork + epoll):经典惊群,内核已对epoll_wait本身做了 WQ_FLAG_EXCLUSIVE 缓解; - 不同进程各自建 epoll 实例、
SO_REUSEPORT绑定同一端口:内核按四元组哈希分流,无惊群,这是当前主流方案(nginx 1.9+、Linux 内核自身实践)。
demo 单线程单 epoll,不存在此问题。
7.5 fd 资源上限
100 万连接首先撞到的往往不是 epoll,而是这些墙:
bash
ulimit -n # 单进程 fd 上限,默认 1024!生产要调到百万级
cat /proc/sys/fs/file-max # 系统级总 fd 上限
cat /proc/sys/net/ipv4/ip_local_port_range # 客户端侧源端口耗尽(压测机)
客户端压测机做百万连接时,单台机器源端口约 2.8 万个/IP(约 28232 个可用端口),需要多个 IP 或多台压测机。
7.6 定时器与空转
海量长连接几乎必然带心跳/超时管理。若把超时做成"每秒唤醒全量扫描 100 万连接",O(n) 扫描会吃掉可观的 CPU。工业级做法:
- 按超时时间分桶 / 时间轮(hashed timing wheel):每秒只处理到期的极小部分;
- 或利用
epoll_wait的 timeout 兼做调度节拍。
demo 没有任何超时逻辑------死连接会永远占着 fd,这在生产上是事故(fd 泄漏 + 内存泄漏)。
8. Demo 在高并发下的缺陷与工业级写法
以这个教学 demo 为基线,升级为能扛十万连接的样子:
scss
/* ① listen_fd 设非阻塞(配合 ET 或批量 accept) */
fcntl(listen_fd, F_SETFL, O_NONBLOCK);
/* ② backlog 加大 */
listen(listen_fd, 1024);
/* ③ 循环 accept,直到 EAGAIN(ET 必需,LT 也建议) */
if (events[i].data.fd == listen_fd) {
while (1) {
int conn_fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (conn_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* 收干净了 */
if (errno == EINTR) continue;
break;
}
register_conn(epfd, conn_fd); /* ADD + 初始化 conn 上下文 */
}
}
/* ④ 读:非阻塞 + 循环读穿(ET 模式强制) */
while ((n = read(fd, buf, sizeof(buf))) > 0) { append_to_conn_buffer(c, buf, n); }
if (n < 0 && errno != EAGAIN) { close_conn(c); }
/* ⑤ 写:处理发送缓冲区满(慢客户端背压) */
ssize_t m = write(fd, c->out.data, c->out.len);
if (m < 0 && errno == EAGAIN) {
/* 内核发送缓冲区满:先把剩余数据存入 c->out,
并给该 fd 追加 EPOLLOUT;等缓冲区腾出空间时 epoll 会报 EPOLLOUT,
届时继续写,写完后 MOD 回 EPOLLIN-only。
demo 的 write 是阻塞语义:慢客户端会卡死整个事件循环! */
epoll_mod(epfd, fd, EPOLLIN | EPOLLOUT, c);
}
再加几件 demo 完全没有的事:
- 每连接上下文 :
data.ptr指向struct conn,携带输入/输出缓冲区(写不定长消息必须自己拼包); EPOLLRDHUP:注册它可以在对端半关闭(发 FIN)时更准确地感知断连;EPOLLONESHOT:多线程 Reactor 中防止两个线程同时操作同一 fd 的事件(处理完要 MOD 重新武装);- 多 Reactor(主从 Reactor / "one loop per core") :主线程 epoll 只管 accept,把 conn_fd 分发给 N 个 worker 线程各自的 epoll(参考 muduo/netty 模型),把百万连接摊到多核;
- 优雅退出 :
signal+eventfd/pipe 唤醒epoll_wait,统一清理资源。
9. 常见坑清单
MAX_EVENTS误解:它是"单次最多取多少个就绪事件",设 1024 左右即可,不是连接数上限;- ET 不读穿:残留数据永远读不到,连接假死(见 6.2);
- ET + 阻塞 fd:最后一次 read 阻塞在事件循环里,整个服务器卡住;
- 忘了
EPOLLOUT背压:对慢客户端持续 write,阻塞模式下卡死循环,非阻塞下不断 EAGAIN 空转(demo 属于前者); - fork 之后共享 epfd:父子进程各自 epoll_wait 同一实例会导致事件被"抢走",语义错乱;
- 重复 ADD 同一 fd :返回
EEXIST;fd 关闭后内核会自动从 epoll 移除(close 会触发),但dup 出来的 fd 例外; - close 一个仍在 epoll 里的 fd :没问题,内核自动清理;但先用 dup2 覆盖会踩坑;
epoll_wait返回后 fd 已被别的线程关掉:多线程共享 fd 表时的经典 use-after-close 竞态;- 把 epoll 当事件队列缓存:epoll 只报告"调用时刻"的就绪状态,不累积历史(LT 的"重复提醒"不是历史记录);
- 海量连接忘了调
ulimit -n:accept返回EMFILE,然后死循环空转------处理 EMFILE 时要先取一个空闲 fd 占位再 close,防止惊群式重试。
10. 总结
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024(编译期) | 无硬限制 | 无硬限制(受 fd 配额约束) |
| fd 传递 | 每次全量拷入内核 | 每次全量拷入内核 | 注册一次,常驻内核 |
| 就绪判定 | 内核 O(n) 遍历 | 内核 O(n) 遍历 | 回调驱动,就绪链表 O(1) 入队 |
| 返回处理 | 用户再 O(n) 扫描 | 用户再 O(n) 扫描 | 直接给出就绪列表 |
| 海量连接表现 | 不可用 | 迅速劣化 | 成本只随活跃连接数增长 |
用一句话记住 epoll:
红黑树存"我关心谁",就绪链表存"谁举手了",回调机制保证数据一到达就有人把 fd 放进链表------
epoll_wait只是去收名单。
而海量客户端场景下,真正需要担心的从来不是 epoll 本身(百万元级连接也就百来 MB 内核内存),而是:fd 配额、per-conn 内存、accept 风暴、慢客户端背压、超时管理与多核扩展。把 demo 按 第 8 节 的路径逐条补齐,你就从"会用 epoll"走到了"能写 epoll 服务器"。
配套代码:
epoll_server.c(LT 模式回显服务器)、epoll_client.c(单发单收客户端)、Android.bp(AOSP 编译脚本)。 推荐延伸阅读:内核源码fs/eventpoll.c、nginx 的 epoll module、muduo《Linux 多线程服务端编程》。