☰
epoll源码剖析:从红黑树到就绪链表,彻底搞懂LT与ET模式
2026/10/17 7:18:33 网站建设 项目流程

1. 一个诡异现象让我决定把 epoll 源码从头翻到尾

先交代一下背景。我之前维护过一个自研的消息分发服务,单机连接数不算夸张,就是常规的万级并发。线上一切平稳,直到某天压测出现一个非常诡异的现象:同一个文件描述符上的读事件,在某些环境下会频繁重复上报,CPU 占用突然爬升,但在另一套环境上同样的代码却完全正常。

我最初怀疑业务层处理逻辑有状态泄漏,排查两轮没找到问题。后来注意到一个细节:两套环境的差异是ET(边缘触发)还是LT(水平触发)以及 socket 的阻塞属性配置不完全一致。就是这个细节,让我决定不再靠着抓包和加日志猜,而是直接去读内核里 epoll 的实现。

这个决定看起来很重,实际上非常有价值。epoll 的设计思想一直被人称为"Linux 下高性能网络编程的基石",但绝大多数使用者停留在 API 调用的层面——会用epoll_create、epoll_ctl、epoll_wait三个函数,知道红黑树存 fd、就绪链表存事件。但真正到了"为什么 ET 模式要求非阻塞 IO""为什么惊群问题在 epoll 上仍然存在""为什么EPOLLONESHOT要这样配合使用"这类问题,文档给不了底层答案,只有源码能。

我是用 Linux 内核 5.15 版本的fs/eventpoll.c做主线阅读的,辅助参考了include/linux/eventpoll.h、include/linux/poll.h以及核心的select.c里的poll_wait机制。这个系列文章我不打算逐行贴代码,而是按"数据结构 -> 注册链路 -> 等待唤醒 -> LT/ET 差异"这条主线,把我在源码阅读过程中真正看懂的机制讲清楚。目标是让读者读完以后,回头再写 epoll 业务代码时,能清楚地知道每一次系统调用背后内核做了什么。

2. 从"文件描述符的视角"看 epoll 的三个核心数据结构

要读源码,先得认识骨架。epoll 在内核态维护的东西,整体上可以理解成一个小型的三级体系:一个 epoll 实例对象、若干被监控的文件描述符节点、以及每个节点与目标文件之间的"事件订阅关系"。

2.1eventpoll:每个 epoll 实例的主控块

每次调用epoll_create或epoll_create1,内核就创建一个struct eventpoll实例。这个结构体是整个 epoll 机制的主控块,里面最重要的几个字段包括:

  • wq:等待队列,调用epoll_wait后没有就绪事件时,当前进程就挂到这个队列上睡眠。
  • rdllist:就绪链表,保存那些已经触发事件、等待用户空间取走的epitem。
  • rbr:红黑树根节点,所有通过epoll_ctl(EPOLL_CTL_ADD)注册进来的文件描述符节点都存在这棵红黑树里。
  • ovflist:在回调过程中暂存新就绪事件的链表,这个是防止事件在回调中丢失的关键设计。
  • poll_wait:当前 epoll 实例自身也作为可以被 poll 的对象,这涉及到"嵌套 epoll"以及 epoll 事件转发机制。

单进程里多个 epoll 实例是完全独立的,每个实例有自己独立的红黑树和就绪链表。这一点和早期select那种全局 fd_set 拷贝机制有本质区别。

从查询的角度理解一下:红黑树的作用是"根据文件描述符快速找到对应的 epitem"。为什么要用红黑树而不是哈希表?因为文件描述符的取值范围在进程内理论上可以很大,并且描述符的增删频率和查找频率都高,红黑树能保证对数级复杂度。实测中几万个 fd 挂在同一棵树上,查找性能依然稳定。

2.2epitem:一个被监控 fd 的完整档案

当执行epoll_ctl(EPOLL_CTL_ADD, fd)时,内核为这个 fd 分配一个struct epitem。它记录的信息包括:

  • ffd:目标文件描述符和对应的struct file指针。
  • rbn:红黑树节点,用于挂入实例的红黑树。
  • rdllink:就绪链表节点,用于挂入rdllist或ovflist。
  • event:用户传入的epoll_event,包括EPOLLIN、EPOLLOUT、EPOLLET、EPOLLONESHOT等掩码。
  • nwait:当前节点等待队列回调的个数。
  • pwqlist:指向eppoll_entry的链表。

这个结构本质上是"用户空间注册意图"与"内核驱动状态"之间的桥梁。需要注意一点:同一个文件描述符不能重复添加。因为添加时会先到红黑树里查找,如果已存在就会返回EEXIST。这也是 epoll 相比 select 的一个优势:状态是常驻内核的,不需要每次调用时重新构建 fd 集合。

2.3eppoll_entry:一把连接目标文件的"事件钩子"

epitem只是站在 epoll 实例角度的描述,真正要收到目标文件(比如一个 socket)的事件,必须向目标文件自己的等待队列里挂一个回调。这个回调就封装在struct eppoll_entry里,核心字段是:

  • wait:wait_queue_entry_t,会被挂到目标文件自身的等待队列上。
  • base:指向所属的epitem
  • wait里保存了回调函数指针ep_poll_callback

我来打个比方:epitem相当于你在物业登记了"我要关注 3 号房的水电异常",eppoll_entry则是在 3 号房门口钉了一个邮箱,物业往邮箱里投纸条,你才能及时知道出事了。没有这个钩子,目标文件永远不知道有一个 epoll 在关心它。

这个设计告诉了我们一个核心事实:epoll 对"事件到达"的感知不是靠轮询,而是靠目标文件自身的等待队列唤醒机制。这就是它和忙轮询最大的不同。

3. 从epoll_ctl到ep_insert:注册一个 fd 到底发生了什么

读 epoll 源码,有一条很实用的主线:逐个追epoll_ctl、epoll_wait的完整调用链。先讲epoll_ctl的 ADD 和 MOD 场景,因为 DEL 相对简单,红黑树里找到接点删掉再断钩子即可。

3.1ep_insert的四个关键节点

epoll_ctl(EPOLL_CTL_ADD)走到内核最终会进入ep_insert。我在读这段代码时,把它分成四步来理解:

第一步,检查并初始化。先取目标文件的struct file指针,检查文件是否支持poll操作(所有 socket 都支持,普通文件大多数不支持)。然后调用ep_take_care_of_epollitem,把这棵树和将来的epitem绑定。实际代码里会先探测有没有超过max_user_watches之类的限制,这是一个容易被忽略的用户态限制:系统对单个用户可注册的文件描述符总数有上限,可以通过fs.epoll.max_user_watches调整。

第二步,初始化epitem,把用户传入的EPOLL_CTL_ADD事件掩码保存下来,然后把红黑树节点按 fd 大小做键值插入。这里有个细节:红黑树的比较函数ep_cmp_ffd,优先比较file指针地址,再比较 fd 数值,实际效果是以"目标文件对象"去重。

第三步,调用ep_rx_hook系列的注册。核心语句是:

ep_ptable_queue_proc(item, ep, epitem);

这行实际上调用了目标文件自己的poll方法,在 poll 里面通过poll_wait把eppoll_entry的等待队列项挂到目标文件的等待队列上。

我给不太熟悉内核文件系统的读者补充一下:每个struct file都有一组操作函数指针,socket 类型文件的poll函数是sock_poll。sock_poll内部会走到 socket 协议层的poll,比如 TCP 的tcp_poll,然后调用poll_wait,把 epoll 提供的等待队列项挂进 socket 的等待队列。这样当 socket 上有数据可读时,协议栈就能通过 wake up 机制找到这个等待队列项,触发ep_poll_callback。

第四步,处理事件回调触发进来的情况。这里有个加锁细节:如果目标文件在注册的瞬间已经有事件就绪,sock_poll返回的mask会带有EPOLLIN等标志,此时ep_insert会直接把该epitem放进就绪链表,并唤醒正在epoll_wait的进程,避免"插入事件后错过早期就绪信号"。

3.2 回调函数ep_poll_callback:epoll 最核心的"闹钟"

整个 epoll 机制里最值得反复琢磨的函数就是ep_poll_callback。它不在epoll_ctl链路里被直接调用,而是被目标文件的事件机制反过来触发。流程是这样的:

当 socket 缓冲区收到数据,网络协议栈会把等待队列上的等待项逐个唤醒,执行ep_poll_callback。这段回调做的事可以概括为:

  1. 判断事件掩码是否包含用户注册的感兴趣事件,如果没有就直接返回。
  2. 把epitem加入就绪链表。这里特别注意加入逻辑:如果当前正处于epoll_wait把就绪链表搬运到用户空间的过程中,会先加入ovflist暂存。
  3. 唤醒正在等待的进程:获取 epoll 实例的wq锁,调用wake_up_locked把挂在wq上的进程唤醒。

这一步是整个 epoll 高性能的关键:数据的到达路径上,进程不需要轮询,而是被动等回调来敲门。回调把就绪 fd 串成链表,epoll_wait返回时直接从这个链表搬运结果给用户态。

读到这里我意识到一个优化点:虽然ep_poll_callback执行频率很高,但它的临界区很小,大多数情况下只是链表插入和一次唤醒操作,所以 epoll 在高并发下的锁竞争要比传统模型小很多。不过当大量 fd 同时就绪时,所有回调都抢同一把 epoll 实例锁,这里会成为热点。实际的业务优化思路是:将不同业务的 fd 分散到多个 epoll 实例,人为降低单实例锁竞争。

3.3epoll_ctl的 MOD 场景为什么也要重新唤醒

MOD 场景(修改事件掩码)在源码中是ep_modify。它做的事比 ADD 简单:找到红黑树上的节点,更新event掩码,然后重新调用目标文件的poll探测一次当前状态。如果当前有满足新掩码的事件,则立即把节点放入就绪链表并唤醒等待进程。

这个"重新探测"是很必要的。举个例子:某个连接之前只关心EPOLLOUT,现在改为关心EPOLLIN,而数据在改之前就已经到达 socket 缓冲区了。如果没有重新探测,这个已经到达的事件就不会被上报,用户态可能一直等下去。源码的严谨性在这里体现得很明显:不能只修改掩码,还要主动补一次就绪检测。

4.epoll_wait的睡眠与唤醒:高并发的"收件"流程

epoll_wait这一侧同样有完整的核心链路:从用户态传入事件数组,到内核ep_poll睡眠等待,再到把就绪事件从内核链表搬运到用户空间。整条链路读下来,信息量非常大。

4.1ep_poll的死循环和超时控制

epoll_wait进入内核后会调用ep_poll。函数外部循环中会先检查就绪链表是否为空,不为空就直接进入事件拷贝;为空则需要把当前进程挂到wq上睡眠。睡眠不是无线程的,而是基于用户传入的timeout时间,通过schedule_hrtimeout_range实现高精度定时唤醒。

这里面有个值得注意的小细节:如果timeout传 -1,表示为永久阻塞,如果传 0,则相当于非阻塞探测当前是否有就绪事件。

ep_poll中有个很有意思的循环设计:取得就绪事件返回用户态之前,会先把当前进程从wq中摘除,然后检查ovflist上是否有"回调过程中新来的事件"。如果有,说明在拷贝过程中又有新事件到达,需要再走一轮循环,避免事件丢失。这个重新调度机制在极高峰值流量下能保证事件不丢,但也意味着epoll_wait可能在某次调用里返回两次"批次"事件后才会真正返回到用户态。很多人误以为 epoll_wait 的循环只是单纯处理 EINTR,源码层面其实还有一个就绪检查循环。

4.2ep_send_events:把一个链表翻译成用户态事件

当ep_poll拿到就绪链表后,最终调用ep_send_events。它的职责是把内核链表上的epitem逐项翻译成用户空间能读的struct epoll_event。

具体步骤是:

  1. 从就绪链表头开始遍历。
  2. 再调用一次目标文件的poll方法,获取当前真实事件掩码。
  3. 将用户注册掩码中感兴趣的位与真实掩码做交集,确认到底哪些事件需要上报。
  4. 将结果写入用户传入的事件数组。
  5. 判断是否为边缘触发模式来决定该epitem是否从就绪链表摘除。
  6. 若用户数组已满但还有未处理节点,剩余节点保留在就绪链表,等下次epoll_wait继续取。

关于第 5 点,我要展开说一下,这直接关系到对 LT 和 ET 的理解。源码里ep_send_events对每个epitem处理完后有个条件判断:

if (!(epi->event.events & EPOLLET)) { // 水平触发:事件处理完,重新放回就绪链表,以便下次继续上报 list_add_tail(&epi->rdllink, &ep->rdllist); }

换句话说,LT 模式下的就绪节点在拷贝完成之后会被重新加回就绪链表,除非这次poll探测发现事件已经不再满足。而 ET 模式则会把节点直接移除,下次只有重新触发事件才会再次进入链表。这就是 LT 和 ET 行为差异的根源。

4.3 从源码视角重新看 LT 模式的"重复上报"

理解了上面的机制,LT 模式偶尔重复上报的行为就变得合理了。在 LT 模式下,只要内核认为该 fd 仍有未读数据或未写空间,epoll_wait就一直会把它放进就绪链表。用户态如果只读取部分数据,剩余数据会导致下一次epoll_wait再次上报这个 fd。这个机制是"保险"的:即使业务代码漏读了一半数据,下一次 epoll 循环还会给你机会,不会导致事件永久丢失。

但也正因为这个特性,LT 模式在某些特殊场景下会带来额外的用户态调度开销。假设有 1000 个连接全部持续可读,LT 模式下这些节点会反复出现在就绪链表里,即使业务处理跟不上,内核也会不断上报。如果使用得当,LT 反而让编程更安全;如果使用不当,就会表现为 CPU 空转。

我压测时遇到的那个"频繁重复上报"问题,最后定位就是某个场景下代码把 socket 设成了阻塞模式,同时用了 LT 监听,业务处理线程在事件风暴时期处理不完导致就绪链表反复回填,用户态陷入"事件上报 -> 部分处理 -> 部分积压 -> 再次上报"的循环。这个循环本身不算内核 bug,但确实暴露了 LT 模式在积压场景下的放大效应。

5. 边缘触发与水平触发:代码逐行走完才想明白的差异

前面已经提到ep_send_events里的条件判断,这一节我想从更深的角度,把这部分的源码逻辑讲透。

5.1 ET 模式为什么需要配合非阻塞 IO

ET 模式是从"状态翻转"的角度触发事件的,它只关注"有没有新事件发生",而不是"当前状态是否满足"。因此,ET 模式下事件节点被处理一次就会从就绪链表移除,这要求用户态必须在一次上报中尽可能把数据读完。

问题在于,如果用阻塞 IO 的 socket 做 ET 读,用户态在读完当前缓冲区所有数据后再次read,socket 没有更多数据就会阻塞进程。一旦进程阻塞,其他就绪连接就无法被及时处理,整体并发性瞬间崩塌。逼近实际的常见做法是:ET 模式下 socket 设置为非阻塞,循环read直到返回EAGAIN,这个"读到 EAGAIN"的边界事件就代表当前数据已全部取走,可以安全回到epoll_wait继续等待下一个读事件。

源码层面并没有直接"强制"你用非阻塞 IO,它只是在事件管理机制上给了你这种压力。内核的 ET 实现天然假设用户态会"把本轮事件一次性处理干净",否则没有后续触发机会。源码里 ET 节点被摘除后,只有等目标文件再次通过协议栈触发回调才会重新加入链表。

5.2 内核源码中 ET 的"再次触发"路径

ET 下再次触发事件的路径可以概括为:socket 从不可读变为可读、从不可写变为可写、出现新连接、收到新数据包、发生异常等。在这些路径上,协议栈都会唤醒等待队列,调用ep_poll_callback。

举个具体例子:一个 TCP socket 在 ET 模式下收到第一个数据包,此时触发EPOLLIN。用户态读到一半,缓冲区还有剩余数据没读完,这时内核不会继续上报,因为事件状态还是"可读",并不产生新的状态翻转。只有缓冲区被读完,过了一段时间又来了新的数据包,新数据包到达才产生一次新的"不可读 -> 可读"翻转,才会再次上报。

理解了这条路径,你就能解释很多实战问题:为什么 ET 模式在高水位线附近容易出"读不到数据"的错觉?实际上是业务代码在某次事件的末尾没有彻底读完缓冲区,之后又迟迟没有新数据包到达,内核自然不上报,用户态就挂住了。这不是内核丢了事件,而是 ET 模式的设计语义决定的。

5.3 LT 与 ET 如何选择:源码视角下的结论

从源码读完的角度来看,我的选择建议很明确:

  • 如果是通用业务、希望代码容错性更高,优先 LT。内核大量"兜底"设计让事件不容易丢。
  • 如果是追求极限吞吐、单次事件处理能力可预测,或者有明确的"一次事件处理完数据"的逻辑,可以用 ET 模式配非阻塞 IO。
  • 如果某个 fd 的事件处理逻辑比较复杂,容易超时,考虑EPOLLONESHOT,让一个 fd 在一次事件处理期间不再被重复上报,处理完后再手动重新注册。

EPOLLONESHOT在源码上相当于加了一个 flag:当ep_send_events把该节点事件上报给用户态后,直接把这个节点从 epoll 实例中暂时禁用(摘除并设置EPOLLONESHOT状态),直到用户态调用 MOD 重新启用。这在多线程处理同一个 fd 的场景里尤其重要:多个线程同时从同一个 epoll 实例取事件时,如果同一个 fd 的多个事件在不同线程中并发处理,会带来连接处理顺序错乱的隐患。

6. 剖析源码后,对我实际项目最有用的几个工程判断

代码读到这个程度,很多之前的"经验之谈"终于有了依据。我梳理了几个在工程上可以直接落地的判断,分享给大家。

6.1 惊群问题:为什么"事件唤醒一个进程"在 epoll 里仍然要小心

很多人以为 epoll 天然解决惊群问题,实际源码分析下来并非绝对。ep_poll_callback在执行唤醒时,默认唤醒wq队列上挂着的所有等待进程。如果多个进程(或线程)同时阻塞在同一个 epoll 实例的epoll_wait上,一个事件来临时确实可能唤醒多个进程。

不过内核后续引入了wake_up_locked和一些优化机制,加上epoll_wait本身在取事件时会竞争锁,实际场景中"虚假唤醒"并非完全没有代价。在多进程模型下,要解决惊群,首选是SO_REUSEPORT让多个进程各自监听不同 socket;或者配合EPOLLEXCLUSIVE标志,让内核在唤醒时只挑一个等待进程,尽量降低无谓唤醒开销。

6.2epoll_wait的超时参数不是简单的"轮询间隔"

源码里schedule_hrtimeout_range的处理会让超时参数拥有较高精度,但实际唤醒仍然受内核调度延迟影响。把timeout设成一个很小的值(比如 1 毫秒)并不会让你得到实时的"零延迟事件捕获",只会让进程频繁进入睡眠唤醒切换,增加上下文切换开销。

我在项目里一直推荐的做法是:有明确的事件驱动结构时,epoll_wait使用较长超时时间(比如 100ms 甚至 -1),把超时协调留给更上层的定时器机制;不要用 epoll_wait 做定时任务轮询,否则负载一上去,调度器开销立刻成为瓶颈。

6.3 max_user_watches 限制是一个真正的坑

源码阅读时注意到的max_user_watches限制,在真实生产环境中很容易踩中。如果单进程注册 fd 数量过大,或者单用户启动多个服务进程,总量超过fs.epoll.max_user_watches后,epoll_ctl(EPOLL_CTL_ADD)会返回ENOMEM。

很多运维同学以为这是内存不足,其实只是内核为了防止某个用户无限制占用内核内存而设置的软限制。排查方式很简单:

cat /proc/sys/fs/epoll/max_user_watches

如果业务确实需要注册非常多的 fd,可以调整这个参数。但要注意:这个参数不是越大越好,它直接影响内核为每个epitem分配的内存总量,需要结合业务规模评估。

6.4 和旧方案对比:select 与 poll 不值得再回头的源码原因

从源码读完出来,我对select和poll的劣势有了非常具象的认识。select需要每次把 fd_set 从用户态拷贝到内核态,内核再逐位扫描所有 fd,无论有多少 fd 就绪;poll稍微好一点,但仍然是每次全量拷贝并线性扫描所有 pollfd。

epoll 的增量注册、常驻内核、回调驱动加就绪链表,让每次epoll_wait只需要处理"有变化的节点"。在高并发、大部分连接空闲的场景下,这种设计能极大降低 CPU 空转。我实测过同样一万个连接、只有一百个活跃的请求压力下,epoll 方案和 poll 方案的 CPU 占用差距基本是一个数量级。

7. 再分享一个从源码里挖出来的性能优化细节:批量唤醒的代价

最后我想单独提一个很多人没注意到的小点。在ep_poll_callback里,唤醒进程并不是无代价的。每次调用wake_up都会触发调度器对目标进程的唤醒动作,包括设置运行状态、放入运行队列、可能触发抢占。如果某个瞬时事件让几千个 fd 同时就绪,就会产生几千次进程唤醒操作,即便最终只有一个线程在epoll_wait里取事件。

这就是我在高并发系统里经常提醒的"事件风暴"问题:把大量文件描述符的 IO 事件密集集中在同一个 epoll 实例上时,瞬时唤醒开销会让当前 CPU 核短暂飙升。解决思路有三个方向:

  • 将不同优先级的连接分散到不同的 epoll 实例,避免单实例事件风暴互相拖累。
  • 引入事件合并机制,让业务层批量处理同一个唤醒批次里的所有 fd。
  • 在高负载时主动降低epoll_wait返回频率,利用内核的就绪链表合并能力,而不是每次唤醒都立刻拷贝全部事件。

这也呼应了前文提到的:epoll 的高性能不是绝对的,它的回调模型在"高活跃连接占比"场景下也会遇到瓶颈。真正的优化往往要在了解内核模型后,从业务分层上做文章,而不是单纯调整某个 API 参数。

源码剖析这件事,读之前觉得复杂,读完之后会形成一种很强的直觉:你知道某个问题该去内核的哪一段代码找答案,而不是在用户态反复猜。希望这篇记录能帮到那些正在和 epoll 搏斗的同行,也欢迎各位在实际项目里遇到新的边界问题时继续交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询