11.4 epoll 与 IO 多路复用
一句话
epoll 把「每次问一遍所有 fd 谁就绪」改成「内核把就绪的 fd 挂到一张链表上,你只取链表」;它消灭的是 O(n) 遍历与重复拷贝,不消灭数据拷贝、协议解析和每连接的内存开销。
五种 IO 模型:两阶段,谁在等
任何网络读都分两段:等数据就绪(网卡→内核缓冲区)、把数据拷到用户态。
| 模型 | 第一阶段(等数据) | 第二阶段(拷贝) | 典型接口 |
|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | read |
| 非阻塞轮询 | 立即返回 EAGAIN,你自己反复问 | 阻塞 | read + O_NONBLOCK |
| IO 多路复用 | 阻塞在 select/poll/epoll_wait | 阻塞 | epoll_wait + read |
| 信号驱动 | 信号通知 | 阻塞 | SIGIO |
| 异步 IO | 不阻塞 | 不阻塞(内核完成后再通知) | io_uring、POSIX AIO |
🔑 epoll 不是异步 IO。它只解决「怎么高效地知道谁就绪」,
read仍然是你自己调用、仍然要拷贝。真正的异步是io_uring那类「提交后内核把活干完再告诉你」。
select / poll 的三个瓶颈
| 瓶颈 | select | poll | 后果 |
|---|---|---|---|
| fd 集合传递 | 每次调用传 fd_set 位图 | 每次调用传 pollfd 数组 | 用户→内核全量拷贝,每次 wait 都付一遍 |
| 内核检查 | 线性扫描 0..nfds | 线性扫描数组 | O(n),与「有多少就绪」无关 |
| 返回值解析 | 位图要再扫一遍 | 要遍历数组看 revents | 用户态又一次 O(n) |
| fd 上限 | FD_SETSIZE(默认 1024) | 无硬上限(受 ulimit -n) | select 到 1024 就废 |
算一下拷贝量:1000 个 fd,pollfd 8 字节,每次 poll 拷 8 KB;每秒 1 万次就是 80 MB/s 的纯拷贝——不算多,但内核还要扫 1000 万个 fd 状态,而其中可能只有 10 个就绪。活跃比例越低,浪费越大。
epoll 的三件套
| 接口 | 作用 | 复杂度 | 关键点 |
|---|---|---|---|
epoll_create1(EPOLL_CLOEXEC) | 创建 epoll 实例 | O(1) | 返回一个 fd |
epoll_ctl(epfd, op, fd, event) | 注册/修改/删除关注的 fd | O(log n)(红黑树插入) | 每个 fd 只注册一次 |
epoll_wait(epfd, events, maxevents, timeout) | 取就绪事件 | O(就绪数) | 只拷贝就绪项 |
内核里的两个结构:
eventpoll
├── rbr :红黑树,interest list(我关注哪些 fd)
└── rdllist :双向链表,ready list(哪些 fd 已经就绪)
注册时(ep_insert),内核把 epitem 挂到目标 socket 的等待队列上,回调是 ep_poll_callback。数据到达 → 唤醒等待队列 → 回调把 epitem 挂进 rdllist → epoll_wait 被唤醒,只遍历 rdllist 把就绪项拷给用户。
这就是「O(1)」的准确含义:取就绪是 O(就绪数),不是全流程 O(1)——epoll_ctl 是 O(log n),数据拷贝依然存在。
⚠️ 常见误传:「epoll 用 mmap 共享内核与用户态,所以零拷贝」。就绪事件仍然要
copy_to_user;epoll 省掉的是「每次传全量 fd 集合」和「全量扫描」,不是数据拷贝。
LT 与 ET:默认值与代价
| 维度 | 水平触发 LT(默认) | 边缘触发 ET(EPOLLET) |
|---|---|---|
| 通知时机 | 只要条件满足(可读/可写)就通知 | 只在状态变化(不可读→可读)时通知一次 |
| 是否必须一次读完 | 否,下次还会通知 | 是,必须循环读到 EAGAIN |
| fd 是否必须非阻塞 | 否 | 是(否则最后一次读会阻塞整个 loop) |
| 编程难度 | 低 | 高 |
| 漏读后果 | 下次继续通知,最多忙一点 | 不再通知 → 连接挂死(除非有新数据到来) |
| 适用 | 绝大多数场景、短连接、请求-响应 | 高并发长连接、想减少唤醒次数 |
ET 的正确写法只有一种骨架:
// 1. 监听 fd 设为非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 2. 注册时带上 EPOLLET
struct epoll_event ev = {0};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// 3. 事件到来时,必须读到 EAGAIN
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { handle(buf, n); continue; }
if (n == 0) { close_conn(fd); break; } // 对端关闭
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读干净了
if (errno == EINTR) continue;
close_conn(fd); break; // 真错误
}
一个能跑的最小 epoll 服务器(Linux)
// epoll_echo.c —— gcc -O2 -std=gnu11 -o epoll_echo epoll_echo.c
#define _GNU_SOURCE // 为了 EPOLLRDHUP / EPOLLEXCLUSIVE 等 GNU 扩展
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define MAX_EVENTS 64
#define BUF_SIZE 4096
static void set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
listen(lfd, 1024);
set_nonblock(lfd);
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {0};
ev.events = EPOLLIN | EPOLLET; // listen fd 也用 ET:accept 必须循环到 EAGAIN
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
char buf[BUF_SIZE];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait");
return 1;
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
// accept 循环:ET 下一次事件可能带来多条连接
for (;;) {
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
perror("accept");
break;
}
set_nonblock(cfd);
struct epoll_event cev = {0};
cev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
cev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev);
}
continue;
}
if (events[i].events & (EPOLLERR | EPOLLHUP)) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
continue;
}
// 读循环:必须读到 EAGAIN,否则 ET 不再通知
for (;;) {
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r); // 简化:忽略部分写(生产要处理 EAGAIN + 发送缓冲)
continue;
}
if (r == 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
}
}
}
这段代码里有四个必须理解的细节:
- listen fd 也用 ET → 必须循环
accept到EAGAIN,否则会漏掉连接(内核不会因为还有排队连接再次通知); - 每个 fd 只
ADD一次 → 之后状态变化由内核回调驱动,不需要每次MOD; - 关闭前先
DEL→ 否则 fd 号被复用后,旧事件会打到新连接上; - 写也要处理
EAGAIN→ 上面的write是简化版;生产代码要挂EPOLLOUT并维护发送缓冲,否则对端不收就会丢数据。
惊群与多核扩展
| 问题 | 现象 | 解法 |
|---|---|---|
| 多进程/线程等同一个 epfd | 一个连接就绪,所有等待者都被唤醒(thundering herd) | EPOLLEXCLUSIVE(Linux 4.5+)只唤醒一个 |
| 单 listen fd 的 accept 竞争 | 多 worker 抢同一个 accept 队列 | SO_REUSEPORT:每个 worker 独立 listen socket + 独立 epfd,内核按四元组哈希分流 |
| 单 epoll 实例的锁竞争 | 高核数下 epoll_wait/epoll_ctl 争用 | 每核一个 event loop(多 reactor),连接绑定到 loop |
| 慢连接拖累 | 一个大响应占住 loop | 每连接发送缓冲 + 输出事件,不阻塞其他连接 |
Go 的 netpoller:在 epoll 之上再加一层调度
Go 不暴露 epoll,但整个网络栈建立在它(Linux)/ kqueue(macOS/BSD)/ IOCP(Windows)之上:
net.Listen把 fd 设为非阻塞,注册到 netpoller;- goroutine 调
conn.Read,无数据 →gopark挂起,M 去跑别的 G; - 数据到达 → 内核就绪 → netpoll 取事件 →
goready唤醒对应 G; - G 被调度回来,重新
read。
所以「一个连接一个 goroutine」在 Go 里成立:阻塞的是 G,不是线程。下面这组数据在 macOS 上测得(netpoller 用 kqueue,与 Linux 的 epoll 同构),测的是每连接成本,不是 epoll 的极限吞吐:
本机实测(Go 1.27,macOS,Apple M1,单进程内同时做客户端与服务端):
| 连接数 | 每连接堆内存 | 每连接 RSS | 建连耗时 | goroutine 数 |
|---|---|---|---|---|
| 1,000 | 9.09 KiB | 14.56 KiB | 78.4 µs | 1,000 |
| 3,000 | 9.08 KiB | 13.70 KiB | 60.4 µs | 3,000 |
按 9 KiB/连接算,1 万条空闲连接的堆开销约 90 MiB——这是「C10K 不是问题」的底气;换成「一连接一线程」,1 万线程按默认 8 MiB 栈算就是约 78 GiB 虚拟地址空间(驻留远小于此,但上下文切换与调度开销是真实的),线程数一多,调度器本身就成了瓶颈。
实测还暴露了两个不是 epoll 的问题:
- 单进程 fd 上限:本机跑到 5,118 条时
accept/dial报too many open files(EMFILE); - 客户端临时端口耗尽:大量短连接在
TIME_WAIT未回收时,同一个目的地址的第 145 条就can't assign requested address(完整分析见 8.3 连接复用与 TIME_WAIT)。
🔑 epoll 解决「一万条空闲连接怎么管」,不解决「端口和 fd 从哪来」。容量规划要同时算 fd、端口、内存与文件句柄。
epoll 不解决什么
| 问题 | 归谁管 |
|---|---|
| 数据在内核/用户态之间拷贝 | 零拷贝:sendfile/splice/mmap,见 11.5 零拷贝 |
| 协议解析(HTTP/TLS) | 应用层/用户态协议栈 |
| 每连接的缓冲区与状态机内存 | 连接池、背压、限流 |
| 磁盘 IO 的异步化 | io_uring(Linux 5.1+) |
| 多核扩展 | 多 reactor + SO_REUSEPORT |
| 普通文件的异步读写 | 普通文件不支持 poll,epoll_ctl 直接返回 EPERM;文件 IO 的异步化用 io_uring/AIO |
io_uring 一句话
io_uring 用两块共享内存环形队列(提交队列 SQ、完成队列 CQ)取代「每次 syscall 传参数」:用户态把请求写进 SQ,内核消费后把结果写进 CQ;IORING_SETUP_SQPOLL 还能让内核线程轮询 SQ,连 syscall 都省掉。它同时覆盖文件 IO 与网络 IO,是「真异步」,但不是 epoll 的替代品——就绪通知与完成通知是两种模型。
反模式
| 反模式 | 后果 |
|---|---|
ET 模式只 read 一次 | 剩余数据不再触发事件,连接永久挂起 |
| ET 配阻塞 fd | 最后一次 read 阻塞整个 event loop |
忘记处理 EPOLLERR / EPOLLHUP | 对端已断,循环空转,CPU 100% |
epoll_wait 超时设 0 忙轮询 | 空转烧 CPU,延迟还不一定更好 |
关闭 fd 前不 EPOLL_CTL_DEL | fd 号复用后旧事件错乱,数据串到别的连接 |
| 多线程同时处理同一个 fd(LT) | 两个线程读同一连接,报文交错;用 EPOLLONESHOT 或每连接单 owner |
| 把普通文件加入 epoll | EPERM;普通文件没有「就绪」概念,只会一直可读写 |
| 一万连接配一万线程 | 栈内存与上下文切换把机器拖垮 |
| 只扩 fd 上限,不扩端口/内存 | 换一个瓶颈继续挂 |
自检
Q1 1 万连接、只有 10 个活跃时,epoll 比 select 快在哪?如果 1 万连接全部活跃,优势还剩多少?
▸ 答案:快在「每次 epoll_wait 只处理就绪的 10 个」;select 每次要把 1 万个 fd 从用户态拷进内核并线性扫描,再扫一遍返回值,全是无效功。全部活跃时,epoll 仍省掉「全量拷贝 + 两次 O(n) 扫描」,但 epoll_wait 返回 1 万个事件、用户态处理 1 万个连接的开销无法避免——此时差距缩小,瓶颈转移到「每连接的处理成本」与多核扩展(多 reactor)。
Q2 ET 模式漏读一次会怎样?为什么必须非阻塞?
▸ 答案:ET 只在「不可读→可读」时通知一次。如果只读了一部分就返回,缓冲区仍有数据,但状态没有新的「变化」,内核不再通知,连接就挂住了(直到对端再发数据)。必须循环读到 EAGAIN 才能确认读干净。必须非阻塞是因为最后一次 read 时缓冲区已空,阻塞 fd 会一直等下去,把整个 event loop 卡死。
Q3 epoll 是异步 IO 吗?同步/异步的分界在哪?
▸ 答案:不是。分界看第二阶段(数据拷贝)谁来做、调用方是否被阻塞:epoll_wait 只告诉你「可读了」,真正的 read 仍由你发起并同步拷贝,所以它是 IO 多路复用(同步 IO 的一种)。异步 IO(io_uring、POSIX AIO)是「提交请求后立刻返回,内核完成拷贝再通知」,调用方全程不阻塞在数据上。
Q4 Go 里 1 万连接为什么不需要 1 万线程?netpoller 做了什么?
▸ 答案:Go 把 fd 设为非阻塞并注册到 netpoller(Linux 上是 epoll)。goroutine 读不到数据时 gopark 挂起,对应的 M(线程)立刻去跑别的 G;数据到达后 netpoll 拿到就绪事件,goready 唤醒 G,调度器把它放回某个 M 上继续 read。所以阻塞的是 goroutine(初始栈 2 KiB,按需增长),不是线程;1 万连接 ≈ 1 万个轻量 G + 每连接约 9 KiB 堆内存。
Q5 LT 和 ET 怎么选?什么场景 ET 反而更容易出错?
▸ 答案:默认选 LT——编程简单、漏读有补偿、短连接与请求-响应场景足够。ET 适合「高并发长连接 + 想减少重复唤醒」的场景,但要求代码严格做到「非阻塞 + 读到 EAGAIN + 处理部分写 + 关前 DEL」。最容易出错的场景:① 监听 fd 用 ET 但 accept 只调一次,高并发下漏连接;② 业务处理里有 continue/return 提前跳出读循环;③ 把 fd 传给别的线程处理却没设 EPOLLONESHOT,两个线程同时读同一连接。
Q6 一台机器要扛 10 万连接,除了换 epoll,还要改什么?
▸ 答案:epoll 只是「知道谁就绪」,还要:① ulimit -n 与 fs.file-max 提到 10 万以上,并给进程留余量;② 内存预算:每连接约 9 KiB 堆 + 内核 socket 缓冲(tcp_rmem/wmem),10 万连接是 GB 级;③ 端口与 TIME_WAIT:主动关闭方会占端口,需要连接复用/长连接;④ 多核扩展:多 reactor + SO_REUSEPORT,避免单 epoll 实例锁竞争;⑤ 应用层:超时、心跳、背压、慢连接隔离,否则内存被慢客户端吃光。
跨篇相关:阻塞/非阻塞/异步的完整分类见 11.3 文件系统与 IO 模型 (阻塞 / 非阻塞 / 异步);零拷贝见 11.5 零拷贝;进程、线程与调度见 11.1 进程、线程与调度;eBPF 观测 epoll 与网络栈见 11.7 eBPF 与内核态观测;连接复用与 TIME_WAIT 见 8.3 连接复用与 TIME_WAIT;长连接与 WebSocket 见 8.11 长连接与 WebSocket;GMP 调度与 goroutine 挂起见 15.1 GMP 调度模型;并发原语见 15.6 并发原语 (channel / context / sync);性能剖析工具见 15.9 性能剖析工具链 (火焰图 / perf / eBPF);在 epoll 之上怎么组织服务模型见 11.8 单机高性能模式 (PPC / TPC / Reactor / Proactor)。