Linux-31 IO 模型演进:阻塞 → epoll
上一篇讨论文件数据如何从页缓存到稳定介质。这一篇讨论另一条高频路径:网络连接上的数据什么时候可读、什么时候可写,以及一个进程如何同时管理十万条连接。
很多文章把 epoll 简化为“突破 select 的 1024 限制”。这几乎抓错了重点。FD_SETSIZE 可以重编译或用 poll 绕开,真正逼出 epoll 的是:每次等待都要把所有 fd 从用户态搬进内核,再由内核扫描一遍;即使只有一个连接有数据,也要检查十万个。
先看五个问题:
- 阻塞、非阻塞、IO 多路复用、异步 IO 分别解决什么,为什么不能混为一谈?
select的根问题为何是 O(n),而不是 1024?poll改了什么,又没改什么?- epoll 的红黑树和就绪链表分别干什么?为什么它能避免重复扫描?
- LT 和 ET 到底差在哪里?为什么 ET 必须读到
EAGAIN,却不必迷信它“永远更快”? - 多线程
accept为什么会惊群?EPOLLEXCLUSIVE、SO_REUSEPORT和 Go netpoller 分别在哪一层解决它?
1. 先把四个 IO 概念拆开
1.1 阻塞与非阻塞:一次调用等不等
以 socket read 为例:
阻塞 socket:
read(fd) -> 没数据时,调用线程睡眠 -> 有数据/EOF/错误后返回
非阻塞 socket:
read(fd) -> 没数据时,立即返回 -1/EAGAIN
阻塞解决代码简单,代价是一个慢连接占住一个线程。非阻塞让线程能做其他事,代价是应用必须处理“现在读不到”的状态,不能写成忙轮询:
for (;;) {
n = read(fd, buf, sizeof buf);
if (n < 0 && errno == EAGAIN)
continue; // 错误:烧满一个 CPU
}
非阻塞只回答“这一次调用是否等待”,没有告诉你什么时候再试。
1.2 IO 多路复用:一个线程等很多 fd
select/poll/epoll 解决的正是“什么时候再试”:
一个线程
-> 告诉内核:我关心 fd 3/5/9/... 的读写状态
-> 睡眠
-> 任一 fd 就绪时被唤醒
-> 只处理已就绪的那几个
它仍然是同步模型:epoll_wait() 返回后,应用自己调用 read/write/accept 执行实际 IO。只是等待阶段从“一个 fd 等一次”变成“很多 fd 一起等”。
1.3 信号驱动 IO:通知不等于可扩展
历史上 SIGIO 可以让内核对 socket 事件发信号。它也是通知模型,但继承了上一篇讲的信号问题:合并、异步 handler 限制、线程路由复杂、难以携带大量事件状态。现代高并发服务器一般不用 SIGIO 做主事件循环。
1.4 异步 IO:提交与完成分离
真正异步 IO 的形式是:
提交 read 请求 -> 立即返回
... 内核/设备完成 IO ...
完成队列通知 -> 取回结果和数据
应用不在完成时再调用 read,而是提前提交操作。Linux 传统 AIO 的语义和文件系统支持较复杂;io_uring 提供提交队列(SQ)和完成队列(CQ),是下一篇的重点。
所以:
| 模型 | 等待什么 | 实际 IO 在哪里发生 |
|---|---|---|
| 阻塞 IO | 单次 read/write 内等待 |
该系统调用中 |
| 非阻塞 IO | 不等待,立即获知结果 | read/write 中尝试 |
| 多路复用 | select/poll/epoll_wait 等就绪 |
返回后应用再 read/write |
| 异步 IO | 提交后等完成通知 | 内核按已提交请求完成 |
“epoll 是异步 IO”这个说法不准确;它是高效的就绪通知(readiness)多路复用。
2. 阻塞线程模型为什么撞墙
2.1 一连接一线程的成本
10 万长连接
-> 10 万线程
-> 每个线程至少一段栈、TLS、task_struct、调度状态
-> 频繁上下文切换、缓存污染、调度器运行队列膨胀
线程池可以限制线程数,却无法让一个阻塞在 read 的线程同时等待别的连接。若 worker 数小于慢连接数,全部 worker 都可能被慢客户端占住,快请求排队。
这不是“线程不能处理网络”,而是连接多、每条连接大部分时间空闲时,线程作为等待单位太粗。
2.2 C10K 的真实约束
经典 C10K 问题不是一个固定数字,而是旧架构在大量闲置连接下的组合瓶颈:
- 每连接线程的内存与调度成本
- 每次
select/poll扫描全部 fd - fd 集合从用户态复制到内核态
- 内核网络栈和设备中断处理能力
- 应用协议解析、日志和下游依赖
epoll 主要解决其中“等待大量 fd 的重复扫描与拷贝”,不会自动让业务 handler、TLS、数据库或 CPU 变快。
3. select:API 限制只是表象
3.1 每次都传全集
fd_set rfds;
FD_ZERO(&rfds);
for (int fd : all_connections)
FD_SET(fd, &rfds);
int n = select(maxfd + 1, &rfds, NULL, NULL, &timeout);
每次调用要做三件 O(n) 工作:
用户态:重建 fd_set
内核: copy_from_user + 扫描 0..maxfd
返回: 把就绪位图 copy_to_user
用户态:再次扫描 fd_set 找谁被置位
即使只有 fd 7 有一个字节数据,maxfd=100000 也意味着大量位检查。O(n) 不只与活连接数有关,也与最大 fd 值有关;若 fd 稀疏,尤其浪费。
3.2 1024 是 fd_set 的实现上限
FD_SETSIZE 常见默认值 1024,来自 fd_set 的固定大小位图。它带来两个问题:高编号 fd 不能安全放入宏;重编译/调整宏会影响 ABI,库之间可能不一致。
但即使把它扩大到百万,扫描复杂度仍是 O(n)。所以“把 FD_SETSIZE 改大”只处理容量表象,没处理可扩展性。
3.3 select 还有语义坑
fd_set会被内核修改成“只包含就绪 fd”,每轮都必须重建- timeout 可能被修改,重试前要重设
- 某 fd 被关闭后,另一个线程继续放进 set,可能得到
EBADF - 监听、读、写、异常集合分开,状态组合难维护
pselect 增加临时信号掩码接口,主要解决“解除信号阻塞”和“开始等待”之间的竞态,不改变扫描本质。
4. poll:绕过 1024,保留 O(n)
4.1 pollfd 数组
struct pollfd fds[N];
fds[i] = (struct pollfd){ .fd = fd, .events = POLLIN };
poll(fds, N, timeout);
poll 用动态数组,不受固定 FD_SETSIZE 位图限制,也不依赖 maxfd。内核返回时通过每项 revents 标识就绪,用户不需要再扫一遍 0..maxfd 的稀疏位图。
但每次调用仍然:
copy pollfd[N] 到内核
遍历 N 个 fd,向每个等待队列临时注册检查
等待
遍历 N 个 fd,收集 revents
copy 回用户态
因此复杂度仍随监控集合大小 N线性增长。N 到十万且就绪很少时,大量 CPU 花在“确认没事发生”。
4.2 poll 的适用范围
几十到几百个 fd、低频管理任务、跨平台库,poll 简单可靠。无需把所有程序都重写 epoll;选型要看活跃 fd 数、就绪比例、平台和复杂度。
5. epoll:注册一次,通知多次
5.1 两个核心数据结构
epoll_create1() 创建一个 epoll instance。随后:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 注册兴趣
epoll_wait(epfd, events, maxevents, timeout); // 等待就绪
概念模型:
interest list:我关心哪些 fd、哪些事件
红黑树 / RB-tree
key: file object + fd 相关身份
用途:EPOLL_CTL_ADD/MOD/DEL 快速查重和修改
ready list:哪些已经发生且尚待用户取走
就绪链表
用途:epoll_wait 直接取已就绪项,不扫描全部 interest list
应用 epoll_ctl ADD fd=42, EPOLLIN
-> epoll 将 watcher 挂到目标 file 的等待队列
-> 同时放入 interest tree
网卡数据到达 -> socket receive queue 从空变非空
-> socket wakeup 回调通知 epoll
-> epoll 把 fd=42 加入 ready list
应用 epoll_wait
-> 从 ready list 拷贝就绪 events 给用户
真正的关键不是“红黑树 O(log n)”本身,而是状态变化时由被监控对象主动把事件推入 ready list。epoll_wait 的工作量接近本轮就绪事件数,而不是总连接数。
5.2 为什么 epoll 不必每次复制全集
兴趣集合在 epoll_ctl 时注册,内核长期保存 watcher。epoll_wait 只传一个输出数组:
struct epoll_event events[256];
int n = epoll_wait(epfd, events, 256, -1);
应用每轮只处理 n 个返回事件。注册/删除连接仍有成本,fd 频繁 churn 时 epoll_ctl 不会免费;但典型长连接服务的“连接寿命远长于一次事件”正好匹配这种摊销方式。
5.3 epoll 的复杂度不要神化
常见简化是“epoll O(1)”。更准确:
- 添加/修改/删除兴趣:通常 O(log n)
- 就绪事件入队:与发生的状态变化有关
epoll_wait:与返回事件数、拷贝和处理有关- 网络协议处理、锁、用户 handler、缓存 miss 仍可能成为瓶颈
如果十万个 fd 每轮都可读,epoll 也要返回十万个事件;它不能让真实工作消失。它优化的是稀疏就绪场景中重复扫描空闲连接的浪费。
6. LT 与 ET:事件不是数据包
6.1 Level Triggered:条件成立就持续提醒
默认 LT(level-triggered)语义:只要条件仍成立,等待会继续报告。
socket receive queue 有 100 bytes
-> EPOLLIN
应用只读 20 bytes,仍有 80 bytes
-> 下次 epoll_wait 仍会报告 EPOLLIN
LT 容错好:一次处理不彻底,内核会再次提醒。缺点是若应用每次只读一点,可能反复收到相同 readiness,产生更多事件和系统调用。
6.2 Edge Triggered:只报告状态变化
ET(EPOLLET)关注“从不就绪到就绪”的边沿:
receive queue: empty -> data arrives -> nonempty
-> 一次 EPOLLIN edge
应用只读 20 bytes,仍有 80 bytes
-> 仍是 nonempty,没有新 edge
-> 若应用回 epoll_wait,可能一直睡眠
所以 ET 的硬要求是:收到可读事件后,循环读到 EAGAIN;收到可写事件后,尽可能 flush 写队列到 EAGAIN 或队列为空。
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) { consume(buf, n); continue; }
if (n == 0) { close_connection(fd); break; } // EOF
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
fail_connection(fd); break;
}
这要求 socket 为 nonblocking;否则读空后会把事件循环阻塞住。
6.3 ET 不保证更快
ET 减少重复通知,理论上可减少系统调用和事件分发;但它增加状态机复杂度:
- 必须正确处理 partial read/write
- 必须维护用户态发送队列
- 忘记读到
EAGAIN会永久卡连接 - 单连接大量数据可能霸占事件循环,损害公平
LT 更容易正确,常在连接数不极端、handler 已有合理 drain 策略时足够好。ET 是性能/控制工具,不是默认“高级模式”。
6.4 EPOLLONESHOT:一次交给一个 worker
EPOLLONESHOT 让一个事件被某个 worker 获取后暂时禁用,worker 处理完成后需 EPOLL_CTL_MOD rearm:
事件到达 -> 一个 worker 获得 fd
-> fd 不再被其他 worker 取到
-> worker drain / 更新状态
-> MOD 重新 arm
它有助于多线程事件循环避免同一连接被并发处理,但把 rearm 正确性变成应用责任。漏 rearm 的效果与 ET 没读空类似:连接沉默。
7. read、write、accept 的工程语义
7.1 可读不只等于“有业务数据”
EPOLLIN 通常表示 read 不会阻塞,但结果可能是:
n > 0:读到数据n == 0:对端半关闭,EOFn < 0, EINTR:被信号中断,按策略重试n < 0, EAGAIN:另一个线程/处理路径已读空,或状态变化- 其他错误:连接错误
不要只在 n>0 时处理,忘了 n==0 会留下死连接。
7.2 可写常常一直成立
大多数 TCP socket 的发送缓冲区大部分时间有空间,所以持续订阅 EPOLLOUT 会让 epoll 不停返回,形成 busy loop。
正确模式:
默认只关心 EPOLLIN
应用有数据要发送:尝试 write
全部写完:不订阅 EPOLLOUT
EAGAIN / partial write:把剩余数据放入用户态队列,订阅 EPOLLOUT
EPOLLOUT 到达:循环 flush 队列
队列清空:取消 EPOLLOUT
EPOLLOUT 是“内核发送缓冲有空间”,不是“对端已收到”也不是“网络没有拥塞”。
7.3 accept 要循环到 EAGAIN
监听 socket 在 ET 模式下同样必须 drain:
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd >= 0) { add_to_epoll(cfd); continue; }
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EMFILE || errno == ENFILE) { mitigate_fd_exhaustion(); break; }
log_accept_error(); break;
}
accept4 原子设置 O_NONBLOCK 和 FD_CLOEXEC,避免 accept 后再 fcntl 的竞态及额外 syscall。
7.4 EMFILE:满 fd 时别陷入错误风暴
进程到达 RLIMIT_NOFILE 时 accept 返回 EMFILE。若事件循环不断收到监听 fd 可读又无法 accept,会形成忙循环。常见“idle fd”缓解:预留一个 /dev/null fd,出现 EMFILE 时关闭预留 fd、accept 并立即 close 一个连接、重新打开 /dev/null,让客户端尽快收到失败而不是一直堆在 backlog。
根本修复仍是:合理的 fd 限额、连接上限、泄漏排查和过载保护;idle fd 只是止血技术。
8. 惊群:谁该被唤醒
8.1 经典 accept 惊群
多个进程/线程都在同一监听 socket 上 accept 或等待同一个 epoll 事件:
新连接到达
-> 唤醒 16 个 worker
-> 1 个 accept 成功
-> 15 个得到 EAGAIN,再睡眠
其余 15 个调度、抢锁、cache miss 全是浪费。早期 Linux 和不同等待模型的惊群程度不同,但“一个资源就绪唤醒过多竞争者”始终是核心问题。
8.2 EPOLLEXCLUSIVE:epoll 等待队列层的限制
Linux 4.5 引入 EPOLLEXCLUSIVE。把同一监听 fd 注册到多个 epoll instance 时,使用它可让就绪事件唤醒至多一个 exclusive waiter,降低多 epoll worker 的惊群。
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
它有组合限制,且主要针对多个 epoll 等待者,不替代应用并发控制。应查本机 epoll_ctl(2) 文档确认可与哪些 flag 组合。
8.3 SO_REUSEPORT:把分流提前到监听层
SO_REUSEPORT 允许多个 socket 绑定同一地址端口,内核把新连接分配到不同监听 socket:
worker 0 -> listen socket 0 -> epoll 0
worker 1 -> listen socket 1 -> epoll 1
worker 2 -> listen socket 2 -> epoll 2
它减少共享监听队列与锁竞争,也更利于 CPU/cache 亲和性。代价是连接分布、平滑重启和负载策略更复杂;Linux 可以结合 BPF 选择 socket。
8.4 三者不是替代关系
| 技术 | 所在层 | 主要目的 |
|---|---|---|
| accept mutex / 应用协调 | 用户态 | 控制谁调用 accept |
EPOLLEXCLUSIVE |
epoll 等待队列 | 限制唤醒多个 epoll waiter |
SO_REUSEPORT |
socket 监听层 | 多个监听 socket 分流新连接 |
不要把 SO_REUSEPORT 当“任意 socket 都能多进程共享”的万能开关,也不要因为用了它就不需要连接级并发状态保护。
9. Go netpoller:goroutine 如何等 epoll
9.1 Go 不让每个连接占一个线程
Go 的 net.Conn.Read 看起来是阻塞 API,但 runtime 把网络 fd 设为 nonblocking,并维护 poll descriptor:
goroutine 调 net.Conn.Read
-> nonblocking read(fd)
-> EAGAIN
-> runtime 把当前 G 标记等待读事件
-> gopark,M/P 去跑其他 G
内核 socket 可读
-> epoll ready
-> netpoller 从 epoll_wait 返回
-> goready 等待该 fd 的 G
-> G 再次 read
因此 Go 对应用提供“同步阻塞式”接口,对内核使用“非阻塞 + 多路复用”。这正是 goroutine 能承载大量闲置连接的原因之一。
9.2 netpoller 不只处理网络
在 Linux 上,Go runtime 的 netpoll 后端使用 epoll;deadline 通常通过 runtime 的 timer 机制处理,到期会唤醒对应 G,使 Read/Write 返回 timeout。网络 readiness 和 deadline 不是同一件事:epoll 告诉运行时 fd 当前可能操作,timer 告诉运行时等待已超时。
SetDeadline 不是“给每次 read 单独创建一个内核 timeout”。它是 fd 级 deadline 状态,影响后续/正在进行的读写,使用时必须在协议边界及时更新或清除。
9.3 Go 也会受 fd 与 CPU 边界限制
netpoll 减少线程阻塞,不会绕过:
ulimit -n/ 容器 fd 限额- 单连接读写缓冲与内存占用
- TLS 解密、JSON 编码、压缩等 CPU
- handler 阻塞在数据库、锁或 channel
- cgroup CPU quota 造成的 wakeup 延迟
上一章的调度与 quota、前一章的锁,仍然决定 epoll 返回后 goroutine 能不能及时运行。
10. 线上排查与命令知识点扩展
10.1 先定位在哪一层排队
连接慢,不一定是 epoll 慢:
① 内核前:SYN backlog / accept queue 满
② epoll 后:事件循环没有及时运行(CPU、quota、GC、锁)
③ read 后:协议解析/handler 慢
④ write 前:用户态发送队列堆积
⑤ write 后:内核发送缓冲、网络拥塞或对端不读
# 监听队列、连接状态与进程 fd 数
ss -ltnp
ss -tan state established '( sport = :8080 )'
ls /proc/"$PID"/fd | wc -l
cat /proc/"$PID"/limits | grep 'open files'
# TCP 汇总、重传和队列线索
ss -s
netstat -s 2>/dev/null | grep -i -E 'listen|overflow|drop|retrans'
# 线程、CPU、上下文切换
pidstat -u -w -t -p "$PID" 1
ss -ltn 的 Recv-Q/Send-Q 对监听 socket 的解释与普通已连接 socket 不同,和 backlog、内核版本有关;应结合 ss -ltnp、应用连接数和内核 TCP 统计判断,不能只按一个数字直接定性。
10.2 epoll 事件怎么观察
# 进程打开了哪些 socket、epoll 实例
lsof -p "$PID" | grep -E 'TCP|eventpoll'
# 某 fd 指向什么对象
readlink /proc/"$PID"/fd/42
cat /proc/"$PID"/fdinfo/42
# epoll fdinfo 常可列出它监控的 target fd 和 event mask
# 追踪 epoll/网络 syscall(短时;strace 会扰动时序)
sudo strace -ff -ttT -p "$PID" \
-e trace=epoll_wait,epoll_ctl,accept4,read,write,recvfrom,sendto
epoll_wait 长时间阻塞通常是健康的空闲表现;频繁 epoll_wait(...)=0 或立刻返回同一个 EPOLLOUT 才可能是 timeout/busy loop/事件订阅错误。
10.3 Go 服务的排查组合
# goroutine 是否大量卡在网络、锁或 channel
curl -o goroutines.txt 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2'
# CPU 与执行时间线
curl -o cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
curl -o trace.out 'http://127.0.0.1:6060/debug/pprof/trace?seconds=5'
go tool pprof -http=:8081 cpu.pprof
go tool trace trace.out
# 容器中补查 quota throttling
cat /sys/fs/cgroup/cpu.stat
cat /sys/fs/cgroup/cpu.max
大量 goroutine 显示在 internal/poll.(*FD).Read 不一定是泄漏,它可能只是健康的闲置连接;关键要看数量是否随连接生命周期回落、fd 数是否同步增长、业务 deadline 是否生效。
10.4 常用参数
| 短参 | 长参 | 英文全称 | 作用 |
|---|---|---|---|
ss -l |
ss --listening |
listening | 显示监听 socket |
ss -t |
ss --tcp |
TCP | 只显示 TCP socket |
ss -n |
ss --numeric |
numeric | 不解析服务名/主机名 |
ss -p |
ss --processes |
processes | 显示关联进程 |
ss -s |
ss --summary |
summary | socket 汇总 |
lsof -p PID |
无统一长参 | list open files | 查看进程打开文件/socket |
strace -f |
strace --follow-forks |
follow forks | 跟踪线程/子进程 |
strace -T |
strace --syscall-times |
syscall times | 显示调用耗时 |
pidstat -w |
无统一长参 | task switching | 显示上下文切换 |
11. 面试题
Q:select 的问题为什么不只是 1024 fd?
1024 是常见 fd_set 固定大小的 API/ABI 限制,扩大它只能容纳更多描述符。真正的扩展性问题是每轮都要用户态重建集合、内核复制并扫描 0 到 maxfd、返回后用户再扫描;即使只有一个 fd 就绪,工作量仍与监控集合或最大编号线性相关。poll 去掉固定位图和 maxfd 依赖,但仍每轮遍历整个 pollfd 数组,所以仍是 O(n)。
Q:epoll 的红黑树和 ready list 分别做什么?
interest tree 保存长期注册的 fd 与事件,用于 ADD/MOD/DEL 查找、去重和更新,通常 O(log n)。目标 fd 状态变化时通过等待队列回调把事件加入 ready list;epoll_wait 直接取 ready list,而不是扫描全部兴趣集合。核心优势是注册一次、按就绪状态增量通知,不是“所有操作都是 O(1)”。
Q:LT 和 ET 的核心差异是什么?
LT 在条件仍成立时持续报告,例如接收队列还有数据就会继续给 EPOLLIN;ET 只在不就绪到就绪的边沿报告。ET 收到事件后必须在非阻塞 fd 上循环 read/accept/write 到 EAGAIN,否则残留数据不会产生新边沿,连接可能永久卡住。LT 更容错,ET 可能减少重复事件但增加状态机复杂度,不能机械认为 ET 更快。
Q:为什么 EPOLLOUT 不能一直注册?
TCP 发送缓冲通常有空间,因此 socket 长时间处于可写状态;持续订阅会让 epoll 不断返回,事件循环空转。应先直接尝试写;仅当部分写或 EAGAIN 时把剩余数据放入用户态队列并订阅 EPOLLOUT,队列清空后立刻取消订阅。EPOLLOUT 只表示内核缓冲有空间,不代表对端收到数据。
Q:ET 下 accept 为什么也要循环到 EAGAIN?
监听 socket 的可读表示 accept queue 可能有连接。ET 通知一次后若只 accept 一个,队列仍非空却不会再产生 empty→nonempty 边沿,剩余连接可能一直不被处理。循环 accept 到 EAGAIN 才真正 drain 队列;使用 accept4 同时设置 NONBLOCK/CLOEXEC 可避免额外调用和竞态。
Q:什么是惊群?EPOLLEXCLUSIVE 与 SO_REUSEPORT 有何区别?
惊群是一个资源就绪唤醒大量竞争者,最终只有少数做有效工作,其余白白调度和抢锁。EPOLLEXCLUSIVE 在 epoll 等待队列层限制同一事件唤醒多个 exclusive waiter;SO_REUSEPORT 在监听 socket 层建立多个绑定同端口的 socket,让内核把新连接分流到各 worker。两者层次不同,也都不替代应用层连接状态同步。
Q:epoll 是异步 IO 吗?
不是。epoll 是 readiness 多路复用:epoll_wait 告诉应用 fd 现在可能读写,应用仍要随后调用 read/write 完成操作。异步 IO 是先提交具体 IO 请求,内核完成后从完成队列取结果。io_uring 是 Linux 上更接近后者的现代接口。
Q:Go net.Conn.Read 看着阻塞,为什么大量连接不需要大量线程?
runtime 把网络 fd 设为 nonblocking。read 遇到 EAGAIN 时只 park 当前 goroutine,并把 fd 的读等待登记到 Linux epoll;M/P 继续跑其他 goroutine。socket 就绪后 netpoller 从 epoll_wait 取事件并 goready 对应 G,再重试 read。应用 API 是阻塞式,底层是非阻塞加多路复用。
Q:epoll 返回很快但服务 P99 仍高,优先查什么?
先查事件循环是否真正获得 CPU:CPU run queue、cgroup cpu.stat throttling、GC、锁和调度延迟;再查 handler、TLS/编解码和下游;最后查发送队列、网络拥塞和对端读取。epoll 只解决“发现 fd 就绪”,不负责让应用 handler 更快,也不能突破 quota 和 CPU 总量。
Q:EMFILE 时为什么监听 fd 可能造成 busy loop?
连接已在 accept queue 中,所以监听 fd 持续可读;进程 fd 已满导致每次 accept 都返回 EMFILE,事件循环立刻再次被唤醒。常用预留 idle fd 技巧让服务临时 accept 并 close 一个连接以清除队首,但根本方案是提高/管理 fd 限额、限制连接和排查泄漏。
小结
- 阻塞/非阻塞描述一次 IO 调用是否等待;多路复用解决很多 fd 何时再试;异步 IO 则把提交和完成分离
- select 的关键瓶颈是每轮复制与 O(n) 扫描,1024 只是固定
fd_set的表面限制;poll 去掉容量限制但保留扫描 - epoll 在
epoll_ctl时长期注册兴趣,在状态变化回调中增量维护 ready list,避免稀疏就绪时扫描全部连接 - epoll 不是魔法 O(1):连接频繁增删、全部 fd 同时就绪、应用 handler 与网络栈仍然有真实成本
- LT 只要条件成立就持续提醒,容错好;ET 只报告边沿,必须对 nonblocking fd drain 到
EAGAIN EPOLLOUT只在用户态有积压发送数据时订阅;accept/read/write都要正确处理 EOF、EINTR、EAGAIN 和资源耗尽EPOLLEXCLUSIVE限制 epoll waiter 惊群,SO_REUSEPORT在监听层分流;它们不是同一个机制- Go 把同步
net.ConnAPI 建在 nonblocking fd、epoll、goroutine park/goready 之上,因此闲置连接不占一个内核线程 - 排查要从 listen queue、fd 限额、epoll 事件、CPU/quota、goroutine/handler、发送队列到网络链路逐层定位
下一篇讲 io_uring 与零拷贝 —— 共享环形队列如何绕开“每次 IO 一次陷入”、sendfile/splice 各省掉哪一次拷贝,以及 Go 的 io.Copy 在什么条件下走到内核零拷贝路径。
12. epoll 的生命周期陷阱
12.1 epoll 监控的是打开文件,不只是整数 fd
fd 是进程表下标,可以被关闭后迅速复用:
fd 42 -> socket A
close(42)
新连接 open -> 又得到 fd 42 -> socket B
应用事件结构里若只保存整数 42,一个延迟处理的旧事件可能被错认为属于 B。成熟事件循环会给连接对象加 generation/version,或让事件携带稳定对象指针,并在关闭时严格控制生命周期。
内核 epoll 兴趣项与 open file description 关联,dup() 出来的多个 fd 可能引用同一个底层 file。关闭一个 fd 不一定立即删除所有关联语义;应用不应依赖“close 任意副本就自动清理一切”,而要显式 EPOLL_CTL_DEL/统一 owner 并查阅 epoll 文档。
12.2 close 与并发事件处理
一个线程处理 fd,另一个线程 close 同一 fd,会引入:
- 旧事件仍在用户态 events 数组
- fd number 被新对象复用
- 当前 read/write 返回错误或作用于错误生命周期
- 连接对象被 free 后事件仍持有指针
常见设计是让每条连接归属一个事件循环线程,其他线程通过任务队列请求关闭;或用引用计数/epoch/generation 延迟回收。EPOLLONESHOT 可以避免同一 fd 同时被多个 worker 消费事件,但不能自动解决对象内存生命周期。
12.3 EPOLLHUP 与 EPOLLERR 不要漏
错误和 hangup 往往即使未显式订阅也会报告。处理事件时不能只看 EPOLLIN:
uint32_t e = events[i].events;
if (e & EPOLLERR) {
int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
close_connection(fd, err);
continue;
}
if (e & (EPOLLIN | EPOLLRDHUP))
drain_read(fd);
if (e & EPOLLOUT)
flush_write(fd);
EPOLLRDHUP 对 TCP 半关闭很有用:对端关闭写方向,本端仍可能需要发送剩余响应。最终仍要以 read()==0 确认 EOF,并按协议决定半关闭还是完全关闭。
13. 公平性与背压:事件循环不是越 drain 越好
13.1 ET 的 drain 与单连接霸占
ET 要读到 EAGAIN,但如果一个连接持续有数据,理论上可能一直读不到 EAGAIN,让其他 fd 饥饿。工程上常设置单轮预算:
每个 fd 每轮最多:
读 64KB,或
解析 100 条消息,或
占用 200us CPU
达到预算仍有工作:
把连接加入用户态 runnable queue
先处理其他 ready fd
这不是违反 ET,而是应用要保证自己会再次处理该连接;可能结合 oneshot/rearm、内部队列或读取上限。低延迟服务要同时考虑吞吐和 event-loop fairness。
13.2 接收背压
如果应用不断从 socket 读入用户态队列,但业务消费者跟不上,内存会无限增长。背压可分层实施:
用户队列达到 high watermark
-> 暂停订阅/处理 EPOLLIN
-> 让内核 receive buffer 填满
-> TCP advertised window 缩小
-> 对端发送被自然限制
队列下降到 low watermark
-> 恢复 EPOLLIN
高/低两个阈值避免在边界反复开关。停止读取并不是丢包,TCP 会通过窗口施加流控;但停太久会增加对端延迟和超时,必须有连接级上限与关闭策略。
13.3 发送背压
write 返回成功只表示字节进入本机发送缓冲,不代表已到对端。若对端读取慢,应用用户态发送队列持续增长:
- 设置每连接 pending bytes 上限
- 超过上限拒绝新消息、丢弃可丢数据或关闭慢客户端
- 仅有 pending 数据时订阅 EPOLLOUT
- 对协议设置写 deadline
WebSocket 推送、日志订阅和消息广播尤其容易被一个慢消费者拖垮。零拷贝也不会消除发送队列背压。
14. 触发模式之外的设计选择
14.1 单事件循环还是多事件循环
单 loop:
一个线程管理全部连接
优点:状态简单、无跨线程锁
缺点:一个慢 handler 阻塞所有连接,单核 CPU 上限
多 loop:
每个线程一个 epoll,连接分片
优点:利用多核、局部性好
缺点:负载均衡、跨 loop 消息和连接迁移复杂
acceptor + workers:
独立线程 accept,再把新 fd 分发到 worker epoll
优点:职责清晰
缺点:分发队列和跨 CPU 移交有成本
选择应基于 handler 是否 CPU 重、连接长短、NUMA 和负载均衡。不能用“epoll 单线程能十万连接”推导出“整个服务只需要一个线程”。
14.2 Reactor 与 Proactor
Reactor(epoll):
内核通知 readiness
应用收到事件后执行 read/write
Proactor(完成模型):
应用提交具体 read/write
内核完成后通知结果
两种模型的状态机不同。Reactor 要在 EAGAIN、partial IO、兴趣集合间管理状态;Proactor 要管理请求生命周期、completion、取消和 buffer 生命周期。io_uring 支持完成模型,也能做 poll/readiness 操作,因此现实实现可能混合两者。
15. 追加面试题
Q:fd 被 close 后复用,epoll 程序可能出现什么 bug?
用户态 events 数组中的旧事件仍携带原 fd number;该数字可能已指向新 socket,延迟 handler 若只按整数索引查连接,会把旧事件作用到新连接。应统一事件 loop 管理关闭、用 generation/version 校验连接对象并安全延迟回收,不能把 fd number 当永久身份。
Q:ET 为什么读到 EAGAIN 仍可能需要公平预算?
若连接持续到数据,drain 循环可能长时间不遇 EAGAIN,从而霸占事件线程。应用应限制单连接每轮字节、消息或 CPU 时间,并确保剩余工作进入自己的 runnable 队列或正确 rearm。ET 规定不遗漏边沿,不代表允许一个连接无限占用事件循环。
Q:网络服务如何把背压传回客户端?
业务队列达到 high watermark 后暂停继续读取,让内核 receive buffer 填满,TCP 窗口缩小并限制发送端;队列降至 low watermark 再恢复。发送方向则限制每连接用户态 pending bytes,慢客户端超过阈值时拒绝、丢弃或关闭。背压必须贯穿 socket、应用队列与下游,不能只扩大 buffer。
Q:EPOLLERR、EPOLLHUP 为什么不能只当成“关闭后忽略”?
它们可能与可读/可写同时出现,接收缓冲中仍有最后数据,半关闭时本端也可能仍要发送。应用应读取 SO_ERROR、drain 可读数据、用 read=0 判断 EOF,再按协议关闭。直接忽略会产生 busy loop,直接 close 也可能丢最后数据。
Q:Reactor 与 Proactor 的区别是什么?
Reactor 通知 fd 已就绪,应用随后调用 read/write;epoll 是典型 readiness reactor。Proactor 由应用先提交 IO,内核完成具体操作后返回 completion 和结果。前者管理 EAGAIN 和兴趣状态,后者管理请求、buffer、取消与完成生命周期。io_uring 更接近 completion 模型,但也支持 poll,现实系统可混用。
xingliuhua