
1. epoll 到底是什么从 select/poll 的痛点到就绪通知模型写过网络服务端的人大概率都经历过这样一个阶段一开始用select连接数一上几百就感觉不对劲换成poll好一点但 CPU 还是有相当一部分耗在无意义的遍历上直到用上 epoll几万连接压在一台普通机器上CPU 也才堪堪跑满一个核。这个变化不是配置调优带来的而是内核里数据结构换了一套的结果。epoll 是 Linux 提供的 I/O 多路复用机制它要解决的问题非常朴素一个线程如何同时盯着成千上万个文件描述符并且只在真正有事件发生时才醒来干活。它对外只暴露三个系统调用epoll_create现在多数场景用epoll_create1、epoll_ctl、epoll_wait。这套接口看起来简单到有点寒酸但背后在内核里塞了红黑树、就绪链表、等待队列回调三套东西配合起来才做到事件驱动 O(就绪数)的复杂度。如果你正在写高性能网关、RPC 框架、Redis 类中间件或者单纯在准备面试想搞清楚epoll 为什么快这篇文章适合你。我不会只停留在红黑树加就绪链表这种背下来的结论上而是把内核里实际的调用链、关键函数、加锁方式、以及几个非常容易踩的语义坑一起摊开讲。看完之后你至少能做到两件事第一能对着strace的输出说清楚每个系统调用干了什么第二遇到 ET 模式下消息读了一半就不来了这种问题能自己定位而不是靠猜。文章里涉及的内核结构体和函数名来自fs/eventpoll.c不同内核版本5.x 与 6.x在个别字段上会有差异比如等待队列条目类型从wait_queue_t换成了wait_queue_entry_t但整体模型是稳定的。下面所有分析都基于这个稳定模型展开。1.1 三个系统调用分别对应什么动作先从使用者的角度把三个调用串一遍这样后面看内核实现时不会迷路。epoll_create返回的是一个文件描述符指向一个匿名 inode 关联的 file 对象。注意这个 fd 是普通 fd可以被dup、可以在fork后被子进程继承也可以被close。它不占网络端口也不是 socket就是一个内核对象的句柄。这一点的实际意义在于如果多个线程共享同一个 epoll fd它们其实在操作同一个eventpoll实例锁竞争和事件分发都会互相影响。epoll_ctl负责增删改。三个操作分别是EPOLL_CTL_ADD把某个 fd 和关心的事件注册进来、EPOLL_CTL_MOD改关注的事件掩码、EPOLL_CTL_DEL摘掉。每次调用都要传 epoll fd、目标 fd、以及一个struct epoll_event。这个结构在 64 位下因为是 packed 的所以只占 12 字节用户态如果自己定义结构体对齐不一致会在某些架构上出问题这是很老但依然有人踩的坑。epoll_wait是消费端。它传入一个用户态数组内核把就绪的事件填进去返回就绪个数。参数里的timeout单位是毫秒-1表示永久阻塞0表示纯轮询立即返回。注意epoll_wait返回 0 只代表超时不代表出错返回 -1 才需要查errno其中EINTR是信号打断属于正常现象业务代码里应该重试而不是当成致命错误。1.2 select/poll 的两处硬伤要理解 epoll 的设计动机得先看清前两代方案到底卡在哪。select的问题比较直白。第一它用位图fd_set表示关注的 fd 集合位图大小在内核里被FD_SETSIZE写死为 1024也就是说 fd 值超过 1023 就没法放进去。第二每次调用都要把整个位图从用户态拷进内核再从内核拷回用户态连接数越多拷贝量越大。第三返回后用户态还得自己从 0 到 maxfd 遍历一遍找出哪些位被置上了。这三件事叠加导致单次调用的开销随最大 fd 值线性增长。poll把位图换成了pollfd数组突破了 1024 的限制但另外两个问题原封不动依然是全量拷贝依然是返回后全量扫描。而且poll的扫描是按数组元素个数来的跟有多少个真正就绪完全无关。把这两代方案抽象一下它们的共同点是**每次调用都重新描述一遍全部关注对象然后内核逐一遍历检查**。这是个典型的轮询模型成本挂在总连接数上而不是活跃连接数上。当你有 1 万个连接、每秒只有 10 个活跃时99.9% 的检查都是浪费。1.3 epoll 的改良把轮询换成注册加回调epoll 的破局点在于把每次告诉内核我关心谁变成只在变化时告诉内核把内核逐一遍历变成谁有事谁自己上报。具体做法是epoll_ctl(ADD)时内核为目标 fd 创建一个epitem挂进红黑树同时通过目标文件自己的poll方法把一个回调函数ep_poll_callback注册到目标文件的等待队列上。之后每当这个 fd 状态变化socket 收到数据、缓冲区从满变空等内核在唤醒等待队列时就会顺带调用这个回调回调把对应的epitem挂进就绪链表。epoll_wait醒来后只需要看就绪链表里有没有东西有就直接取不用扫全表。所以 epoll 的复杂度是O(1)准确说是 O(就绪数)与总注册数无关。这就是几万连接只占一个核的底气所在。代价是内核要维护红黑树和回调注册epoll_ctl本身比poll的一次调用贵但因为它调用频率低这笔账算下来非常划算。2. 内核里的三块基石eventpoll、epitem 与红黑树把系统调用的外壳剥掉epoll 在内核里其实就围绕三个结构体转eventpoll代表一个 epoll 实例epitem代表实例里的一个被监听对象eppoll_entry代表挂在目标文件等待队列上的那个钩子。理解这三者的关系后面所有流程都能自己推出来。2.1 eventpoll一个实例的全部家当struct eventpoll是epoll_create时kzalloc出来的随 epoll fd 的生命周期存在。它的核心字段可以分成三组来记第一组是存放就绪结果的struct list_head rdllist就是那个著名的就绪链表。epoll_wait每次都从这个链表里取事件。第二组是存放注册关系的struct rb_root_cached rbr是红黑树根树里每个节点是一个epitem。用红黑树而不是哈希表是因为需要按 fd 有序地快速查找、插入、删除红黑树的 O(log n) 在这里足够而且不用处理哈希冲突和扩容。rb_root_cached相比普通的rb_root额外缓存了最左节点某些需要按序扫描的场景能省一次下降。第三组是同步和等待相关的spinlock_t lock保护就绪链表和红黑树的短临界区struct mutex mtx保护epoll_ctl这类可能睡眠的路径wait_queue_head_t wq是epoll_wait自己睡觉用的等待队列别和poll_wait搞混——后者是给epoll fd 自己又被别人 epoll这种嵌套场景准备的。源码里还能看到ovflist、txlist、visited_list_link、genl这些字段它们都是为特定路径服务的临时结构下面讲事件传递和嵌套检测时会提到。2.2 epitem被监听对象的完整档案struct epitem每次EPOLL_CTL_ADD时创建DEL时销毁。它的字段设计得非常紧凑每个都有明确用途。union { struct rb_node rbn; struct rcu_head rcu; }这个联合体很有意思当 epitem 挂在红黑树上时用rbn当它被删除等待 RCU 宽限期回收时用rcu。两者不会同时需要所以合并省了内存。struct list_head rdllink是把它挂到eventpoll.rdllist上的链表节点。struct epitem *next用在ovflist溢出链上这是个单向链表因为溢出场景只在事件传递过程中出现不需要双向。struct epoll_filefd ffd里存了目标文件指针和 fd 号。int nwait记录挂了多少个等待队列条目。struct list_head pwqlist是eppoll_entry的链表头因为一个 fd 可能同时挂在多个等待队列上比如 socket 的可读队列和可写队列。struct epoll_event event存的是用户注册时传进来的事件掩码和 data其中 data 通常是用户自己放进去的 fd 或者指针epoll_wait返回时会原样带回。2.3 红黑树与就绪链表为什么必须共存一个自然的疑问是既然有就绪链表了为什么还要红黑树反过来既然有红黑树了为什么还要就绪链表答案是两者承担的任务不同缺一不可。红黑树回答的是这个 fd 我注册过没有、注册的事件是什么它必须能按 fd 快速查找因为epoll_ctl的 MOD 和 DEL 都要先找到对应的 epitem。如果只用链表查找就是 O(n)几万连接下每次 ctl 都是灾难。就绪链表回答的是现在谁有事它必须能在事件发生时 O(1) 挂入、在epoll_wait时 O(就绪数) 取出。如果只用红黑树epoll_wait就得遍历整棵树才能知道谁就绪又回到了 O(n)。所以这两者是一个**全集索引 活跃子集队列**的经典组合。同一个 epitem 通过不同字段分别挂在两个结构上通过rbn挂在红黑树通过rdllink挂在就绪链表。一个节点同时属于两个容器这在 Linux 内核里是常见手法代价是删除时要记得两边都摘。2.4 eppoll_entry把 epoll 的钩子插进目标文件光有 eventpoll 和 epitem 还不够关键问题是目标 fd 有事件时怎么知道要通知哪个 epoll 实例这靠的是struct eppoll_entry。epoll_ctl(ADD)会调用目标文件的poll方法传入一个wait_address目标文件的等待队列头和一个poll_table这个 table 的回调是ep_ptable_queue_proc。在这个回调里内核分配一个eppoll_entry把entry-wait.func设成ep_poll_callback然后add_wait_queue把它挂到目标文件的等待队列上。eppoll_entry里有个base指针指回 epitemwhead指回等待队列头。这样当目标文件被唤醒时内核遍历等待队列调到的就是ep_poll_callback回调里通过base找到 epitem再通过epitem-ep找到 eventpoll把 epitem 挂进 rdllist最后wake_up唤醒在eventpoll.wq上睡觉的epoll_wait。这条链路串起来就是目标 fd 状态变化 → 唤醒目标文件等待队列 → 调用 ep_poll_callback → epitem 入就绪链表 → 唤醒 epoll_wait。整条链上没有全量扫描这就是 epoll 高效的本质。3. 三个系统调用的内核走位拆解上一节讲的是静态结构这一节跟着代码走一遍动态流程。我会按调用顺序把关键函数标出来你可以对照fs/eventpoll.c看。3.1 epoll_create 与 epoll_create1epoll_create和epoll_create1最后都汇到do_epoll_create。它的动作顺序大致是检查 flags 合法性调用ep_alloc分配eventpoll并初始化各个链表和锁然后anon_inode_getfile拿到一个匿名 inode 的 file 对象把它和 eventpoll 互相绑定最后fd_install安装到当前进程的 fd 表里返回 fd。有个细节值得留意eventpoll里存了struct user_struct *user用来对max_user_watches做限额。每个 epitem 会消耗一个 watch这个值可以通过/proc/sys/fs/epoll/max_user_watches查看和调整。在内存大、连接数多的机器上默认值有时会成为隐形瓶颈报错是ENOSPC很容易被误判为磁盘问题。epoll_create的参数size从 Linux 2.6.8 之后就被忽略了内核只把它当成必须大于 0的校验。所以你会看到老代码写epoll_create(1024)其实写 1 也行。3.2 epoll_ctl 的 ADD、MOD、DEL 分支epoll_ctl进来先做参数检查然后用ep_find在红黑树里查目标 fd 对应的 epitem根据查到的结果和用户要做的操作交叉判断ADD且已存在返回EEXIST。MOD或DEL但不存在返回ENOENT。其余情况进入对应处理函数。ep_insert是最复杂的一条路径它要做这么几件事分配 epitem 并填充 ffd 和 event把 epitem 插入红黑树调用ep_item_poll触发目标文件的poll在这个过程中分发eppoll_entry并注册回调如果这次poll返回了已经就绪的事件就直接把 epitem 挂进 rdllist并且因为已经有事件还要wake_up一下eventpoll.wq防止有线程正睡着等不到通知。这里有个很容易被忽略的点epoll_ctl(ADD)之后如果目标 fd 当前就已经可读那么epoll_wait会立刻返回这个事件即使此后没有任何新的状态变化。这正是 LT 模式的语义基础和 ET 形成鲜明对比。ep_remove做的是逆操作从红黑树摘除、从就绪链表摘除可能不在上面用list_del_init的变体判断、释放所有eppoll_entry、wake_up等。ep_modify则相对轻量主要是更新 event 掩码并重新触发一次 poll 检查就绪状态因为掩码变了之后是否就绪的结论可能变。3.3 epoll_wait 的主循环epoll_wait最终调到ep_poll可以把它理解成一个先查后睡再查的循环。第一步加锁检查rdllist是否为空。如果不为空直接跳到事件传递环节整个epoll_wait不会睡眠这也是为什么高负载下epoll_wait通常立刻返回。第二步如果就绪链表为空且timeout ! 0就把当前任务以TASK_INTERRUPTIBLE状态挂到eventpoll.wq上然后调用schedule_hrtimeout_range睡眠。这里用的是高精度定时器超时精度比老的 jiffies 方案好很多。第三步被唤醒后可能是回调wake_up也可能是超时也可能是信号重新检查rdllist和timeout判断是否需要继续睡。第四步真正有事件时调用ep_send_events把事件填到用户空间。填的过程会加eventpoll.mtx互斥锁这是为了和epoll_ctl串行化避免传递过程中红黑树被改。timeout的处理有个小细节内核会把毫秒转成ktime_t再转成 jiffies。timeout为-1表示无限等待0表示立即返回。转换过程中如果算出来的 jiffies 小于实际需要会有微小的时间偏差业务上一般不用在意。3.4 ep_poll_callback 里的加锁与入队这是整个机制里最核心的一个回调它运行在中断上下文或者唤醒路径上所以不能睡眠。它的逻辑大致是先取epitem-ep拿到 eventpoll加自旋锁。然后检查这个 epitem 是不是已经在链表上通过ep_is_linked判断rdllink是否为空如果已经在了就跳过避免重复挂。如果不在就list_add_tail挂到 rdllist。接着判断当前是否处于事件传递过程中ep-ovflist ! EP_UNACTIVE_PTR如果是说明正在ep_send_events遍历 txlist此时新来的事件不能直接塞 rdllist而是挂到ovflist溢出链上等传递完成后由ep_scan_ready_list合并回 rdllist。最后如果唤醒的是自己!ep_is_linked(epi-rdllink)变化了或者满足其他条件就调用wake_up(ep-wq)唤醒可能在epoll_wait上睡眠的任务。实测小技巧如果你把断点打在ep_poll_callback会发现它被调用的次数往往远大于epoll_wait返回的事件数。这是因为唤醒路径可能被多次触发而回调里有去重逻辑把重复的挡掉了。这也解释了为什么 ET 模式下事件合并是天然的内核本来就在做去重。4. LT 与 ET一次唤醒背后的触发语义差异这是面试和实战里被问最多、也最容易出错的部分。很多人能背出LT 水平触发、ET 边缘触发但问到ET 为什么必须配非阻塞、读了一半会怎样就答不清楚了。我们从内核行为讲起。4.1 水平触发为什么每次都可能返回LT 的判定发生在ep_send_events_proc里。遍历某个 epitem 时内核会再调一次ep_item_poll得到当前实际就绪的事件掩码。如果这个掩码非零就把事件填给用户。填完之后有一个关键分支如果当前是 LT 模式并且这次 poll 的结果显示事件仍然就绪就把这个 epitem 重新挂回 rdllist。这就是 LT 的本质它不像名字暗示的那样是水平信号而是只要条件还成立就反复上报。你这次没读完下次epoll_wait还会给你。这个行为对业务代码非常友好因为漏读不会导致事件丢失最多是重复唤醒。代价是在有大量持续就绪连接时LT 模式下epoll_wait会不断返回同一批 fdCPU 花在重复上报上。这也是为什么一些追求极致性能的框架会选择 ET。4.2 边缘触发与非阻塞的强绑定ET 的判定同样在ep_send_events_proc区别在于只有状态从不就绪变成就绪的那一次才上报上报完不再挂回 rdllist。除非这个 fd 再次经历从无到有的边沿变化否则不会再有事件。那么问题来了如果 socket 缓冲区里有 10KB 数据ET 模式下epoll_wait只通知你一次。你读了 4KB 就停手剩下的 6KB 怎么办答案是——不会再有通知因为缓冲区里始终可读可读这个条件没有发生新的边沿变化。这就是经典的ET 漏读。正确的做法是ET 模式下必须在收到事件后用循环一直读到read返回EAGAIN或EWOULDBLOCK把所有数据榨干。而为了让这个循环能正常终止fd必须设为非阻塞。如果是阻塞 fd读到没数据时read会挂住整个线程你就再也没机会回去处理别的连接了。// ET 模式下的标准读循环骨架 for (;;) { n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { // 对端关闭 close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 读干净了正常退出 } if (errno EINTR) { continue; // 被信号打断重试 } // 真正的错误 close(fd); break; } }这段代码看着简单但有几个细节EINTR必须重试否则信号一来就读漏n 0和EAGAIN是两回事前者是对端关闭后者是暂时无数据写路径同理write返回EAGAIN时要挂到写事件上等下次通知。4.3 EPOLLONESHOT 与 EPOLLEXCLUSIVE 的适用场景这两个是容易被忽视但很有用的 flag。EPOLLONESHOT的作用是事件上报一次后这个 fd 在 epoll 里就自动失效了除非你手动EPOLL_CTL_MOD重新激活。为什么需要它考虑多线程共享一个 epoll 的场景一个连接的数据可能被线程 A 读了一部分然后线程 B 又被唤醒去读同一份数据两个线程操作同一个 fd状态就乱了。EPOLLONESHOT保证同一时刻只有一个线程在处理这个 fd处理完再重新注册是很多框架保证连接不能被两个线程同时处理的标准手段。EPOLLEXCLUSIVE是 Linux 4.5 加入的用来缓解惊群。多个进程或线程各自有自己的 epoll但监听同一个 listen fd。当新连接到来时默认会唤醒所有 epoll 实例只有一个能 accept 成功其余白醒一次。加上EPOLLEXCLUSIVE后内核在唤醒时只挑一个等待者。注意EPOLLEXCLUSIVE只能在EPOLL_CTL_ADD时使用和EPOLLONESHOT也不能同时用混用会返回EINVAL。它默认是非排他地尽量少唤醒不保证绝对的精确一次。至于用不用 ET我的经验是LT 写起来省心适合业务逻辑复杂、读不干净也无所谓的场景ET 适合自己完全掌控读写循环、追求减少重复唤醒的场景比如网络框架的底层 reactor。选哪个更多是工程取舍不是性能教条。5. 几个容易踩坑的内核细节原理讲完了这一节说说实际开发里踩过的坑。有些问题在文档里根本不会写但线上会真的把你卡住。5.1 就绪事件是填充而不是拷贝很多人以为epoll_wait是把内核里攒着的事件拷贝一份给用户其实更准确的说法是每次调用时现算一遍事件掩码再填进用户数组。原因在于rdllist上挂的只是 epitem里面存的event.events是你注册时给的关注掩码不是当前的实际就绪掩码。真正的事件是ep_send_events_proc遍历时调用ep_item_poll拿到的revents。这意味着epoll_wait返回的事件反映的是调用那一刻的状态不是事件发生那一刻的状态。如果你注册了EPOLLIN | EPOLLOUT但epoll_wait返回时数据已经被别人读走了你可能拿到 0 事件内核会跳过 revents 为 0 的项。这个机制有个直接后果epoll_wait返回的events里data是你注册时的原样但events掩码是现算的。所以别指望在data里放什么事件历史它不带状态。5.2 惊群与多线程 accept 的取舍传统多进程/多线程 accept 的模式是N 个 worker 各自accept同一个 listen fd。新连接来时内核唤醒所有等待者只有一个成功其余返回EAGAIN。这就是惊群连接数一高白白浪费的 CPU 很可观。解决办法有几条路各有代价。一条是用EPOLLEXCLUSIVE让内核只唤醒一个。另一条是自己搞定分发只让主线程持有 listen fd 并 accept然后把连接 fd 通过 Unix 域套接字或者共享队列分发给 worker。还有一条是让 worker 各自监听自己的 listen fd靠SO_REUSEPORT让内核做四层负载均衡这是目前不少高性能服务的做法代价是连接分布可能不均匀且某些特性如 listen backlog 的共享行为和单 fd 不同。选哪条要看你对连接亲和性的要求。SO_REUSEPORT简单但不好做全局连接数控制主线程分发多一次跨线程传递但可以按负载灵活调度。5.3 epoll 嵌套与 loop checkepoll fd 本身是一个文件可以被EPOLL_CTL_ADD加到另一个 epoll 里这就是 epoll 嵌套。它能用但内核加了深度限制。主要原因是要防止 A 监听 B、B 监听 A 这种循环或者更长的环形依赖导致ep_poll_callback无限递归。内核用ep_loop_check_proc做检测从被添加的 epoll 往下遍历它的整个监听子树最大深度是EP_MAX_NESTS值为 4。超过会返回ELOOP。eventpoll里的visited和visited_list_link就是为这个检测服务的遍历前把所有访问过的节点串起来检测完统一复位。如果你真的遇到ELOOP大概率是设计上把 epoll 用得过于绕了通常拆掉一层或者换成用户态的事件分发会更清楚。5.4 fd 关闭、dup 与 fork 的引用计数问题epoll 里存的是file指针不是 fd 号本身。这个区别在几个场景下会暴露出来场景一手动close(fd)后忘记EPOLL_CTL_DEL。只要你没有别的引用file 的引用计数归零内核会通过 file 的释放路径自动把对应 epitem 从 epoll 里摘掉不算泄漏。但如果这个 file 还被dup出来的另一个 fd 引用着close 一个不会触发清理epoll 里依然挂着还会继续上报事件——这时候你可能会收到一个意外的可读事件。场景二fork之后。子进程继承了 epoll fd父子的 epoll fd 指向同一个eventpoll事件会被两边竞争消费。这通常不是你想要的行为。如果确实要让子进程独立应该在子进程里重新epoll_create或者用close-on-exec配合exec。场景三dup出来的 fd 值不同但指向同一个 file。在 epoll 里它们会被当成两个不同的 fd因为 key 是 fd 号加 file 指针的组合可以分别注册但事件其实是同一份。这类用法极少见真遇到了要小心事件重复。6. 实测与排查把原理落到可观测的行为上光看代码容易产生我懂了的错觉真正把这些机制跑一遍、用工具看一眼理解会牢得多。这一节讲讲怎么用日常工具观察 epoll 的行为。6.1 用 strace 看真实的调用序列跑一个最简单的 epoll 服务端strace -f -e traceepoll_create1,epoll_ctl,epoll_wait,accept4 ./server输出会非常直观地展示整条链路。你会看到epoll_create1(EPOLL_CLOEXEC)创建实例然后一串epoll_ctl(ADD)注册 listen fd接着epoll_wait挂起。客户端连上来时epoll_wait返回 1紧接着accept4拿到连接 fd然后又是一串epoll_ctl(ADD)把新连接注册进去。观察这些输出时留意两点。一是epoll_ctl的参数EPOLL_CTL_ADD后面跟的epoll_event结构会显示成{eventsEPOLLIN, data{u32...}}events是不是包含EPOLLET一眼可见。二是epoll_wait返回的顺序和频率LT 模式下你会看到同一个 fd 反复出现ET 模式下只在边沿出现。如果epoll_wait一直返回 0 而 CPU 还很高说明timeout设成了 0 变成了忙轮询或者有别的线程在空转这两种情况要区分开。6.2 用 /proc 和 perf 观察内部开销/proc/pid/fdinfo/epfd这个文件很有用。查看一个 epoll fd 的 fdinfo会列出这个实例当前监控的 fd 列表、注册的事件掩码以及tfd目标 fd、events、data字段。连接泄漏、注册了不该注册的 fd、事件掩码写错都能在这里一眼看出。如果要看开销分布perf top -p pid或perf record里搜ep_poll_callback、ep_send_events、ep_insert这些符号。正常情况下ep_poll_callback占比应该很低。如果它高得离谱通常是事件风暴——某个 fd 在短时间内剧烈变化回调被反复触发。这时候要回头看业务逻辑是不是有忙等待或者小包发送。max_user_watches的用量可以间接从epoll实例的 epitem 数量估算。如果你在 2GB 内存的机器上开了几万个 epoll 实例每个实例又注册了大量 fd可能会碰到ENOSPC。调整/proc/sys/fs/epoll/max_user_watches需要 root且要考虑内核内存开销每个 watch 大约占几十字节。6.3 常见问题速查表把前面几节提到的问题整理成一张表方便对照排查。现象可能原因排查手段与处理epoll_wait一直返回 0 但 CPU 高timeout传 0 变忙轮询或线程空转检查 timeout 参数用perf top定位热点ET 模式下数据读到一半就断了没有循环读到EAGAIN改成循环读确认 fd 为非阻塞epoll_ctl(ADD)返回EEXIST同一 fd 重复注册用 fdinfo 查已注册列表改为MODepoll_ctl(MOD/DEL)返回ENOENTfd 从未注册或已被移除确认注册顺序检查是否有地方提前close注册返回ENOSPC超过max_user_watches调整/proc/sys/fs/epoll/max_user_watches注册返回ELOOPepoll 嵌套形成环或超深检查嵌套结构深度上限为 4新连接到来所有 worker 都被唤醒惊群用EPOLLEXCLUSIVE或改为主线程分发同一连接被两个线程处理多线程共享 epoll 无保护用EPOLLONESHOT加重新注册epoll_wait偶发EINTR信号打断判断 errno 后重试不要当致命错误fd 关了事件还在上报file 被dup或 fork 共享引用计数未归零显式EPOLL_CTL_DEL理清 fd 生命周期6.4 实操心得我自己踩过的那几个坑说几个具体经历可能比抽象结论更有用。第一个坑是 ET 模式下的半包。早年写一个协议解析服务ET 模式读循环里判断收到完整包就break结果后面的数据就再也不来了。当时以为是内核 bug查了两天才反应过来break之后缓冲区里还有残留边沿已经过去了。正确做法是不管包是否完整都要读到EAGAIN为止把数据全收进来放到应用层的缓冲区里再解析。第二个坑是 LT 模式下的 CPU 虚高。一个服务在低负载时 CPU 就 30%后来发现是epoll_wait的timeout设成了 100ms而同时有大量空闲连接处于可读状态对端每隔一段时间发心跳导致每次都在处理这一批 fd。改成对心跳连接单独管理、加上EPOLLONESHOT避免重复上报后空闲 CPU 降到 3%。这说明 LT 的重复上报在连接数大时确实是真实成本。第三个坑是close顺序。有一段代码先close(fd)再在异常路径里尝试EPOLL_CTL_DELDEL 返回ENOENT被当成错误打了日志其实完全正常因为 file 引用已经归零epitem 已经被内核清掉了。后来把日志级别降下来并且约定先 DEL 再 close顺序清楚之后就没再误报过。第四个是EPOLLEXCLUSIVE的版本问题。在比较老的发行版内核上这个 flag 不存在编译能过但运行时EPOLL_CTL_ADD返回EINVAL。上线前一定要确认目标内核版本或者做运行时探测。这几个坑的共同点是它们都不是 API 用错了而是对事件语义的理解有偏差。把 epoll 当黑盒用迟早会在某个边界场景翻车理解了 rdllist、回调、LT/ET 判定这三件事大部分问题都能提前避开。