Linux IO 模型纵深解析 05:epoll 的诞生
epoll 的诞生 —— 为高并发而生的事件驱动 IO 模型
Key Words:epoll、eventpoll、epitem、wait_queue(等待队列)、RB-tree(红黑树)、Ready List、非阻塞 IO、事件驱动、水平触发(LT, Level Triggered)、边沿触发(ET, Edge Triggered)
前一篇已经讨论了 select / poll。
👉点击阅读:# Linux IO 模型纵深解析 04:select & poll
今天,如标题所述要讨论的是 epoll:
在 Linux 的 IO 演进史里,epoll 并不是一个“更快的 poll”,而是一次对 IO 关注点本身的重构。select / poll 时代,内核被迫在“你关心哪些 fd”这个问题上反复被动扫描;epoll 出现之后,问题被翻转成了——状态变化发生时,由内核主动通知用户态。这一转变,直接决定了现代 Linux 高并发网络程序的基本形态,也解释了为什么 Nginx、Redis、muduo、libevent 乃至几乎所有高性能网络库,都无一例外地围绕 epoll 构建事件循环。
这篇文章在整个《Linux IO 模型纵深解析》系列中,承担的是现代 Linux 高并发 IO 的核心模型篇角色。讨论的重点不会停留在“epoll 比 poll 快”这种经验结论上,而是顺着一条明确的纵向穿梭线,从用户态 API 一路走进内核对象,再落到 CPU 与内存层面的真实动作上:
从 epoll_create / epoll_ctl / epoll_wait 这组看似简单的接口出发,拆解内核里为它们服务的 eventpoll、epitem、wait_queue 等核心结构,顺藤摸瓜进入 sys_epoll_wait -> do_epoll_wait 的调度路径,最终落在驱动层唤醒、等待队列回调、就绪事件入队这些“真正发生事情”的地方。
在这个过程中,会反复穿插三个层面的内容埋点:
一是结构体解构,但不是字段罗列,而是站在设计者视角解释为什么要这样组织状态、链表和红黑树;
二是源码考古,指出 epoll 从 2.6 初登场到 5.x/6.x 在可扩展性和锁粒度上的演化痕迹;
三是工程范式,结合主流高性能网络库,讨论边沿触发、水平触发、one-shot、惊群规避这些“坑为什么会存在”。
epoll 解决的从来不只是性能问题,它牵扯的是一整套事件驱动编程模型的成立条件:
为什么 fd 必须是非阻塞的,为什么回调驱动天然要求状态机,为什么“读到 EAGAIN”为何不是错误而是协议的一部分。这些问题一旦只从 API 文档理解,几乎必踩坑;只有把目光下探到内核,才能明白它们背后的必然性。
[!TIP]
epoll 的真正价值不在于“避免 O(n) 扫描”,而在于把 IO 就绪的判定时机,从用户态轮询,迁移到了内核态的状态变更瞬间。这是模型层面的变化,而不是实现层面的优化。
文章中会明确标出调用流程图与数据结构交汇点,例如:sys_epoll_wait -> do_epoll_wait -> ep_poll_callback 这条路径上,事件是如何从设备驱动一路冒泡到用户态的;
也会在关键位置开启技术小支线,讨论如 ready list 与 rb-tree 并存的历史包袱、wait_queue 设计的锁权衡、以及 epoll 在多核场景下的 cache line 影响。
文章目录
-
- epoll 的诞生 —— 为高并发而生的事件驱动 IO 模型
- 1. epoll 的设计动机与问题背景
- 2. epoll 的系统调用接口设计
- 3. epoll 内部数据结构与对象关系
- 4. epoll 的回调与就绪通知路径
-
- 4.1 中断驱动 IO 思想的自然延续
- 4.2 核心论点一:epoll 的真正威力来自回调注册,而不是等待本身
- 4.3 核心论点二:事件是在 IO 就绪时被推送的,而不是在 epoll_wait 中被发现的
- 4.4 核心论点三:wait queue 从睡眠工具升级为事件分发机制
- 4.5 架构细节对照:fs/select.c vs fs/eventpoll.c
- 4.6 epoll 的对象:eventpoll / epitem / eppoll_entry 到底长什么样
- 4.7 回调到底是谁注册的:epoll_ctl(ADD) → file->poll → poll_wait
- 4.8 回调到底是谁调用的:驱动/协议栈 → wake_up → wait_queue_entry->func
- 4.9 `ep_poll_callback()` 到底干啥:把 epitem 推进 rdllist,然后叫醒 epoll_wait
- 4.10 `epoll_wait()` 为什么不用“发现”:它只是在消费 rdllist
- 下一节预告
- 5. epoll 的使用模式(LT/ET)与常见陷阱
1. epoll 的设计动机与问题背景
IO 等待模型演进层(select / poll → 事件驱动)
1.1 高并发网络服务的时代背景
一句话点破背景:不是 Linux 写 IO 慢了,而是网络服务的并发模型彻底变了。
在很长一段时间里,Unix 世界里的 IO 模型并没有“高并发”这个硬指标。早期的服务端程序,要么一进程一连接,要么一线程一连接,连接数停留在几百这个量级,select 能跑,poll 也能撑。直到 Web 服务、IM、游戏服务器开始出现“单机支撑成千上万长连接”的需求,问题才真正暴露出来。
C10K 问题并不是某篇论文凭空制造的焦虑,而是实实在在的工程现实:当连接数达到几千甚至上万,CPU 时间开始大量浪费在“我在检查谁就绪了”这件事上,而不是“真正处理已经就绪的连接”。这不是网卡慢、不是内存小,而是 等待模型本身在吞噬 CPU 周期。
1.2 select / poll 的等待路径回顾
select 和 poll 在设计上有一个共同点:等待的主动权始终在用户态。
应用线程每一轮循环都会做三件事:
把“我关心的 fd 集合”交给内核 → 内核逐个检查这些 fd 的状态 → 把结果拷贝回用户态。
这里的关键不在 API 细节,而在于一个无法回避的事实:
内核根本不知道你上一次已经检查过谁,也不知道哪些 fd 近期根本不可能就绪。
它只能老老实实地,从头扫到尾。
poll 相比 select 的改进,本质上只是把 fd_set 换成了 pollfd 数组,解决了 fd 数量上限和位图操作不友好的问题,但 扫描这件事一行都没少。O(n) 的复杂度并不是实现疏忽,而是模型决定的。
1.3 epoll 的总体思路预览
epoll 的设计者在一开始就绕开了“如何更快地扫描 fd”的死胡同,而是换了一个问题:
为什么一定要在等待时扫描?
为什么不能在状态变化发生的那一刻,直接把“值得处理的 fd”记录下来?
这就是 epoll 的出发点。
不是“等你来问”,而是“事情发生了我再叫你”。
在这个模型里,“谁需要被检查”这个决策不再发生在 epoll_wait 调用期间,而是发生在 fd 状态变化的瞬间,发生在驱动唤醒、socket 缓冲区变化、等待队列回调的那一刻。
[!NOTE]
epoll 解决的问题从来不是“监听更多 fd”,而是避免对绝大多数“当前不可能就绪的 fd”做无意义检查。
1.4 核心论点一:epoll 并不是为“监听更多 fd”而生,而是为彻底避免无意义的线性扫描
select / poll 的性能瓶颈,并不取决于“真正就绪的 fd 有多少”,而取决于“你一共监听了多少 fd”。哪怕这一轮只有一个连接可读,内核仍然要把所有 fd 过一遍。这种扫描的本质是 对未知状态的盲目确认。
epoll 的关键变化在于:
内核不再在等待阶段判断状态,而是在状态变化阶段做标记。
当一个 socket 从“不可读”变为“可读”时,这个变化并不是在 epoll_wait 里才被发现的,而是在数据到达、协议栈处理完成、socket 状态更新的那一刻,沿着等待队列回调路径,被直接“推送”进 epoll 的就绪队列。
这意味着一件很重要的事情:epoll_wait 本身不再承担“检查”的职责,它只负责 消费已经被确认过的结果。
从复杂度角度看,epoll 的时间消耗与“本轮有多少就绪事件”相关,而不是与“你监听了多少 fd”相关。这个差异在高并发、低活跃场景下尤为致命:成千上万长连接,大多数时间是空闲的,select / poll 却要为它们反复付出扫描成本。
这不是微优化,而是 把 CPU 时间从“无意义确认”转移到“真实业务处理”上的一次根本性迁移。
[!IMPORTANT]
epoll 的性能优势来自于“避免做事”,而不是“把事情做快”。
1.5 核心论点二:epoll 的本质是等待模型的反转
select / poll 的语义可以总结为一句话:
“我现在来问一遍,看看谁准备好了。”
epoll 的语义则完全不同:
“当你准备好的时候,记下来,等我来取。”
这不是 API 使用方式的变化,而是 控制权的转移。
在 select / poll 模型中,应用线程是主动轮询者;在 epoll 模型中,应用线程变成了被通知者。
这个反转带来的影响非常深远:
内核开始长期持有“你关心哪些 fd”的上下文,而不是每次系统调用临时传入;
fd 的状态变化不再被动等待用户态触发,而是通过回调机制即时捕获;
等待行为从“周期性扫描”变成了“事件驱动”。
因此,epoll 并不是 select 的“升级版”,而是一套 全新的等待语义。如果仍然用“poll faster”来理解 epoll,很多使用上的坑是必然会踩的,比如阻塞 fd、错误的 ET 使用方式、无视 EAGAIN 的循环读写。
[!NOTE]
这是语义级的变化,而不是实现级的优化。
1.6 核心论点三:从轮询时代进入事件驱动时代
当并发规模从 C10K 走向 C100K,轮询模型的局限开始全面暴露:CPU cache 被反复污染,系统调用频率居高不下,线程调度成本被无限放大。
epoll 的出现,第一次让 Linux 拥有了 工程上可落地的事件驱动 IO 模型。
这为后续一整套编程范式奠定了基础:Reactor 模式、事件循环、状态机驱动逻辑,开始成为高性能网络程序的默认选择。
这也是为什么 Nginx、Redis、muduo 等项目,并不是“选择了 epoll”,而是 被 epoll 的模型塑造了整体架构。线程模型、连接管理、缓冲区设计,全部围绕“事件到来才做事”展开。
轮询时代关注的是“我现在能不能干活”;
事件驱动时代关注的是“什么时候值得我醒来”。
[!Supplement]
为什么单纯“优化 poll”是行不通的因为 poll 的瓶颈不在数据结构、不在位操作,而在语义本身。只要“等待”这件事仍然发生在用户态主动发起的那一刻,内核就无法提前知道哪些 fd 值得关注。扫描是不可避免的,最多只能做得“稍微没那么慢”。
[!Question]
为什么 epoll 要求 fd 通常是非阻塞的?
如果在事件驱动模型中一次 read 阻塞住,会发生什么?
2. epoll 的系统调用接口设计
系统调用层 × 用户态使用模型
2.1 Unix “一切皆文件”思想的自然延伸
一句背景定调:epoll 的 API 形态不是偶然,而是 Unix 文件抽象一路演化下来的必然结果。
在 Unix 的设计哲学里,“一切皆文件”从来不只是句口号,它意味着:只要某个对象需要被内核管理生命周期、参与调度、被复用,那么它最终都会被塑造成一个 struct file,被 fd(file descriptor,文件描述符)所引用。socket 是这样,eventfd 是这样,epoll 也不例外。
这直接决定了一个事实:epoll 不可能被设计成 select / poll 那种“每次调用顺带传一堆参数”的接口形式。因为 它本身就是一个需要长期存在的内核对象,而不是一次等待行为的附属品。
2.2 核心论点一:为什么监听集合的维护必须与等待过程解耦
select / poll 的接口形态,本质是一种“一次性参数传递模型”:
监听哪些 fd、监听什么事件、等待多长时间,全部塞进一次系统调用里。
这种模型在并发规模较小时还能接受,但一旦 fd 数量上来,就会暴露出两个无法修补的结构性问题。
第一,监听集合是瞬时的。
每次调用 poll,用户态都要重新构造一份 pollfd 数组;内核态也无法持久保存任何关于“你长期关心谁”的信息。下一次调用,所有上下文全部丢失,只能从头再来。
第二,等待与管理强耦合。
poll 既承担“我关心谁”,又承担“我现在等结果”。这意味着只要你想调整监听集合,就必须重新进入等待流程;而等待流程本身,又必须重新扫描完整集合。
epoll 的设计从一开始就明确拆掉了这种耦合。
监听集合的维护,是一个长期状态管理问题;
等待就绪事件,是一个短期调度问题。
两者混在一起,只会导致接口臃肿、内核状态无法复用。
因此,epoll 被刻意拆成了三类系统调用:
`epoll_create` 负责创建并初始化一个监听上下文;
`epoll_ctl` 专注于“监听关系”的增删改;
`epoll_wait` 只做一件事——等待已经被内核确认的就绪事件。
[!NOTE]
epoll 的 API 拆分不是“多此一举”,而是为了让“监听集合”从一次性参数,升级为内核长期维护的状态。
2.3 核心论点二:epoll_fd 是一个内核对象,而不是临时参数
很多人在第一次用 epoll 时,容易忽略一个细节:epoll_create 返回的是一个 真正的 fd。
这意味着什么?
意味着 epoll 实例拥有完整的生命周期,可以被复用、被传递、被关闭;
意味着内核可以在这个 fd 背后,挂载复杂的数据结构、锁、等待队列;
也意味着 epoll 本身可以参与到“文件系统级别”的资源管理中。
相比之下,select / poll 的监听集合只是系统调用参数,调用一结束,内核就没有任何理由记住它们。这也是为什么 poll 永远不可能做到“事件发生时再通知你”——因为它连“你是谁、你关心谁”都记不住。
epoll_fd 的存在,让“等待上下文”第一次成为了内核中的一等实体。
你可以在主线程创建 epoll 实例,在工作线程里调用 epoll_wait;
你可以把 epoll_fd 封装进 Reactor 对象,伴随整个服务进程的生命周期;
你甚至可以在 fork 之后让子进程继承 epoll 实例,继续监听同一批 fd。
这些能力,全部建立在一个前提之上:epoll 不是一次调用,而是一个长期存在的内核对象。
[!IMPORTANT]
一旦 epoll 被设计成“对象”,select / poll 那种临时参数模型在语义上就已经出局了。
2.4 核心论点三:epoll_ctl 的存在,让“监听关系”成为一等公民
在 poll 的世界里,“监听关系”是非常廉价的。
一个 pollfd 结构体,只在本次系统调用中有效,调用结束就被丢弃。内核不会、也没法为它建立任何长期索引。
epoll_ctl 的引入,彻底改变了这一点。
当你调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event) 时,内核做的不是“记住这次调用的参数”,而是 在 epoll 实例和目标 fd 之间,建立一条稳定的关系。这条关系会一直存在,直到你显式删除,或者其中一方生命周期结束。
这意味着:
监听关系本身可以被内核管理、索引、回调;
fd 的状态变化可以精准地触发“对应的 epoll 实例”;
而不是像 poll 那样,每次都重新猜测“这次你关心的是不是我”。
从设计视角看,这是一种非常激进但极其必要的决策。它把“监听”从函数参数,提升成了内核内部的图结构关系,为后续的事件回调、就绪队列、O(1) 行为打下了基础。
[!NOTE]
epoll_ctl 的真正意义,不在于“增删 fd”,而在于让监听关系拥有持久身份。
2.5 架构细节预览(不展开实现)
从源码角度看,这一整套接口最终都落在同一个文件中:fs/eventpoll.c。
在这个文件里,epoll 的世界被拆成了两个核心角色:struct eventpoll,代表一个 epoll 实例;struct epitem,代表“某个 fd 与某个 epoll 实例之间的监听关系”。
这两者的具体字段、组织方式和锁策略,后面会单独展开;在这一节,只需要记住一个设计事实:
epoll 不是“监听 fd 的工具”,而是“管理监听关系的系统”。
[!NOTE] epoll 仅限 Linux
正如我在这一系列的上一篇文章所述: epoll 是 Linux 为了实现高并发而做出的模型,而 select / poll 由 POSIX 标准定义的,他们是所有 Unix 和 Unix-like 系统所需要实现的,众所周知 macOS 是严格的类 Unix 系统,自然在 Linux 上写的 select / poll 代码可以跨 macOS Linux 等 Unix 世界的平台。但是epoll是 Linux 单独为了高并发而自身做出的平台级的模型相关代码不可跨平台。其他平台需要对应平台做出相关优化,如 macOS 的 kqueue
[!TIP]
为什么 epoll_fd 也可以被 close因为它本质上就是一个文件描述符,背后挂着
struct file和struct eventpoll。
一旦close(epfd)被调用,内核会回收整个 epoll 实例,并自动拆除所有监听关系。int epfd = epoll_create1(0);这也是为什么工程中通常会把 epoll 的生命周期与 Reactor 对象强绑定。
下一节预告
下一节将真正深入内核,拆解 epoll 是如何在 fd 与 epoll 实例之间建立关系的,以及为什么需要 epitem 这种中间结构。
3. epoll 内部数据结构与对象关系
内核数据结构层(eventpoll 子系统)
3.1 Linux 内核中红黑树的通用用途
先把一个常见误区摊开讲清楚:epoll 用红黑树,并不是因为红黑树“高级”。
在 Linux 内核里,红黑树(Red-Black Tree,红黑树)是一个被高度复用的通用容器,用来解决一类非常具体的问题:在动态变化的集合中,快速查找、插入、删除某个元素,并且保持操作复杂度稳定。VMA(虚拟内存区域)管理用它,定时器子系统用它,epoll 也只是又一个“合理的使用者”。
因此,在 epoll 的语境下,红黑树只承担“如何管理一堆监听对象”的角色,而不是 epoll 的灵魂。
3.2 核心论点一:epoll 的核心不是红黑树,而是“fd ↔ epoll 实例”的关系建模
如果把 epoll 的实现翻开来看,真正值得反复琢磨的,并不是 rb_insert_color 这种通用算法,而是 struct epitem 的存在本身。
在 select / poll 模型里,“监听关系”是没有实体的。
用户态告诉内核:“这次我关心这些 fd”,内核照单全收,调用结束,关系消失。内核从来不需要为“监听关系”分配对象、维护状态。
epoll 的世界里,这种思路彻底失效了。
一旦监听集合变成持久状态,内核就必须回答一个问题:
“某个 fd 是被哪些 epoll 实例监听的?”
以及反过来:
“某个 epoll 实例当前监听了哪些 fd?”
这已经不是一个“数组参数”能解决的问题,而是一张关系网。
struct epitem 就是这张关系网的最小建模单元。
它既不等同于 fd,也不等同于 epoll 实例,而是专门代表“fd 与某个 epoll 实例之间的一次监听关系”。
这个设计非常关键,因为它直接支撑了后续的一切能力:
一个 fd 可以被多个 epoll 实例监听;
一个 epoll 实例可以监听成千上万个 fd;
监听关系可以被独立地增加、删除、修改,而不影响其他关系。
换句话说,epoll 的核心不是“用什么数据结构存 fd”,而是 把监听关系显式建模成内核对象。红黑树只是用来组织这些对象的一种手段。
[!NOTE]
epitem 的存在,标志着“监听关系”在内核中第一次拥有了独立身份。
3.3 核心论点二:红黑树解决的是“管理”,就绪链表解决的是“交付”
epoll 内部同时维护两套结构,这个设计初看之下显得“有点浪费”,但实际上是一个非常清醒的职责划分。
红黑树,解决的是长期管理问题。
它承载的是“这个 epoll 实例目前监听了哪些 fd”,支持按 fd 快速定位对应的 epitem,用于 epoll_ctl 的增删改查。
就绪链表(通常被称为 ready list 或 rdllist),解决的是运行时交付问题。
它只关心一件事:哪些事件已经发生,值得被用户态立刻处理。
这两类需求在时间尺度上是完全不同的。
监听集合的变更是低频操作,而事件就绪是高频操作;
监听集合强调“可管理性”,而事件交付强调“低延迟”。
如果试图用一套数据结构同时承担这两种职责,就会出现典型的两难:
要么为了查找方便牺牲事件插入性能;
要么为了快速投递事件牺牲管理能力。
epoll 的选择非常直接:拆开来,各司其职。
当 fd 状态发生变化时,对应的 epitem 会被挂到就绪链表上;
当用户调用 epoll_wait,内核只需要遍历就绪链表,而不是扫描整个监听集合。
这也是为什么 epoll 的等待复杂度与“监听数量”彻底解耦,而只与“就绪事件数量”相关。
[!IMPORTANT]
epoll_wait 从来不碰红黑树,它消费的是已经被内核确认的“结果集”。
3.4 核心论点三:epoll 用空间换时间,是一次明确而激进的工程决策
从内存视角看,epoll 的确比 poll “重”。
每一个监听关系,都要分配一个 epitem;
epoll 实例本身也要维护红黑树、链表、锁、等待队列。
但这是一笔非常清楚的账。
poll 每次调用都要把用户态的 pollfd 数组拷贝进内核,再拷贝回用户态;
当 fd 数量巨大时,这个拷贝本身就成了不可忽视的成本;
更糟的是,这些数据大多是“冷数据”,每次都要重新加载进 CPU cache。
epoll 的策略正好相反:
监听关系只在建立和变更时付出成本;
运行时尽量只处理“真正发生变化的那一小撮 fd”。
这意味着内核愿意多吃一点常驻内存,来换取运行时路径的极致简化。
在高并发网络服务这种“连接多、活跃少”的典型负载下,这个妥协几乎是单向碾压。
[!NOTE]
epoll 的空间开销是显性的,但 poll 的时间浪费是隐性的,而且会随着并发线性放大。
3.5 架构细节预览
从 fs/eventpoll.c 的视角看,可以用一句话概括对象关系:
一个 eventpoll 实例,拥有一棵管理 epitem 的红黑树,
和一条承载就绪事件的链表rdllist (ready double linked list 双向循环链表);
每一个 epitem,连接着一个具体 fd,并被挂接到对应的等待队列上。
这些关系在内核中形成了一张稳定的图结构,为后续“事件推送”机制提供了基础支点。
[!Supplement]
为什么 epoll 要维护两个数据结构因为“管理”和“交付”在访问模式、频率和性能目标上完全不同。
用一套结构硬扛两种需求,最终只会在两个方向上都不及格。
4. epoll 的回调与就绪通知路径
驱动 / 协议栈 → wait queue → eventpoll
4.1 中断驱动 IO 思想的自然延续
epoll 并没有发明“事件”,它只是把内核里早就存在的中断 / 唤醒路径,系统性地暴露给了 IO 等待模型。
在块设备、网络设备这些子系统里,“事情发生时通知上层”从来就不是新鲜事。中断触发、软中断处理、协议栈推进、唤醒等待者,这条路径在 Linux 里已经被打磨了几十年。epoll 真正做的,是把这套机制,从“进程是否该被唤醒”,升级成“哪个 fd 的哪个事件发生了”。
4.2 核心论点一:epoll 的真正威力来自回调注册,而不是等待本身
如果只盯着 epoll_wait 看,很容易误判 epoll 的重心。
事实上,epoll 的关键动作,发生在 epoll_wait 被调用之前,甚至发生在 epoll_ctl 阶段。
核心角色是一个老面孔:poll_table。
在 Linux 内核里,poll/select/epoll 并不是完全割裂的体系。它们共享一套“poll 语义”:
当内核想知道一个 fd 是否就绪时,会调用对应文件操作里的 poll 回调(file_operations->poll),而 poll_table 就是用来 在这个过程中注册回调关系 的工具。
select / poll 的做法很朴素:
构造一个 poll_table → 传给各个 fd 的 poll 函数 → fd 把当前进程挂进自己的 wait queue → 等待被唤醒。
epoll 在这里走了一条完全不同的路。
当你调用 epoll_ctl(ADD) 时,内核并不是简单地“记下这个 fd”,而是 主动调用一次该 fd 的 poll 函数,并通过一个特殊的 poll_table,把 ep_poll_callback 注册进目标 fd 的等待队列。
从这一刻开始,epoll 就不再是旁观者了。
它已经把“当这个 fd 状态变化时,请通知我”这件事,提前埋进了内核的唤醒路径里。
[!IMPORTANT]
epoll_ctl 阶段完成的不是“登记”,而是回调关系的建立。
4.3 核心论点二:事件是在 IO 就绪时被推送的,而不是在 epoll_wait 中被发现的
这是理解 epoll 的分水岭。
在 select / poll 模型中,事件的“发现时刻”发生在系统调用内部:
内核扫描 fd → 判断状态 → 决定返回哪些就绪项。
在 epoll 模型中,这个逻辑被彻底反转。
当网卡收到数据,中断触发;
协议栈处理数据,把 payload 放进 socket 接收队列;
socket 状态从“不可读”变为“可读”;
然后,等待队列上的回调被触发。
这时,ep_poll_callback 被调用。
它并不是在“判断要不要唤醒进程”,而是在做一件更具体的事:
把对应的 epitem 放入 epoll 实例的就绪链表中,并在必要时唤醒正在 epoll_wait 的线程。
所以,当用户态调用 epoll_wait 时,内核并不会“检查 fd”。
它只是看一眼:就绪链表里有没有东西。
有,就拷贝给用户;没有,就睡。
[!NOTE]
epoll_wait 是一个“结果消费函数”,而不是“状态探测函数”。
4.4 核心论点三:wait queue 从睡眠工具升级为事件分发机制
在早期内核设计里,wait queue(等待队列)解决的是一个非常单纯的问题:
谁在等,事情发生了就把谁叫醒。
select / poll 基本停留在这个层面:
进程睡在 wait queue 上,被唤醒后再去扫描 fd,判断到底发生了什么。
epoll 对 wait queue 的使用,明显更“重”。
通过 ep_poll_callback,wait queue 不再只是“唤醒某个 task_struct”,而是承担了 事件上报的第一跳。
回调函数在被调用时,已经明确知道:
是哪个 fd;
发生了哪类事件(读、写、异常);
需要投递给哪个 epoll 实例。
这意味着事件在进入用户态之前,已经被 精准路由 了一次。
wait queue 从“调度工具”,变成了“事件分发管道”。
这也是为什么 epoll 可以把“唤醒”和“事件收集”合并成一个动作,而不是像 poll 那样唤醒之后再全量扫描。
4.5 架构细节对照:fs/select.c vs fs/eventpoll.c
看源码时,一个非常直观的对比点在于:fs/select.c 里,poll_table 的作用基本止步于 add_wait_queue;
而 fs/eventpoll.c 里,poll_table 被定制化,用来绑定 epitem 与回调函数。
关键函数 ep_poll_callback,正是这个设计的落点。
谁注册回调?——epoll_ctl 阶段,由 epoll 主动注册;
谁调用回调?——fd 所属子系统,在状态变化时触发 wait queue;
谁消费结果?——epoll_wait。
整个链路是自底向上、事件驱动的,而不是自顶向下、轮询驱动的。
4.6 epoll 的对象:eventpoll / epitem / eppoll_entry 到底长什么样
fs/eventpoll.c 里,epoll 不是一个“算法”,而是一组对象在协作。
4.6.1 struct eventpoll:一个 epoll 实例的内核实体
epoll_create1() 返回的 epoll_fd,背后对应的核心对象就是 struct eventpoll,它负责两件事:管理监听集合 + 保存就绪队列。
/* fs/eventpoll.c */
struct eventpoll {
spinlock_t lock; /* 保护就绪队列/关键状态 */
struct mutex mtx; /* 保护监听集合的修改路径(epoll_ctl) */
struct rb_root_cached rbr; /* 监听集合:fd -> epitem 的索引树 */
struct list_head rdllist; /* 就绪链表:已经就绪、等待交付的 epitem */
wait_queue_head_t wq; /* epoll_wait 睡在这里 */
struct file *file; /* “一切皆文件”的落点:epoll 本身也是 file */
};
这里先抓住两个锚点:rbr 是“我管理着谁”(长期状态),rdllist 是“谁已经就绪”(运行时状态)。这俩分工不清,你后面一定看不懂回调为什么能 O(1) 推送。
[!IMPORTANT]
epoll_wait 绝大多数情况下只在消费rdllist,它不负责“发现谁就绪”,它负责“交付谁已就绪”。
4.6.2 struct epitem:监听关系的实体(fd ↔ epoll 实例)
每 ADD 一个 fd,就产生一个 epitem。它是 epoll 的“关系建模”核心。
/* fs/eventpoll.c */
struct epitem {
struct rb_node rbn; /* 挂在 eventpoll->rbr 上,按 fd 做索引 */
struct list_head rdllink; /* 挂在 eventpoll->rdllist 上(当就绪时) */
struct eventpoll *ep; /* 属于哪个 epoll 实例 */
struct file *ffd; /* 监听哪个文件(含 socket) */
int fd; /* 用户态传入的 fd 值(用于索引/对比) */
struct epoll_event event; /* 用户关心的事件掩码 + data(用户数据) */
struct list_head pwqlist; /* 关键:挂着“回调注册项”(eppoll_entry) */
};
注意 pwqlist:这玩意儿是 epoll 把自己“插”进目标 fd 的 wait queue 的证据链。
没有它,epoll 就只能像 poll 一样每轮去问;有了它,事件发生时就能从底层“推送”到 epoll。
4.6.3 struct eppoll_entry:真正挂进 wait queue 的“回调节点”
回调函数指针到底挂在哪?
答案:挂在 wait queue entry 里。
/* fs/eventpoll.c (骨架化摘录) */
struct eppoll_entry {
struct wait_queue_entry wait; /* 注意:wait 里有 func 指针 */
struct epitem *base; /* 指回这条监听关系 epitem */
};
而 wait_queue_entry 的定义来自通用等待队列机制(include/linux/wait.h),核心是它的函数指针:
typedef int (*wait_queue_func_t)(struct wait_queue_entry *wq_entry,
unsigned mode, int flags, void *key);
struct wait_queue_entry {
unsigned int flags;
void *private;
wait_queue_func_t func; /* 谁来处理“被唤醒” */
struct list_head entry; /* 挂在某个 wait_queue_head_t 上 */
};
epoll 的“魔法”就在这里:它不是把 task_struct 挂进去睡觉这么简单,它把自己的 callback 塞进了 func。
4.7 回调到底是谁注册的:epoll_ctl(ADD) → file->poll → poll_wait
你问“谁注册回调”,答案非常明确:epoll_ctl 注册,底层 fd 的 poll 配合注册。
4.7.1 epoll_ctl(ADD) 会走进 ep_insert()
大致调用链是这样的(省略校验/权限细节):
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
...
switch (op) {
case EPOLL_CTL_ADD:
error = ep_insert(ep, &epds, file, fd);
break;
...
}
}
ep_insert() 里最关键的一段不是 rb-tree 插入,而是 让目标 file 把 epoll 的回调挂进它的 wait queue。
4.7.2 epoll 如何“让对方把我挂进去”:靠 poll_table + qproc
内核里 poll 体系约定:调用 file->f_op->poll(file, poll_table*) 时,poll 实现会通过 poll_wait() 把等待者挂到对应 wait queue。
epoll 在这一步会构造一个“特制 poll_table”,让 poll_wait() 最终挂进去的不是 current 进程,而是一个 eppoll_entry,并且它的 wait.func = ep_poll_callback。
骨架大概长这样:
/* fs/eventpoll.c (骨架化示意) */
struct ep_pqueue {
poll_table pt; /* poll_table 里有 qproc */
struct epitem *epi;
};
static void ep_ptable_queue_proc(struct file *file,
wait_queue_head_t *whead,
poll_table *pt)
{
struct ep_pqueue *epq = container_of(pt, struct ep_pqueue, pt);
struct epitem *epi = epq->epi;
struct eppoll_entry *pwq = kmalloc(sizeof(*pwq), GFP_KERNEL);
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->base = epi;
add_wait_queue(whead, &pwq->wait); /* 这里就 回调节点挂进目标 fd 的 wait queue */
list_add(&pwq->wait.entry, &epi->pwqlist);
}
然后 epoll 调一次目标 fd 的 poll:
static int ep_item_poll(struct epitem *epi, poll_table *pt)
{
return epi->ffd->f_op->poll(epi->ffd, pt);
}
而目标 fd(例如 socket)的 poll 实现,会调用 poll_wait():
/* socket 的 poll 会类似这样 */
poll_wait(file, &sk->sk_wq->wait, pt);
最后 poll_wait() 会执行 pt->qproc(...),也就是刚才那个 ep_ptable_queue_proc()。
[!NOTE]
epoll 回调不是“等出来的”,是 ADD 的时候就注册进去了。epoll_ctl(ADD)这一步,本质是在目标 fd 的 wait queue 上插了一个带 func 指针的节点。
4.8 回调到底是谁调用的:驱动/协议栈 → wake_up → wait_queue_entry->func
你还问“谁调用回调”,答案也非常硬:不是 epoll 调的,是底层子系统在就绪时 wake_up 触发的。
以 TCP 收包导致可读为例,链路大致是:
网卡中断 / NAPI 收包 → 协议栈把数据放进 socket 接收队列 → socket 状态变为可读 → sock_def_readable() → wake_up_interruptible_poll(&sk->sk_wq->wait, EPOLLIN | ...)
wake_up*() 这类通用唤醒函数会遍历 wait queue 上的 entry,然后调用 entry 的 func:
/* wait queue 通用逻辑骨架化 */
static void __wake_up_common(wait_queue_head_t *wq_head, ...)
{
struct wait_queue_entry *curr;
list_for_each_entry(curr, &wq_head->head, entry) {
curr->func(curr, mode, flags, key); /* ✅ 这里就是调用 ep_poll_callback 的地方 */
}
}
所以流程是非常“硬”的:
-
epoll_ctl(ADD) 把
eppoll_entry.wait.func = ep_poll_callback挂到目标 fd 的 wait queue -
目标 fd 就绪时,底层调用
wake_up... -
wake_up 遍历 wait queue,发现这个 entry,直接调用
ep_poll_callback
4.9 ep_poll_callback() 到底干啥:把 epitem 推进 rdllist,然后叫醒 epoll_wait
这就是“事件是被推送的,而不是被发现的”的现实落点。
/* fs/eventpoll.c (骨架化摘录) */
static int ep_poll_callback(struct wait_queue_entry *wq, unsigned mode,
int sync, void *key)
{
struct eppoll_entry *pwq = container_of(wq, struct eppoll_entry, wait);
struct epitem *epi = pwq->base;
struct eventpoll *ep = epi->ep;
/* 关键动作:把 epi 放到 ep->rdllist(就绪链表) */
spin_lock(&ep->lock);
if (list_empty(&epi->rdllink))
list_add_tail(&epi->rdllink, &ep->rdllist);
spin_unlock(&ep->lock);
/* 再把睡在 ep->wq 上的 epoll_wait 线程叫醒 */
wake_up(&ep->wq);
return 1;
}
注意这里的“分工感”:
ep_poll_callback 不扫描任何集合,不遍历 rb-tree;它只做两步:
把 epitem 入队 → 把 epoll_wait 叫醒。
也正因为如此,所谓 “O(1) 就绪通知”才成立:
通知成本和监听集合大小无关,只和“这次发生了事件的 fd 数”有关。
[!TIP]
所谓 O(1) 不是数学上严格常数,而是语义上“无需线性扫描”。回调一触发,直接把对应 epitem 入 ready 队列,这条路径的目标很明确。
4.10 epoll_wait() 为什么不用“发现”:它只是在消费 rdllist
当线程调用 epoll_wait,内核大概做两件事:
-
rdllist为空就睡到ep->wq -
rdllist有货就把事件拷贝到用户态数组
骨架你可以这么理解:
/* fs/eventpoll.c */
static int do_epoll_wait(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
if (list_empty(&ep->rdllist))
wait_event_interruptible_timeout(ep->wq,
!list_empty(&ep->rdllist),
timeout);
/* 醒来后:消费 rdllist,把 epi->event 拷贝出去 */
...
}
这就是为什么我前面说:epoll_wait 是“交付函数”,不是“探测函数”。
[!IMPORTANT]
现在能把“谁注册回调、谁调用回调”说清楚了:
注册者:epoll_ctl(ADD) 通过 poll_table/qproc 把 entry 挂进目标 fd wait queue
调用者:底层子系统在就绪时 wake_up 遍历 wait queue,直接调用 entry->func(ep_poll_callback)
[!Question]
如果同一个 socket 被两个不同的 epoll 实例监听,会发生什么?
提示:wait queue 里会挂两份 eppoll_entry,wake_up 会依次调用它们的 func。
下一节预告
下一节回到用户态,专门把 LT / ET 单独讲:为什么 ET 必须配合非阻塞、为什么一定要循环读到 EAGAIN,以及工程上怎么设计缓冲区/状态机才能不翻车。
5. epoll 的使用模式(LT/ET)与常见陷阱
用户态事件驱动编程模型
5.1 Reactor 模式:epoll 赖以生存的用户态语境
epoll 从来不是“并发模型”,它只是 Reactor 模式里的一块关键零件。
Reactor 的核心思想很简单:
IO 就绪 ≠ IO 完成,事件到来 ≠ 工作完成。
epoll 负责把“值得处理的事件”交出来,至于如何处理、处理到什么程度、是否立刻处理完,完全是用户态的责任。
这也是为什么:
同样用 epoll,muduo 和 nginx 的代码风格、状态机复杂度、性能特征会差异巨大;
而这些差异,几乎全部来自用户态的事件消费策略,而不是内核。
5.2 核心论点一:epoll 不决定并发模型,你如何消费事件才决定系统性能
很多初学者会把 epoll 当成“并发引擎”,这是一个非常危险的误解。
从内核视角看,epoll 只做了一件事:
告诉你“哪些 fd 在这一刻具备继续推进状态的条件”。
至于你推进多少、推进到哪里、是否阻塞在用户态,这些 epoll 一概不管。
这就引出了 LT(Level Triggered,水平触发)和 ET(Edge Triggered,边沿触发)最本质的区别——责任归属的差异。
LT 模式下,epoll 的语义是“状态驱动”:
只要 fd 仍然处于可读 / 可写状态,它就会反复提醒你。
这意味着用户态可以写出“懒一点”的代码:
一次 read 只读一部分也没关系,下一轮 epoll_wait 还会再叫你。
ET 模式下,epoll 的语义是“变化驱动”:
只在状态发生“不可读 → 可读”这条边沿上通知你一次。
从那一刻起,你就对这个 fd 的状态负责了。
如果你没把数据读干净,内核不会再帮你兜底提醒。
这两种模式并不存在“谁更高级”的问题,它们只是把责任划分到了不同的位置:
LT 把容错性交给内核;
ET 把性能潜力和正确性一并交给用户态。
真正决定系统性能的,从来不是“用了 ET 还是 LT”,而是:
你的事件循环是否阻塞、是否及时消费事件、是否在用户态引入了隐性串行化。
[!NOTE]
epoll_wait 返回得再快,只要你在回调里阻塞 1ms,整个 Reactor 就会停 1ms。
5.3 核心论点二:ET 模式不是“更快”,而是“一种更严格的协议”
ET 模式在工程实践中被反复神话,但它的本质非常朴素:
内核减少了“重复提醒”的工作量,前提是你必须严格遵守 IO 消费协议。
这个协议的核心只有一句话:
每次收到事件通知,必须把对应方向的 IO “drain” 到返回 EAGAIN 为止。
为什么必须这样?
回到上一章的内核路径:
epoll 的事件是通过回调推送进就绪链表的;
而回调只在“状态发生变化”时触发。
如果你在 ET 模式下只读了一点点数据就返回用户态:
socket 的状态依然是“可读”;
但状态没有再次发生变化;
因此回调不会再次触发;
你就永远也等不到下一次通知。
这不是 bug,而是契约违约。
ET 模式的“快”,并不是单次操作更快,而是减少了无意义的 wakeup 和 epoll_wait 返回次数。
在高并发场景下,这种减少可以显著降低系统调用压力和上下文切换频率。
但代价也很明确:
你必须把 fd 设置为非阻塞;
你必须在事件回调中循环 read / write;
你必须能正确处理 EAGAIN / EWOULDBLOCK;
你必须有能力维护更复杂的用户态状态机。
这也是为什么 ET 模式在“写对之前”,往往比 LT 更慢、更容易出事故。
[!IMPORTANT]
ET 不是性能优化开关,而是协议升级。
升级协议的前提,是你能完整实现协议。
5.4 核心论点三:epoll 的大多数事故,发生在用户态而不是内核
如果你系统性地回顾线上 epoll 相关事故,会发现一个共同点:
内核几乎总是无辜的。
典型事故模式包括但不限于:
第一类:阻塞操作混入事件循环。
哪怕你所有 fd 都是非阻塞的,只要在回调里做了阻塞 DNS、阻塞日志、阻塞锁等待,epoll 的优势就会被瞬间抹平。
第二类:ET 模式下未 drain IO。
读一次就走,写一次就走,结果连接“假死”,看起来像 epoll 没通知,实际上是用户态没履约。
第三类:错误理解 EPOLLOUT。
EPOLLOUT 并不意味着“现在适合大量写”,它只意味着“发送缓冲区从满变成不满”。
滥用 EPOLLOUT 往往会导致写放大和 CPU 空转。
第四类:事件与连接状态脱节。
epoll 通知的是 fd 状态,不是业务状态。
如果你的协议状态机与 IO 事件不同步,很容易出现“事件来了,但业务不能处理”的反直觉场景。
第五类:误用 EPOLLONESHOT。
one-shot 不是“更安全”,而是“更容易忘记 rearm”。
一旦忘记重新 epoll_ctl(MOD),连接会直接进入永久沉默。
这些问题的共同根源在于:
epoll 把一部分“以前由内核隐式承担的责任”,显式交给了用户态。
你得到的是更高的性能上限,但也必须为此付出工程复杂度。
[!NOTE]
epoll 的失败案例,几乎都是“使用者没理解协议”,而不是“机制设计有问题”。
5.5 架构细节:用户态真正打交道的几个标志位
在实际工程中,你每天面对的不是 eventpoll 或 epitem,而是这几个标志位的组合语义:
EPOLLIN / EPOLLOUT
描述的是当前是否具备推进 IO 状态的条件,而不是“有多少数据”。
EPOLLET
要求你对 IO 状态变化负责,而不是等内核反复提醒。
EPOLLONESHOT
强制你在每次事件处理完成后,明确告诉内核“我准备好接收下一次通知了”,本质上是把调度控制权进一步推给用户态。
这些标志位没有对错,只有“是否与你的事件循环设计相匹配”。
[!Supplement]
面试题:ET 和 LT 的本质区别是什么?表面答案是“触发方式不同”;
深一层是“谁负责重复提醒”;
本质答案是:谁对 IO 状态的完整消费负责。
5.6 实战范式对照:muduo vs nginx
这不是“谁对谁错”的问题,而是设计目标不同。
muduo 选择的是:
非阻塞 fd + LT + 简洁事件循环。
它把复杂度控制在用户态最小可接受范围内,用清晰的回调语义换取可维护性。
nginx 选择的是:
ET + 严格 drain + 精细状态机。
它假设工程团队可以承受更高复杂度,用极致的 IO 消费效率换取吞吐和延迟优势。
两者都大量使用 epoll,但并发模型的灵魂完全不同。
5.7 小结与向前一步
epoll 解决了一个非常明确的问题:
如何在同步 IO 模型下,高效地等待和分发多个 IO 事件。
但它始终没有解决两件事:
IO 操作依然是同步的;
数据拷贝路径依然绕不开用户态。
这也是为什么,在 epoll 成为“事实标准”之后,Linux 内核仍然继续演进,最终走向了真正意义上的异步接口。
下一篇,将从这里自然过渡到新的时代:
1)epoll 解决了“如何高效等多个 IO”
2)但仍然是“同步 IO + 事件通知”
3)真正的异步模型,才刚刚登场 —— io_uring
👉 下一篇文章占位:
# io_uring 的设计动机与内核革新
[!IMPORTANT]
epoll 是一把锋利的工具,但它从来不是银弹。
理解协议、尊重模型,才是高并发系统真正的护城河。
更多推荐



所有评论(0)