操作系统内功 · 第 61 篇|epoll 实现内幕:红黑树、就绪队列与 LT/ET 语义的内核真相

epoll 实现内幕:红黑树与就绪队列(手绘信息图)

从用法到实现:三个值得回答的问题

第 5 篇讲过 epoll 的用法:epoll_create、epoll_ctl、epoll_wait 三板斧,LT 与 ET 的行为差异,边沿触发要配非阻塞 fd。用法之外,还有三个「为什么」值得到内核里找答案:几十万个 fd 为什么还是「只处理就绪的」而不是每次全扫一遍?就绪事件是谁、在什么时机放进队列的?LT 与 ET 在内核里到底差哪一行逻辑?把这三个问题拆开,epoll 的设计观就透了。

内核里每个 epoll 实例是一个 eventpoll 对象,两个核心数据结构撑起全部语义:一棵红黑树与一条就绪链表。红黑树按 fd 索引所有已注册的 epitem,epoll_ctl 的增删查改走树的对数复杂度路径;就绪链表(rdllist)只放「当前有事件可报」的 epitem,epoll_wait 只扫这条链——所有已注册 fd 里只有就绪的那一小撮被处理,这就是「复杂度只与就绪数相关」的来源。与之对照,select 与 poll 每次调用都要把全部 fd 从用户态拷进内核逐个问一遍,fd 一多就是纯罚时间,这正是当年 epoll 要解决的原始痛点。

还有一条容易被忽略的设计选择:epoll 实例本身也是一个文件(这就是为什么 epoll_create 返回 fd)。一个进程可以开多个 epoll 实例,一个 epoll 可以被 dup、被传递、被另一个进程监视——「一切皆 fd」的哲学在 epoll 这里同样成立,也为后面讲惊群与共享埋下伏笔。

对比的老对象值得数字化一下:select 的 fd 规模有历史硬上限(千级),poll 解除了上限但仍是每次全量拷贝全量问,两者在万级 fd 上单次调用成本以毫秒计——这正是当年高性能网络库绕开系统调用、自己管事件的动机;epoll 把成本砍到「只随就绪数增长」,内核级事件循环才重新成为最优解。另一个冷知识:epoll 实例本身也是文件,可以被另一个 epoll 监视,嵌套 epoll 在分层事件分发架构里有真实用途,但语义细节多(内层边沿事件在外层的呈现形态),不建议随手用。

eventpoll 与 epitem:红黑树加就绪链表

把数据结构摊开看。eventpoll 的关键字段:红黑树树根(管理全部 epitem)、就绪链表 rdllist、以及一个供 epoll_wait 睡眠的等待队列。epitem 是注册关系的载体,每个「epoll 实例加 fd」的组合对应一个:它记录所属的 eventpoll、目标 fd 与文件指针、用户登记的关心事件集,外加两个连接件——挂上就绪链表的链入点、挂到目标文件 epoll 关注列表的链入点。同一个文件被两个 epoll 实例监视,就是两个 epitem 各自挂进同一个文件的关注列表;fd 与文件的多对一关系(dup 复制)则让「按 fd 注册、按文件报事件」的语义埋下后面要讲的微妙行为。

就绪链表是整个设计的点睛之笔:它把「哪些 fd 现在有事」从 O(全部注册) 的查询问题,变成了 O(1) 的链表插入——插入这个动作由事件到达的回调顺手完成,查询侧(epoll_wait)只需要摘链表。数据结构的角色分工对应了事件的时空分布:注册是低频重操作,用树;就绪是高频轻操作,用链。很多高性能系统的设计草图里都有这对组合的影子,值得当作通用模式记下来。

内存与限额的关系也在这里:每个 epitem 是百字节量级的内核内存,watch 总数限额的本质就是「限制一个用户能占用的 epitem 总内存」——限额数字除以单条开销大致等于内存预算。监控系统、代理服务器这类动辄监视几十万 fd 的应用,容量规划要把这笔内核内存算进去,而不是只算应用自己的堆。

顺带一提 eventpoll 的并发设计:注册路径用互斥锁保护红黑树,就绪路径用更轻的锁保护就绪链表,回调在高频热路径上只碰后者——重操作慢锁、轻操作快锁的分层,与红黑树加链表的数据结构分层互为表里。读内核源码时把这两层对应关系对上,eventpoll 相关代码的阅读难度会直线下降。

epoll_ctl 与回调:就绪如何被发现

epoll_ctl 的 ADD 动作做两件事:在红黑树插入 epitem,并向目标文件的等待队列挂一个回调。挂回调走的是文件自己的 poll 实现(socket 是 tcp_poll 一类的协议栈 poll):内核在文件的等待队列上登记一个轮询项,其回调函数统一指向 ep_poll_callback。同时 ADD 流程会立刻做一次初始 poll——fd 上已经有数据时,第一次 epoll_wait 马上就能报出来,不需要等新的网络事件。

回调路径还有两个高频细节。一是 EPOLLOUT 的注册时机:socket 初始几乎总是可写,常驻注册 OUT 会让 epoll_wait 空转唤醒,规范做法是「写满时才注册 OUT,缓冲区腾空后立即摘除」,让可写事件只作为堵转解除的信号出现。二是半关闭的感知:对端关闭写入方向后,连接会报半关闭类事件,读循环随后读到零字节或错误——两套信号配合才能准确管理半开连接。这两个细节是 epoll 应用层正确性的高频翻车点,比触发模式本身更常上事故报告。

之后的事件流以 TCP 收包为例:网卡中断,协议栈把数据放进接收队列,随后触发 socket 的「数据就绪」回调,等待队列上挂着的 ep_poll_callback 被叫醒。它调用一次该文件的 poll 探测当前事件掩码,与 epitem 登记的关心事件求交——有交集就把 epitem 挂进 rdllist,并唤醒睡在 epoll_wait 上的进程。关键在于:内核不扫描任何 fd,就绪信息由「数据到达」这一事件主动推给 epoll;epoll 从不问「谁就绪了」,它只登记「谁就绪时告诉我」。

这也是理解 epoll 扩展性的核心:回调路径的成本与注册总数无关,只与真实发生的事件数相关。百万 fd 里一秒只有千次事件,就只有千次回调与千次链表插入。「反向注册、正向推送」这个范式,是所有事件驱动系统(从内核 epoll 到用户态各类反应堆框架)共同的骨架。

LT 与 ET:内核里差的那一行

水平触发与边沿触发的分野,落在 epoll_wait 取事件的执行体里。它逐个处理 rdllist 上的 epitem:再次调用文件 poll 拿当前真实掩码,与关心事件求交后拷给用户态。关键的分叉在交付之后:LT 模式下,如果再次探测发现事件仍在(比如接收缓冲区还有数据没读完),epitem 被重新挂回 rdllist——下一次 epoll_wait 还会报它;ET 模式下不回挂,这个 epitem 从此安静地离开就绪链表,直到下一次新事件的回调把它再放回来。

「边沿」的语义由此落地:所谓 edge,就是「回调再次触发」这个动作本身。数据到达,回调,入链,报一次;如果你读不完,缓冲区里剩下的数据不会再产生回调(没有新边沿),rdllist 里也没有它——应用从此再无通知。这就是 ET 必须循环读到 EAGAIN 的全部原因:读完缓冲才可能制造下一次边沿(再来新数据时),留在缓冲里的数据是永远不会再响的哑铃。同理 ET 下写也要写到 EAGAIN:可写事件的「边沿」是发送缓冲区从满到空的那一刻,写一半停手,剩余的可写状态不会再报——写路径因为「缓冲区经常是空的」更容易漏事件,不少 ET 事故出在写侧而非读侧。

配套要求也随之清楚:ET 的 fd 必须非阻塞。循环读到 EAGAIN 时,阻塞 fd 会把整个事件循环卡死在一个 fd 上;非阻塞读到 EAGAIN 立刻返回,事件循环继续处理别的 fd。LT 没有这个硬约束,因为没读完它下次还会提醒——两种模式的取舍本质是「谁来保证处理完整」:LT 让内核兜底提醒,ET 把责任移给应用,换来更少的重复唤醒。高性能服务器选 ET 的收益正在后者:少了反复探测与重复唤醒,中断与调度次数都下降。

工程里还有两个语义增强值得知道。一是混合策略:监听 socket 用水平触发(少一个 accept 漏接的顾虑),业务连接用边沿触发(减重复唤醒),同一条事件循环里两种模式共存完全合法——这是不少高性能服务器的实际形态。二是单次触发模式:事件报一次后自动失活、需显式重新武装,它是多线程共享一个 epoll 实例时的利器——保证同一条连接同一时刻只被一个线程处理,配合重新武装实现连接级的线程交接。

超时与定时器的协奏也值得一小节笔墨:epoll_wait 的超时参数让事件循环天然支持「空闲时睡觉、定时醒来」的节奏,但把业务定时器直接折算成最近的超时时间是下策——精度差、调用次数多。成熟做法是 timerfd 一类把定时器也变成文件描述符,统一进 epoll 的监视列表,事件循环只认一种事件模型。「一切皆 fd」在这里再次发力:定时器、信号、进程间事件都能进同一个循环,这是 epoll 应用架构能长期演进的关键。

epoll_wait、惊群与限额

epoll_wait 的等待实现是标准的等待队列:无事件时进程睡到 eventpoll 自己的等待队列上,带超时参数;回调入链就绪项时唤醒等待者。多个进程或线程共享同一个 epoll 实例等待同一个监听 fd 时,会出现全部被唤醒、只有一个 accept 成功的经典惊群——多核机器上这 wake-then-fail 的模式既浪费缓存又浪费调度。内核后来给注册监听 fd 提供了排他模式标志(EPOLLEXCLUSIVE),唤醒时只挑一个等待者;再叠加系列前文讲过的 SO_REUSEPORT 按连接哈希分片,两条路都能把 accept 摊到多线程。实践中另一种流行做法是每线程独立 epoll 实例、各自注册同一个监听 fd 加排他标志,或干脆每个线程用自己的监听 socket——惊群治理的本质是「把一次广播唤醒改成一次定向唤醒」。

限额方面记两个数:fs.epoll.max_user_watches 这条 sysctl 限制每个用户全体 epoll 实例能挂的监视总数,默认值按内存比例推导,常见服务器在几万到几十万量级——监控类应用长期注册海量 fd 时撞线会收到 ENOSPC,扩这条 sysctl 是标准处置;另一道软墙是 fd 上限本身,epoll 实例与被监视的 fd 都受 nofile 限制约束(ulimit 体系系列前文讲过,此处不展开)。还有个隐蔽语义必须点名:dup 复制出的 fd 与原 fd 指向同一个文件,事件按「文件」去重,而 close 关掉其中一个 fd 并不会自动摘除注册——规范的清理路径是先 epoll_ctl 删除、再 close,反过来写会出现「fd 已关、事件还来」的灵异现象,这类内存安全级 bug 在长连接服务里最阴险。

epoll_wait 的参数里也有两个易误解的点:maxevents 是单次返回的事件数上限而非总量限制,设得过小只会增加调用次数;超时参数给负数表示无限等待、零表示非阻塞探测——「零超时探一次再带超时等待」的组合是手搓调度循环的常见雏形。成熟项目的选型也可以当参考答案:经典高性能 Web 服务器多选边沿触发以压榨多核下的唤醒次数,单线程内存数据库选择水平触发——事件循环极短、重复提醒的成本可控,健壮性更划算。选型背后是同一个问题:处理单位成本与重复提醒频率,哪个更贵。

小结

epoll 的快不是魔法,是两笔账:注册关系放进红黑树(低频重操作),就绪事件放进链表(高频轻操作),回调把「谁就绪」从查询题变成通知题。理解「反向注册、正向推送」这个范式,等于同时理解了 epoll、io_uring 与用户态反应堆框架的公共骨架。

LT 与 ET 的内核差异只有一行——交付后是否回挂就绪链表。这一行决定了两种模式的全部行为差异与应用侧义务:ET 必须非阻塞、必须循环读写到 EAGAIN、漏读漏写都是永久静音。选 ET 是用纪律换性能,选 LT 是用重复唤醒换健壮性,两者都是正当选择,混淆语义才是事故来源。

运维与开发各记一张清单:开发侧——监听 socket 用排他标志或端口分片治惊群;清理 fd 先删后关;watch 总量关注 max_user_watches。运维侧——高并发服务如果出现 accept 集中在个别核、或 ENOSPC 报错,分别对应惊群与限额两个检查点。

最后补一句完整的话回答「为什么不用 select 与 poll」:不是因为 epoll 的算法快,而是它把事件发现的成本从「与应用规模成正比」改成了「与事件数量成正比」,而真实系统的 fd 规模与事件密度通常差好几个数量级。这个洞察适用于一切事件系统设计:把随规模线性增长的成本,转移给随活跃度增长的成本,是高并发架构的第一性原理。下一篇讲一个把文件系统整体搬出内核的机制:FUSE。

← 返回资讯列表

读者留言

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

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