AI 资讯 知识库 操作系统内功操作系统内功 · 第 5 篇|IO 多路复用:从 select 1024 上限到 epoll 事件循环

操作系统内功 · 第 5 篇|IO 多路复用:从 select 1024 上限到 epoll 事件循环

1999 年,工程师 Dan Kegel 整理了一页后来影响深远的问题清单——C10K:一台机器服务一万个并发连接,为什么这么难?当年 Apache 的「每连接一线程」模型在一万连接面前内存与调度双双崩盘,而 select 系统调用还有个 1024 的硬上限。这个问题的答案最终改写了服务器世界的格局:Apache 的每连接一线程模型在一万连接前内存与调度双双见底(后来补的 event MPM 属于补丁式追赶),nginx 以每 worker 一个事件循环登场,Redis 用单线程事件循环扛住十万连接,Linux 内核则为此进化出 epoll。本篇沿着这条演进线把 IO 多路复用讲透:select 输在哪、epoll 赢在哪、惊群怎么治、io_uring 又在补什么。

一、select 的三宗罪与 poll 的改良

select 暴露给应用的是三个 fd 集合(读、写、异常),每次调用都要把整个集合从用户态拷进内核,内核线性扫一遍,返回后应用再线性扫一遍找出就绪者。三宗罪由此而来:一是 FD_SETSIZE 硬编码 1024——集合是固定大小的位图,想监听更多 fd 就得重编译改宏,man 页毫不客气地称其对现代应用是「不合理的低上限」;二是每次调用都要重建集合,状态无法跨调用积累;三是扫描与拷贝都是 O(n),一万连接时每次唤醒都要全量过一遍,大多数是白功。

poll 做了改良:fd 数组按需构造,1024 上限没了。但 O(n) 的命运没有变——每次调用仍是全量拷贝加全量扫描。C10K 的症结就在这里:事件就绪的代价不应与连接总数成正比,而应与「真正发生事件的连接数」成正比。要打破这个正比,内核必须替应用记住「都在等什么」——这正是 epoll 的设计起点。

二、epoll 的两件套:红黑树与就绪链表

epoll 把「监听状态」从一次性调用改成了持久对象。epoll_create 建一个实例,epoll_ctl 增删改关注的 fd——内核用红黑树组织这份关注清单,增删查 O(log n),状态跨调用持久;事件发生时内核把对应 fd 挂进就绪链表,epoll_wait 只取链表、不看全量。于是 O(n) 变成了 O(1)(就绪数 n 是事件数,不是连接数),十万连接下也只处理活跃的那几百个。代价同样清楚:每个注册的 fd 约占 90 字节(32 位内核)到 160 字节(64 位内核)内核内存,受 fs.epoll.max_user_watches 系统参数限额——这是「内核替你记账」的存储成本。

graph LR
  A[应用 epoll_ctl] -->|注册与删除关注| B[内核红黑树 interest list]
  C[网卡数据到达] -->|内核回调| D[就绪链表 ready list]
  B -->|事件就绪时挂入| D
  D -->|只返回就绪者| E[应用 epoll_wait]

这套结构解释了 epoll 的一个使用纪律:fd 的注册变更要走 epoll_ctl 而非每次重建,就绪集合也不需要应用自己再过滤一遍「哪个 fd 活了」——内核已经替你过滤完。nginx、Redis、Java NIO(Linux 上的 EpollSelector)、Go 的 netpoller,全是这套两件套的不同包装。

三、LT 与 ET:两种敲门方式

epoll 有两种触发语义。LT(水平触发,默认):只要 fd 仍处于就绪状态,每次 epoll_wait 都会重复通知——语义与 poll 一致,man 页称之为「更快的 poll(2)」,用错了顶多浪费点 CPU。ET(边缘触发,EPOLLET):只在状态变化的瞬间通知一次——从无数据到有数据敲一次门,之后数据攒着不再提醒。ET 把通知次数压到最低(高吞吐服务的心头好),但纪律严苛:man 页明文要求 ET 必须搭配非阻塞 fd,且必须循环读写直到返回 EAGAIN——因为通知只有一次,剩下的数据全靠你自己读完,读一半留着,下一次唤醒可能永远不会来。流式 socket 读到「返回量小于请求量」即可确认读尽,数据报型文件则必须读到 EAGAIN。工程上的选择通常是:ET 配合现成的事件循环框架(nginx、Netty 都已封装好纪律),自研代码用 LT 更稳。

四、惊群与三代解法

多个 worker 进程等待同一个监听 socket,新连接抵达瞬间全体被唤醒、却只有一人 accept 成功,其余人白醒——这就是惊群(thundering herd),连接风暴时白耗的唤醒成本相当可观。三代解法各有取舍。第一代是应用层互斥:nginx 的 accept_mutex 让同一时刻只有一个 worker 把监听 fd 挂进自己的 epoll(1.11.3 之前默认开启);代价是连接分发略不均,官方文档在内核支持就绪后劝大家关掉它。第二代是内核独占唤醒:EPOLLEXCLUSIVE(Linux 4.5,2016)让内核只唤醒一个等待者,官方新特性页引用的改版负载从 860 秒降到 24 秒;Cloudflare 的生产负载均衡代码用的正是它。第三代釜底抽薪:SO_REUSEPORT(Linux 3.9,2013)允许多个 socket 绑定同一端口,内核按四元组哈希把连接直接分流到各 socket——没有共享队列就没有惊群;nginx 1.9.1 起支持 reuseport,通常与 worker_processes auto 搭配——每个 worker 独立 socket 独立队列,连接在内核态就完成分片,CPU 缓存亲和也顺带改善。它的坑也在共享的反面:组内 socket 关闭(平滑重启场景)可能让已分流的连接吃到 RST,LWN 有专文分析。选型口诀:多进程共享监听选 EPOLLEXCLUSIVE,追求分片隔离与多线程扩容选 reuseport。

五、单线程的胜利:Redis 的事件循环与 threaded I/O

Redis 是 epoll 哲学的极致展示:命令执行基本单线程,快的原因官方文档概括为三件事——内存级操作、epoll 事件循环、没有锁与线程切换开销。单线程还白送两个好处:命令天然原子、延迟分布稳定可预测。代价同样鲜明——一个慢命令拖住所有人:对百万元素的 key 执行 DEL 会阻塞整个实例,于是 Redis 才有了 UNLINK(后台异步回收)这类「把脏活外包给卸载线程」的命令。用 Redis 的性能心智模型可以总结成一句话:执行路径上的每一步都要短,长活外包给后台。当 2020 年 Redis 6.0 引入 threaded I/O 时,社区的第一反应是「终于要变多线程了」——其实不是:命令执行依然单线程,线程只用来分担网络 socket 的 read/write 系统调用,把「内核态收发包」这个 IO 密集段并行化。

更反直觉的是默认值:io-threads 默认 1——等于关闭,io-threads-do-reads 默认 no(线程默认只用于写回复,因为典型瓶颈在写出方向),官方注释直言多数负载下 threaded I/O「通常不值得」。直到 Redis 8(2025 年 GA)重写了 IO 线程实现,官方才开始建议多核机器设 8。这段历史对架构师的启示比参数本身值钱:瓶颈在 IO 时加线程,瓶颈在 CPU 时才考虑并行计算——Redis 十年不缺并行能力,缺的只是能被并行加速的瓶颈。至于 io_uring:社区 PR 至今未合并,Redis 的选择是继续把 epoll 用到极致。

六、io_uring:事件循环的下一章

epoll 只解决了「网络事件通知」,每次读写仍是一次同步系统调用。io_uring(Jens Axboe 设计,Linux 5.1,2019 年 5 月)把异步做成了机制本体,配套的用户态库 liburing 把 mmap 环缓冲、注册 fd 这些样板代码封装掉,入门成本已大幅下降:用户态与内核共享一对环形缓冲——SQ(提交环)与 CQ(完成环),应用把 IO 请求排进 SQ、从 CQ 收割完成事件,多数情况下提交与收割无需陷入内核,连系统调用的开销都省了。5.1 首发支持 readv/writev/fsync/poll,5.2–5.6 快速补齐 sendmsg/accept/openat 等全家桶,LWN 专文称之为「爆发式增长」。

它的现实很骨感。性能上与 epoll 的对比没有普适结论:社区实测显示纯网络代理场景 epoll 多数更好,io_uring 的优势集中在高并发加批处理加 SQPOLL 的组合场景——「更快」必须按负载说。安全上倒是结论清晰:Google 的内核漏洞研究团队统计,42 个提交的内核漏洞里 60% 出自 io_uring,处置是 ChromeOS 完全禁用、Android 用 seccomp 挡住;内核 6.2 顺势引入 kernel.io_uring_disabled 系统参数(0 默认放行、1 仅特权、2 完全禁用),RHEL 9.4 等发行版已回移植收紧策略。给工程师的实用判断:io_uring 是存储与代理类服务值得跟进的方向,但在生产网络服务里启用前,先确认你的发行版与安全基线是否还允许它。

七、工程落地清单

  1. 新代码直接 epoll(或框架封装),别再碰 select。poll 也只留给兼容场景;1024 上限的教训写进代码评审清单。
  2. ET 只配现成事件循环。自研就用 LT,把「循环读到 EAGAIN」的纪律交给被验证过的框架。
  3. 惊群按拓扑选解法。多进程共享监听用 EPOLLEXCLUSIVE,多线程或多实例用 SO_REUSEPORT,注意 reuseport 与平滑重启的 RST 坑。
  4. Redis 别急着开 io-threads。先确认瓶颈真在网络收发:官方默认关闭的态度就是基准线。
  5. io_uring 上线前过一遍安全基线。确认发行版策略(kernel.io_uring_disabled)与容器 seccomp 允许它。
  6. 监听队列与 accept 速率一起看。somaxconn、应用 backlog 与 accept 告警是惊群与过载的联合诊断线。

结语

从 select 到 epoll 再到 io_uring,二十五年演进的逻辑一以贯之:把「等」的成本与连接总数解耦——select 让应用等,epoll 让内核帮忙等,io_uring 让等待连系统调用都省掉。C10K 早已不是问题,C10M 靠的也是同一套逻辑的极致化。理解了事件循环,你就理解了 nginx、Redis、Netty 的骨架;下一篇我们看 IO 的另一半优化——当数据只是「路过」你的服务器时,连内核拷贝都可以省掉:零拷贝。

参考资料

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?