目录

Linux-31 IO 模型演进:阻塞 → epoll

目录

上一篇讨论文件数据如何从页缓存到稳定介质。这一篇讨论另一条高频路径:网络连接上的数据什么时候可读、什么时候可写,以及一个进程如何同时管理十万条连接。

很多文章把 epoll 简化为“突破 select 的 1024 限制”。这几乎抓错了重点。FD_SETSIZE 可以重编译或用 poll 绕开,真正逼出 epoll 的是:每次等待都要把所有 fd 从用户态搬进内核,再由内核扫描一遍;即使只有一个连接有数据,也要检查十万个。

先看五个问题:

  1. 阻塞、非阻塞、IO 多路复用、异步 IO 分别解决什么,为什么不能混为一谈?
  2. select 的根问题为何是 O(n),而不是 1024?poll 改了什么,又没改什么?
  3. epoll 的红黑树和就绪链表分别干什么?为什么它能避免重复扫描?
  4. LT 和 ET 到底差在哪里?为什么 ET 必须读到 EAGAIN,却不必迷信它“永远更快”?
  5. 多线程 accept 为什么会惊群?EPOLLEXCLUSIVESO_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 listepoll_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:对端半关闭,EOF
  • n < 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_NONBLOCKFD_CLOEXEC,避免 accept 后再 fcntl 的竞态及额外 syscall。

7.4 EMFILE:满 fd 时别陷入错误风暴

进程到达 RLIMIT_NOFILEaccept 返回 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 -ltnRecv-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.Conn API 建在 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,现实系统可混用。