做后端开发这些年被问到最多、也最容易把人问住的一个问题就是“epoll 到底是怎么实现的”。很多人背得出那套标准答案红黑树加就绪链表回调机制水平触发和边缘触发。但真往下追问一层——红黑树里存的到底是什么、回调是谁在什么时候调的、就绪链表为什么能避免遍历、LT 和 ET 的差异在源码层面究竟落在哪一行——能顺畅讲下来的人就不多了。我自己也是踩过不少坑才把这条链路彻底理顺的早年间写过一个长连接网关QPS 一上去 CPU 就飙到单核打满用 perf 一看全耗在epoll_wait返回后的循环里当时以为是内核 bug后来才明白是自己对 ET 模式的理解有偏差漏读了数据导致边沿丢失程序靠超时兜底反复空转。这篇东西就是把我这些年对 epoll 底层实现的理解完整摊开讲一遍。它适合谁看写过 select/poll 觉得性能不够、想搞清楚 epoll 为什么快的同学面试被追问底层细节、想真正弄懂而不是背八股的同学以及线上用 epoll 出过问题、想从原理层面找到排查思路的同学。我会从整体设计思路讲起把红黑树、就绪链表、回调这三块核心结构拆开再落到epoll_create、epoll_ctl、epoll_wait三个系统调用的具体实现路径最后讲 LT/ET 的源码级差异和一堆实战里才会遇到的坑。全程尽量说人话复杂的结构用生活化的类比带过去关键的地方给出可对照的代码路径和参数计算争取让你看完能自己把这条链路在脑子里跑一遍。1. 整体设计思路epoll 为什么要长成这样在拆具体结构之前得先想明白一个问题select 和 poll 到底差在哪epoll 又是冲着解决什么去的。这个“为什么”想通了后面所有的设计选择都会变得顺理成章而不是一堆需要死记硬背的名词。1.1 从 select/poll 的两个硬伤说起select 和 poll 的本质工作模式是“轮询”。你把手上一堆 fd 交给内核内核每次被调用时都要把这堆 fd 从头到尾扫一遍逐个检查有没有事件。这里有两个绕不开的成本。第一个是每次调用都要把 fd 集合从用户态拷贝到内核态fd 越多拷贝越贵而且是每次调用都拷不管你这次有没有事件。第二个是内核要线性遍历整个集合复杂度是 O(n)一万个连接里只有一个活跃你也得把一万个都看一遍。poll 相比 select 只是去掉了 1024 的数量限制、改用了数组结构但“拷贝 遍历”这两个根本问题一个都没解决。这就好比你家有一千个信箱select/poll 的做法是每次想知道有没有信就派个人把一千个信箱挨个打开看一遍看完回来报告。信来得越稀疏这种做法的浪费就越离谱。epoll 的思路完全换了个方向把“登记关注”和“等待事件”这两件事拆开。你第一次告诉内核“我关心这些 fd”内核把它记在一个长期存在的结构里这个结构挂在文件描述符对应的内核对象上不会因为你调用一次等待就消失。之后每次等待内核不再需要你重复递交整个集合只需要检查“有没有已经就绪的”有就直接给你没有就睡。这一拆拷贝成本和遍历成本同时被干掉了。1.2 三个核心结构如何各司其职epoll 在内核里主要靠三样东西撑起来理解它们的分工是理解整个机制的关键。第一是红黑树用来存所有被监听的 fd。选红黑树而不是哈希表或者普通链表核心考量是增删改查都要够快且稳定。epoll_ctl的 ADD/DEL/MOD 操作对应树的插入和删除复杂度 O(log n)而且红黑树是有序的方便做范围类操作。为什么不用哈希哈希虽然平均 O(1)但扩容和冲突处理在极端情况下不稳定而红黑树的最坏情况也是 O(log n)对内核这种要求确定性的场景更合适。第二是就绪链表一个双向链表专门存那些已经发生事件的 fd。epoll_wait被调用时内核根本不需要去遍历红黑树只要看这个链表空不空就行——空了就睡非空就把链表里的节点摘出来返回给用户。这就是 epoll 避免 O(n) 遍历的根本原因它把“找就绪”的工作提前到了事件发生的那一刻而不是等到你调 wait 的时候才去找。第三是回调机制这是把前两者串起来的那根线。每个被监听的 fd 上都会挂一个回调函数当这个 fd 上有事件发生时比如网卡收到数据、socket 缓冲区可写内核在中断或软中断处理路径里就会调用这个回调回调干的事很简单把这个 fd 对应的节点挂到就绪链表上同时唤醒正在epoll_wait上睡眠的进程。整个过程是事件驱动的不需要任何人主动去轮询。打个比方select/poll 是你每隔一会儿亲自去前台问“有我的快递吗”epoll 是你跟前台说“有我的快递就给我打个电话”然后你就可以去干别的了快递到了前台主动通知你。红黑树是前台的登记簿就绪链表是“待通知”的队列回调就是前台看到快递时拨电话的那个动作。1.3 一个 epoll 实例在内核里到底长什么样从用户视角看epoll_create返回的就是一个普通的 fd你可以像操作文件一样对它做close。但在内核里这个 fd 指向的是一个eventpoll结构体它才是整个 epoll 实例的真正载体。这个结构体里最关键的几个字段一个是红黑树的根rbr一个是就绪链表的头rdllist还有一个等待队列wq用来挂那些因为epoll_wait没拿到事件而睡眠的进程。这里有个容易被忽略但很重要的点epoll 实例本身也是一个文件所以它可以被dup、可以在多个进程间共享通过 fork 继承或者传递 fd。多个进程监听同一个 epoll 实例时事件的分发就涉及到“惊群”问题这个后面讲实战坑的时候会展开。理解“epoll 实例是个文件对象”这一点对后面理解它的生命周期管理、理解为什么close之后资源才真正释放都很关键。2. 核心结构深度拆解红黑树、就绪链表与回调上一节讲的是宏观分工这一节往细里抠把每个结构的字段含义、操作路径、以及它们之间怎么协作讲透。这部分是面试重灾区也是真正理解 epoll 的分水岭。2.1 红黑树节点里到底存了什么很多人以为红黑树里存的就是 fd 数字其实不是。红黑树的每个节点是一个epitem结构体它才是真正被管理的最小单位。这个结构体里至少包含这么几样东西ffd里记录了 fd 号和指向对应 file 结构的指针rdllink是就绪链表的挂载点注意这个节点既在红黑树里又可能同时挂在就绪链表上靠的就是这个独立的链表节点event保存了你注册时关心的掩码EPOLLIN、EPOLLOUT 等和用户数据还有nwait之类的字段用于处理同一个 fd 被多个 epoll 实例监听的情况。为什么用epitem而不是直接存 fd因为一个 fd 可能被多个 epoll 实例同时监听每个实例都需要自己独立的一份“关注记录”包括自己关心的掩码、自己的就绪链表挂载点。如果只存 fd 号这些信息就没地方放了。epitem相当于给“某个 epoll 实例对某个 fd 的关注”这件事建了一个档案红黑树就是这些档案的索引。查找的时候内核用 fd 号和 file 指针组合起来作为 key 去红黑树里定位epitem。这也是为什么epoll_ctl的 MOD 和 DEL 能快速找到目标——O(log n) 的树查找而不是像 poll 那样线性扫。2.2 就绪链表为什么能做到 O(1) 取事件就绪链表rdllist是个双向链表它的设计精髓在于“只进已经就绪的”。当一个 fd 上有事件发生回调函数会检查这个epitem是不是已经在链表上了通过rdllink是否为空或者一个专门的标志位判断如果不在就挂上去。这个“判重”很重要否则同一个 fd 在 ET 模式下可能被重复挂多次导致epoll_wait返回重复事件。epoll_wait取事件时直接从链表头开始摘节点摘一个处理一个直到链表空或者达到用户传入的 maxevents。整个过程没有任何遍历红黑树的动作复杂度只和“本次就绪的数量”相关而不是和“监听的 fd 总数”相关。这就是 epoll 在大量连接、少量活跃场景下性能碾压 select/poll 的根本原因。这里有个细节值得说链表节点在返回给用户之前会根据 LT 还是 ET 模式决定要不要重新挂回去。LT 模式下如果这个 fd 的事件没被处理干净它会被重新挂回链表下次epoll_wait还能拿到ET 模式下则不会自动重挂除非有新的边沿事件到来。这个差异是后面 LT/ET 那一节的核心。2.3 回调是怎么被触发和执行的回调函数ep_poll_callback是整个机制的发动机。它被挂在哪里挂在对应 fd 的等待队列上。当你epoll_ctlADD 一个 fd 时内核会通过这个 fd 对应的 file 结构找到它的poll方法每种设备都有自己的 poll 实现socket 有 socket 的管道有管道的然后调用它把自己的回调注册进去。这就是所谓的“把回调挂到设备的等待队列上”。之后当设备上有事件发生——比如网卡收到数据触发中断中断处理里唤醒 socket 的等待队列——内核就会遍历这个等待队列挨个调用挂在上面的回调。轮到ep_poll_callback时它做三件事把对应的epitem挂到就绪链表如果epoll_wait有进程在睡唤醒它处理一些 EPOLLONESHOT 之类的特殊语义。这个设计的高明之处在于把工作分摊到了事件发生的时刻。事件来了立刻处理挂链表epoll_wait只是来取现成的结果。对比 select/poll 那种“事件来了没人管等你问的时候才现找”的模式高下立判。不过这也带来一个副作用如果事件来得极其频繁回调的开销会累积在中断/软中断上下文里这是高负载场景下需要留意的点。2.4 三个结构如何协同一次完整的事件流转把三个结构串起来看一次完整流转理解会更立体。假设你 ADD 了一个 socket fd 并关心 EPOLLIN。第一步内核创建epitem插入红黑树同时调用该 socket 的 poll 方法注册回调。第二步某时刻数据到达网卡中断触发协议栈处理完把数据放进 socket 接收缓冲区唤醒 socket 等待队列回调ep_poll_callback被调用epitem挂入就绪链表若有人在epoll_wait睡眠则唤醒。第三步epoll_wait醒来从就绪链表摘节点把事件信息拷到用户传入的events数组返回就绪个数。第四步用户处理完若是 LT 模式且数据没读完下次 wait 还会拿到若是 ET 模式得等下次数据到达才有新事件。这条链路里红黑树负责“谁被关注”就绪链表负责“谁已经就绪”回调负责“把前者变成后者的搬运工”。三者缺一不可任何一环出问题都会表现为“事件收不到”或者“CPU 空转”。3. 三个系统调用的实现路径与参数细节原理讲完落到代码层面。这一节按epoll_create、epoll_ctl、epoll_wait的顺序把每个调用的内部路径和参数含义讲清楚给出可以直接对照的伪代码和参数计算。3.1 epoll_create实例的诞生epoll_create的签名是int epoll_create(int size)那个 size 参数在现代内核里其实已经被忽略了早期它用来提示内核预分配多大空间现在内核用红黑树动态管理不需要预分配所以传个大于 0 的数就行传 0 或负数会返回 EINVAL。更推荐用epoll_create1它支持EPOLL_CLOEXEC标志避免 fd 泄漏到子进程这是生产环境的好习惯。内部实现大致是分配一个eventpoll结构体初始化红黑树根和就绪链表头初始化等待队列然后创建一个匿名文件对象把这个eventpoll挂到文件的 private_data 上最后分配一个 fd 指向这个文件。所以epoll_create的本质是“造一个文件文件里装着 epoll 实例”。这也解释了为什么 epoll 实例能被 fork 继承、能通过 Unix 域套接字传递——它就是个文件描述符。注意epoll_create返回的 fd 也要记得close否则会泄漏。很多长连接服务跑久了 fd 耗尽查半天发现是 epoll 实例本身没关这种坑我见过不止一次。3.2 epoll_ctl增删改的完整路径epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)是操作入口op 有 ADD、DEL、MOD 三种。以 ADD 为例内部路径大致是先根据 epfd 找到eventpoll实例再根据 fd 找到对应的 file 结构然后用 fd 和 file 组合去红黑树里查是否已存在。如果已存在且 op 是 ADD返回 EEXIST如果不存在且 op 是 DEL 或 MOD返回 ENOENT。存在性检查通过后ADD 会新建一个epitem填充 event 掩码和用户数据插入红黑树然后调用目标 fd 的 poll 方法注册回调。这里有个关键参数是event-events掩码常用的有 EPOLLIN可读、EPOLLOUT可写、EPOLLERR错误、EPOLLHUP挂断、EPOLLET边缘触发、EPOLLONESHOT一次性。掩码的组合决定了回调在什么条件下被触发。MOD 操作稍微特殊一点它不是删了重建而是原地更新epitem的 event 掩码然后重新调用 poll 方法调整回调注册。DEL 则是从红黑树摘除epitem如果它还在就绪链表上也要摘掉然后释放。这里有个性能细节频繁 MOD 同一个 fd 的掩码比如在可读和可写之间来回切会带来持续的树操作和回调重注册开销高并发场景下要尽量避免能一次注册关心的所有事件就别来回改。3.3 epoll_wait取事件的完整流程epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)是消费端。内部流程先检查就绪链表是否非空非空则直接进入收集循环为空则根据 timeout 决定是立即返回、睡固定时间还是无限睡。睡的时候进程被挂到eventpoll的等待队列上等回调来唤醒。收集循环从就绪链表头开始摘节点把epitem里的 event 信息拷到用户空间的events数组计数加一直到达到 maxevents 或链表空。这里 maxevents 必须大于 0且通常建议设成和你的连接规模匹配的值——设太小会导致一次取不完、需要多次 wait设太大浪费内存。实践中一般设成预期并发活跃连接数的量级比如几千到几万。timeout 参数的单位是毫秒-1 表示永久阻塞直到有事件0 表示立即返回非阻塞轮询。这里有个常见误区很多人以为 timeout 是“总耗时上限”其实它是“等待事件的最长时间”一旦有事件到来就提前返回不会等满。理解这一点对设计超时逻辑很重要。3.4 参数计算与选型实例拿一个实际场景算一下。假设一个网关服务预期维持 5 万长连接平均活跃比例 10%即约 5000 个连接同时有事件。那么maxevents设成 5000 到 10000 比较合适一次 wait 基本能取完一轮。如果设成 1024就得 wait 五次才能处理完一轮增加了系统调用次数。红黑树规模是 5 万节点每次epoll_ctl是 O(log 50000) 约 16 次比较完全可以接受。再看 timeout 的选型。如果是纯事件驱动的服务timeout 设 -1 让内核在有事件时唤醒最省 CPU但如果服务还需要定期做心跳检测、超时清理就得设一个有限值比如 100ms 或 1000ms把定时任务和事件处理揉在一个循环里。我一般倾向设一个较小的有限 timeout配合时间轮做超时管理比纯 -1 加独立定时线程更好控制。4. LT 与 ET 的源码级差异与实战选择LT水平触发和 ET边缘触发是 epoll 最容易被讲糊的地方。很多资料只说“LT 是有数据就一直通知ET 是只在状态变化时通知一次”这话没错但太表面。真正要理解的是它们在就绪链表重挂逻辑上的差异以及由此带来的一整套编程约束。4.1 差异到底落在哪一行逻辑上核心差异在epoll_wait收集事件之后、决定要不要把epitem重新挂回就绪链表的那段逻辑。LT 模式下只要这个 fd 上还有未处理的数据即再次调用它的 poll 方法仍返回就绪epitem就会被重新挂回链表下次 wait 还能拿到。ET 模式下只有当这次事件是“新边沿”时才挂处理完就摘掉不会因为数据没读完而反复通知。换个说法LT 是“状态触发”只要处于就绪状态就一直提醒你ET 是“变化触发”只在从非就绪变就绪的那一刻提醒一次。这个差异直接决定了编程模型——LT 下你可以只读一部分数据剩下的下次再读ET 下你必须一次读到 EAGAIN 为止否则剩下的数据可能永远等不到下一次通知。4.2 ET 模式必须配非阻塞 IO 的原因这是 ET 模式最硬的一条约束也是我当年踩坑的地方。ET 下你收到可读通知后必须循环读直到返回 EAGAIN表示缓冲区读空。如果你的 fd 是阻塞的最后一次读在没数据时会阻塞住整个线程整个事件循环就卡死了。所以 ET 必须配非阻塞 fd读到 EAGAIN 才停。这个约束背后的逻辑是ET 只通知一次你必须在这一轮把能处理的都处理完而“处理完”的判定标准就是读到 EAGAIN。阻塞 IO 无法表达“暂时没数据了但连接没断”这个状态只有非阻塞的 EAGAIN 能表达。所以“ET 阻塞 IO”是个必死的组合不是性能问题是正确性问题。4.3 两种模式的适用场景与选型建议LT 的优点是编程简单不容易漏事件适合大多数业务场景尤其是逻辑复杂、一次处理不完、需要分多次读的场合。缺点是如果处理不当比如一直不读会反复触发通知造成“忙等”式的 CPU 消耗。ET 的优点是通知次数少在高并发、事件密集的场景下能减少回调开销和系统调用次数适合追求极致性能的网关、代理类服务。缺点是对编程要求高必须严格循环读到 EAGAIN必须处理短读短写稍有不慎就丢事件。我的选型经验是业务逻辑复杂、团队水平参差的项目优先 LT稳字当头纯转发、逻辑简单、性能敏感的项目用 ET但要配足测试。两者没有绝对优劣只有匹配与否。4.4 EPOLLONESHOT 与多线程下的语义还有一个常被忽略的标志是 EPOLLONESHOT。默认情况下一个 fd 的事件可能被多个线程同时拿到如果多个线程都在同一个 epoll 实例上 wait导致同一个连接被并发处理数据错乱。EPOLLONESHOT 让这个 fd 在触发一次后自动失效必须重新 MOD 才能再次监听从而保证同一时刻只有一个线程处理它。这个标志在多线程 reactor 模型里几乎是标配。用法是注册时带上 EPOLLONESHOT线程拿到事件处理完后重新epoll_ctlMOD 一次恢复监听。这样既保证了并发安全又不需要额外的锁来保护单个连接的状态。代价是每次处理完要多一次 MOD 系统调用但换来的是无锁的清晰模型很划算。5. 实战中的典型问题与排查实录原理和参数都清楚了真正上线还是会遇到各种幺蛾子。这一节把我实际遇到过的问题、排查思路和解决方法整理出来做成速查表方便你对号入座。5.1 事件丢失与 CPU 空转的排查最常见的两类症状一是“连接明明有数据但收不到事件”二是“CPU 莫名飙高但没多少实际流量”。前者多半是 ET 模式下没读到 EAGAIN或者读的时候用了阻塞 fd 卡住了后者多半是 LT 模式下事件没处理干净导致反复触发或者 ET 模式下漏读后靠超时兜底空转。排查手段先用strace看epoll_wait的返回频率和返回值如果返回很频繁但每次就绪数很少基本可以确定是重复触发。再用perf top看热点是不是集中在事件处理循环。定位到具体 fd 后检查它的读写逻辑是否严格循环到 EAGAINfd 是否设了非阻塞。5.2 惊群问题的表现与规避多个进程或线程监听同一个 epoll 实例时一个事件可能唤醒所有等待者但只有一个能真正处理其余的醒来发现没活干又睡回去这就是惊群。表现为上下文切换次数异常高、CPU 有相当比例耗在调度上。规避手段现代内核在epoll_wait层面已经做了部分优化只唤醒一个但跨进程场景仍可能有残留。更彻底的做法是每个线程/进程维护独立的 epoll 实例用 SO_REUSEPORT 让内核在监听层面做负载均衡或者用 EPOLLEXCLUSIVE 标志较新内核支持显式声明独占唤醒。我一般推荐独立 epoll 实例 SO_REUSEPORT 的组合简单可靠。5.3 常见问题速查表症状可能原因排查手段解决方向有数据但收不到事件ET 模式未读到 EAGAIN / 阻塞 fdstrace 看 wait 返回、检查 fd 阻塞标志循环读到 EAGAIN、设非阻塞CPU 空转无流量LT 重复触发 / ET 漏读超时兜底perf top 看热点、统计 wait 频率处理干净事件、修正 ET 读逻辑上下文切换异常高惊群看调度统计、检查监听模式独立 epoll SO_REUSEPORTfd 逐渐耗尽epoll 实例或连接未 closelsof 看 fd 增长趋势补 close、用 EPOLL_CLOEXEC同一连接并发处理错乱多线程共享 epoll 无保护检查是否用 EPOLLONESHOT加 EPOLLONESHOT 处理完 MODMOD 频繁性能差掩码来回切换统计 ctl 调用次数一次注册全部关心事件5.4 几个只有踩过才知道的细节第一个细节epoll_wait返回的 events 数组里data字段是你注册时填的内核原样带回不做任何解释。很多人指望内核帮忙填点额外信息不存在的所有上下文都得自己通过这个字段带进去通常填一个指向连接对象的指针。第二个细节ET 模式下如果一次读到的数据量正好填满你的缓冲区你无法判断是“刚好读完”还是“还有更多”必须再读一次看是否 EAGAIN。这个“多读一次”的开销是 ET 模式的固有成本设计缓冲区大小时要考虑进去。第三个细节epoll_ctl的 DEL 操作如果 fd 已经被 close 了行为在不同内核版本上有差异稳妥做法是先 DEL 再 close别指望 close 自动清理。虽然现代内核在 close 时会清理相关的 epitem但显式 DEL 更可控也避免一些边界情况。6. 从原理到落地一套可复用的实现骨架讲了这么多原理和坑最后给一套可以直接抄作业的骨架把前面的知识点串成一个能跑的最小实现。这套骨架我在多个项目里用过改改就能上。6.1 初始化与事件循环骨架初始化的核心是三步epoll_create1建实例、把监听 socket 设非阻塞并注册 EPOLLIN、进入 wait 循环。下面这段伪代码把关键点都标出来了int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; // 监听 socket 用 LT 即可 ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, 1000); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { accept_new_conn(epfd); // accept 后设非阻塞注册 EPOLLIN|EPOLLET } else { handle_conn(events[i].data.fd); // ET 下必须循环读到 EAGAIN } } do_timeout_check(); // 配合有限 timeout 做超时清理 }这段骨架里监听 socket 用 LT 是因为 accept 逻辑简单、事件不密集连接 socket 用 ET 是为了高并发下减少通知次数。timeout 设 1000ms 是为了定期跑超时检查实际值按业务调整。6.2 连接处理的关键约束连接处理函数里ET 模式下必须遵守“循环读到 EAGAIN”的铁律。读循环的写法是反复read每次处理数据直到返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK 才退出。写操作同理要处理短写把没写完的数据挂到连接的发送缓冲区注册 EPOLLOUT等可写事件来了继续写写完再取消 EPOLLOUT 关注。这里有个容易忽略的点注册 EPOLLOUT 的时机。不要一直关注 EPOLLOUT因为 socket 大部分时间是可写的一直关注会导致事件循环被可写事件淹没。正确做法是“有数据要发且一次没发完时才关注 EPOLLOUT发完立刻取消”。这个开关逻辑是高性能网络库的标配。6.3 性能调优的几个抓手第一是maxevents和缓冲区大小的匹配前面算过按活跃连接数量级设。第二是减少epoll_ctl调用能一次注册的事件别分多次能复用连接别频繁增删。第三是注意回调开销事件极密集时考虑用 ET 减少通知次数。第四是监控epoll_wait的返回分布如果长期返回 0 或很小的数说明 timeout 设得偏大或者事件处理有问题要及时调整。我个人在实际操作中的体会是epoll 的性能问题九成不是 epoll 本身的问题而是使用者对 LT/ET 语义理解不到位导致的逻辑缺陷。把“循环读到 EAGAIN”“非阻塞 fd”“EPOLLONESHOT 保护并发”这几条铁律刻进肌肉记忆大部分坑都能提前避开。真遇到诡异问题别急着怀疑内核先用 strace 和 perf 把现象量化再回头对照原理找差异基本都能定位。这套东西后续还可以往 io_uring 方向扩展着看两者在设计哲学上有不少可以互相印证的地方理解了 epoll 的事件驱动模型再看更新的异步 IO 机制会顺很多。
阅读完成 · 觉得有帮助?