拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux epoll内核原理:红黑树、就绪链表与LT/ET触发机制

Linux epoll内核原理:红黑树、就绪链表与LT/ET触发机制

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指针指回 epitem,whead指回等待队列头。这样当目标文件被唤醒时,内核遍历等待队列,调到的就是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_create1

epoll_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会不断返回同一批 fd,CPU 花在重复上报上。这也是为什么一些追求极致性能的框架会选择 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 check

epoll 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 trace=epoll_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结构会显示成{events=EPOLLIN, 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 查已注册列表,改为MOD
epoll_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_DEL,DEL 返回ENOENT被当成错误打了日志,其实完全正常,因为 file 引用已经归零,epitem 已经被内核清掉了。后来把日志级别降下来,并且约定"先 DEL 再 close",顺序清楚之后就没再误报过。

第四个是EPOLLEXCLUSIVE的版本问题。在比较老的发行版内核上这个 flag 不存在,编译能过但运行时EPOLL_CTL_ADD返回EINVAL。上线前一定要确认目标内核版本,或者做运行时探测。

这几个坑的共同点是:它们都不是 API 用错了,而是对事件语义的理解有偏差。把 epoll 当黑盒用,迟早会在某个边界场景翻车;理解了 rdllist、回调、LT/ET 判定这三件事,大部分问题都能提前避开。

返回列表