
在高并发的 Linux 服务端开发里epoll 是那个绕不开的“地基”。面试时几乎都会被问到 LT 和 ET 有什么区别多数人也能背出几句标准答案水平触发会一直提醒边缘触发只提醒一次。可一旦真的上手写代码尤其是把一个几百人用的服务从 LT 改成 ET问题就会一个接一个冒出来CPU 跑满、连接假死、事件丢失最后排查来排查去根子往往就出在对这两种触发模式的语义没有真正吃透。这篇文章把我这些年折腾 epoll LT/ET 的经验整理出来既讲清楚原理层为什么会有两种模式也给出 ET 模式能直接落地的完整写法和避坑清单。如果你正在写网络服务或者准备把某个模块改造成 epoll 模型又或者已经被 ET 的“疑难杂症”折磨过几轮本文应该能帮上忙。文章不堆概念尽量把内核行为、用户态写法和业务选型放在一个故事里讲完。1. 项目概述epoll 到底在解决什么问题LT 与 ET 又是哪两个“人”1.1 从 select 到 epoll为什么需要换一个 IO 模型在解释 LT 和 ET 之前先回到最基础的问题epoll 是用来干嘛的。一个服务器进程需要同时处理成千上万个网络连接早期的 select/poll 模型要轮询所有 fd每调用一次都让内核扫描一遍全部文件描述符然后告诉调用者哪些 fd 有事件。连接数量少的时候感觉不到什么一旦连接数到几千每次 select 都把 fds 集合从用户态拷贝到内核态还要线性扫描CPU 开销变得非常明显。那个时代也有人用多进程来分摊但进程数量上来后内存和调度开销同样吓人。epoll 的出现改变了这个模式。它把一个进程感兴趣的 fd 统一注册到内核中的一个事件表里内核在连接状态变化时维护一个独立的就绪链表用户侧只需要在每次循环里从就绪链表上取事件。fd 数量很大时epoll 不会随着连接总数线性退化实际只在就绪事件数量上做文章。这正是它成为高并发网络服务首选的根本原因。有了 epoll 这个工具后LT 和 ET 其实是事件通知策略上的两个分支。它们解决的同一个问题是“内核什么时候、以什么频率告诉用户某个 fd 有事”但采取的方式完全相反所以我把它们比作 epoll 的双面人生。1.2 LT 与 ET 的一句话差异便于记忆的核心差异是LTLevel Triggered水平触发只要 fd 处于可读或可写状态每次调用 epoll_wait 都会返回这个 fd。ETEdge Triggered边缘触发fd 从不可读变为可读、或从不可写变为可写的那一刻才会通知一次之后如果不再次发生状态跳变即使状态一直存在也不会再通知。用生活里的类比就是家里的烟雾报警器。LT 模式相当于“只要检测到烟雾就一直响”直到你把烟雾处理干净ET 模式相当于“只在烟雾出现的那一秒响一次”如果你没把握住那一秒去处理后面它不会再提醒你除非烟雾散了再来一轮。这个区别看似简单但正是因为“持续提醒”和“只提醒一次”的差异导致两种模式在用户态的代码写法、性能表现和出错行为上完全不同。1.3 两种模式在现实项目里的分布实际使用中LT 的普及度其实比很多人想象得要广。因为 LT 对应的是最简单稳妥的语义epoll_wait 返回什么你读就是了读到读不动为止读不完也不用担心下轮它会再报。这让“事件循环”这类基础组件非常适合用 LT 兜底。ET 则出现在追求极致性能的路径上。nginx 事件模块、部分自研高并发网关在数据转发场景里就倾向于 ET 的“一次性尽可能做完事”的风格。它减少系统调用频次也降低无效唤醒代价是需要用户自己承担更多的状态管理责任。下面几个章节会把这套逻辑展开讲。2. 原理拆解LT 和 ET 在内核里的“双面”差异2.1 LT事件一直挂在就绪链表上反复提醒要真正理解 LT需要知道内核维护了几样东西。每个 epoll 实例内部都有一棵红黑树你通过 epoll_ctl 往里面添加的被监视 fd以 epitem 节点形式挂在树上。同时还有一条就绪链表rdllist当 fd 上有事件发生时对应的 epitem 会被放到这条链表上。LT 模式下只要 fd 仍然处于可读/可写状态epitem 就一直保留在就绪链表上。所以每次 epoll_wait 从 rdllist 取走并返回了这个 fd内核并不会把它从链表上摘除。下一轮 epoll_wait 再来时就绪链表中依然有它于是又返回一次。循环往复听起来好像有点傻但这正是“水平触发”的语义只要电平是高的输出持续有效。这里容易产生一个误区很多人以为 LT 模式下内核每时每刻都在通知你其实不是。它只是在每次 epoll_wait 被调用时把你没消化的就绪事件重新报一遍。如果你一直阻塞在 epoll_wait 里有事件的 fd 会被反复上报但这并不代表高频率的额外开销只有在你频繁调用 epoll_wait 而事件又一直未处理完时多余的返回次数才会显现出来。2.2 ET事件上报一次后立即出列ET 模式的处理就完全不同了。当 fd 状态发生跳变时内核把 epitem 放入 rdllist 并唤醒等待者。但是在用户通过 epoll_wait 取走这个事件之后内核会立刻将 epitem 摘出就绪链表。也就是说一次事件对应一次上报不会重复提醒。从数字电路的角度看这就相当于边沿检测只在上升沿或下降沿产生一个脉冲。fd 从“无数据可读”变成“有数据可读”这是一个上升沿从“可写”变成“不可写”再变成“可写”同样也是边沿。之后电平的高低变化本身不再产生新的脉冲除非再次出现跳变。这个机制带来一个非常直接的推论ET 模式下如果上一次事件触发时你只读了 100 字节而 socket 缓冲区里还剩 900 字节那么这 900 字节不会再有独立的事件通知。你必须在上一个事件处理周期内把数据尽量读完否则这 900 字节就一直躺在缓冲区里等待下一次“边沿”的出现。这个“下一次边沿”什么时候来可能是 1 毫秒后也可能是 1 小时后完全看对端什么时候再发数据。很多线上假死就是这么出现的。2.3 内核实现层面epitem、rdllist 与 eventpoll我平时排查问题习惯从数据结构倒推行为。epoll 中三个关键对象epitem红黑树中的一个节点描述一个被监听的 fd 以及关注的事件集合。rdllist就绪链表所有有事件要上报的 epitem 都在这里排队。eventpoll整个 epoll 实例的根对象聚合了上述结构以及等待队列。LT 与 ET 对这三个对象的使用差异集中在 rdllist 上。LT 模式下事件上报后 epitem 不离开 rdllistET 模式下上报后立即把它从 rdllist 移除。这个差异直接决定了下一个 epoll_wait 是否还会遇到同一个 fd。还有一个细节epoll_ctl 在重新修改 fd 事件时如果该 fd 已经在 rdllist 中内核会相应调整事件掩码。这就意味着用户可以在运行时把 fd 从监听 EPOLLIN 改成监听 EPOLLOUT或者通过重新注册把 LT 改成 ET但要注意重新注册时的边界行为。比如你原本在 epoll_ctl 添加 fd 时没有设置 EPOLLET后来又调一次 EPOLL_CTL_ADD内核会报 EEXIST正确做法是先用 EPOLL_CTL_DEL 或直接用 EPOLL_CTL_MOD 修改事件。这些细节在线上重构时特别容易被忽略。2.4 为什么 ET 在特定负载下性能更好很多人以为 ET 一定比 LT 快其实要看场景。我理解 ET 的性能优势来源主要有两个。第一系统调用次数更少。LT 模式下如果一个连接长期保持可读读了一部分数据没读完下一轮 epoll_wait 又会立刻返回它这样用户就不得不在“epoll_wait-读-处理- epoll_wait”之间来回切换。ET 模式下则是一次事件把数据尽量读完然后再回到 epoll_wait 上等待新的跳变。每轮循环的系统调用总数下降了。第二唤醒次数更少。LT 会让一个持续可读的 fd 不断唤醒事件循环导致 CPU 频繁从阻塞态起来又回去。ET 模式告诉内核“只叫一次就够了”让进程尽可能睡在 epoll_wait 上节省了大量无意义的调度与上下文切换。当然这是相对的理论收益具体落地还是看业务流量特征。比如大包持续转发的场景ET 收益明显而大量小包、单包处理完就关闭连接的情况下LT 和 ET 差异往往很小因为每个事件本来就只出现一次。3. 实操过程ET 模式从代码到线上的完整打法3.1 前置条件ET 必须搭配非阻塞 IOET 模式开发的第一个前置条件是把所有挂到 epoll 上的 fd 设置为非阻塞。为什么是硬性要求举一个真实后果就清楚了ET 事件触发后用户按语义需要循环读取数据直到缓冲为空。你用 read 去读读着读着发现没数据了如果 fd 是阻塞模式read 会停在那里等待新数据。这一停整个事件循环线程就被拖住了其他几百上千个连接全部得不到处理。设置非阻塞的标准做法int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);也可以用 accept4 在 accept 时直接带上 SOCK_NONBLOCK避免多一次 fcntl。还有一点非常关键从 accept 返回的新 fd 并不会自动继承 listen fd 的非阻塞属性你必须对每个新连接都单独设置。这个坑我在代码评审时看到过很多次accept 循环里忘了设置非阻塞最后跑起来表现就是极其隐性的卡顿。3.2 数据读取必须把循环 read 到 EAGAIN 作为纪律ET 模式的第二条纪律是每次 EPOLLIN 事件处理时必须循环读取直到碰到 EAGAIN 为止。这背后是 ET 的“只通知一次”语义在约束我们没有下一次补通知的机会。一个规范的事件处理顺序定义固定大小的缓冲区常见的 4KB 或 16KB。循环 read。返回值大于 0 时把数据交给业务逻辑处理然后继续 read。返回值等于 0 时表示对端已关闭清理连接后退出循环。返回 -1 且 errno 等于 EAGAIN 或 EWOULDBLOCK说明当前数据已经读尽正常结束本轮事件处理。返回 -1 且 errno 为 EINTR说明被信号打断根据业务决定是重试还是退出。其他错误码按异常处理关闭 fd。注意在 Linux 上 EAGAIN 和 EWOULDBLOCK 的数值相同但为了可移植性代码里最好两个常量都判断。还有一点EINTR 场景往往被人忽略如果信号密集read 可能频繁返回 EINTR处理不当也会造成读循环提前结束。很多时候“事件丢失”不是内核丢了事件而是读循环在数据还没读完之前就因为某个中途返回走了。比如有些代码喜欢在 read 一次后先去处理业务就绪队列结果队列处理完不再回来继续 read连接上的残留数据就被永远留在了缓冲区。ET 模式下这是致命的。3.3 accept 循环新连接也要一次性收完与数据读取类似ET 模式下的 listen fd 在 EPOLLIN 事件触发后必须循环调用 accept直到返回 EAGAIN。原因在于内核只会在有新连接到达时触发一次可读事件如果只 accept 一次就回 epoll_wait剩下的积压连接无法被及时处理。真实的线上案例某个短连接服务把 listen fd 注册成 ET但 accept 只做一次连接量一上来客户端很多请求超时。排查时用 ss 命令看 socket receive 队列发现 listen fd 的 backlog 里有积压但服务器没有 accept就像门卫只接待了敲门的第一位客人就把门关上了。改成循环 accept 后积压立刻消失。accept 循环的核心判断就是错误码for (;;) { int cfd accept4(lfd, NULL, NULL, SOCK_NONBLOCK); if (cfd -1) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno ECONNABORTED || errno EINTR) continue; // 或根据策略退出 break; } // 注册 cfd 到 epoll, 设置 EPOLLIN|EPOLLET }3.4 一个直接可复制的 ET 事件循环骨架上面几节讲了原则这里给一个能跑的最小骨架省略业务处理逻辑保留核心事件循环结构#define MAX_EVENTS 128 char buf[4096]; for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { uint32_t ev events[i].events; int fd events[i].data.fd; if (ev (EPOLLERR | EPOLLHUP)) { close(fd); continue; } if (fd listen_fd) { while (1) { int cfd accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK); if (cfd -1) { if (errno EAGAIN) break; break; } epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, (struct epoll_event){EPOLLIN | EPOLLET, {.fd cfd}}); } continue; } if (ev EPOLLIN) { while (1) { ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { /* 把 buf 交给业务处理 */ } else if (r 0) { close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; close(fd); break; } } } /* EPOLLOUT、业务发送逻辑可根据需要加入 */ } }这个骨架里最关键的就是“循环到 EAGAIN”这个重复动作。建议平时写 ET 服务时把 read 循环和 accept 循环封装成两个独立函数统一入口和退出条件免得在复杂业务逻辑里读漏。3.5 调试与验证怎么确认自己的 ET 代码没有丢事件写完代码不能只靠眼测我一般用三招验证。第一招是用 strace 跑系统的调用序列。一条命令即可strace -f -e traceepoll_wait,epoll_ctl,read,accept,write -p pid观察在同一个 fd 的 EPOLLIN 事件后用户态是否连续执行了多次 read以及最后一次 read 是否返回了 EAGAIN。如果发现只 read 一次就回 epoll_wait几乎可以断定有泄漏风险。第二招是构造一个“一次连接发多次数据”的测试脚本。客户端连上后不读响应连续把 10 个包快速发出去服务端如果在第一次 EPOLLIN 处理后就把 fd 挂了大概率只收到前面少数几个包。用这种方式能很容易暴露出 ET 处理不完整的问题。第三招是在代码里记住每个连接最近一次事件的触发时间、读取字节数、EAGAIN 次数等统计信息暴露到监控接口里。线上出现异常时通过统计看每个事件的处理量能快速定位是“事件处理不完整”还是“下游处理慢”。4. 常见问题排查与避坑实录4.1 事件“丢失”的真正原因明确地说Linux 内核的 epoll 在 ET 模式下不会自己丢事件。数据到达时它一定会触发一次边沿只是不重复触发。所以“丢事件”的现象几乎都是用户态没有把该拿的数据拿完或者读取的时机不对。最常见的丢事件路径在 read 循环中只 read 一次就处理业务业务代码太耗时返回后不再继续 read。一次性 read 了小于缓冲区大小的数据然后 break没等 EAGAIN。accept 循环不足连接 accept 后没有马上注册到 epoll。某个错误码处理不当比如把 EAGAIN 当成致命错误直接 close 了 fd。要避免丢失解决问题的思路应该是“检查在 ET 触发后是否完成了所有待处理工作”不是去怪内核。4.2 ET 与阻塞 IO 的经典卡死现场说一个我实际排查过的问题服务在压测时偶发性整体停顿重启后恢复。抓了线程栈才发现某个 worker 线程卡在 read 阻塞上。原因就是 ET 模式下一个连接的可读事件触发后线程开始循环读读到缓冲区暂时没有新数据时read 阻塞了。由于该线程是唯一的事件循环线程阻塞一个就阻塞了全部。这类问题的排查手段很直接用 gdb 抓线程栈看卡在哪个函数再用 strace 确认那个线程确实阻塞在 read 上而不是 epoll_wait 上。只要确认是 ET 模式配合了阻塞 fd修复方向就是设置非阻塞并确保所有读写路径都不会阻塞。如果你在自己的项目里遇到类似卡顿可以先查所有 fd 的 O_NONBLOCK 标志有没有设置完整再查有没有第三方库内部打开 fd 时默认用了阻塞模式。后者很容易成为漏网之鱼。4.3 惊群ET 不背锅但仍要处理ET 模式减少了事件通知频率但没有解决多 worker 等待同一个 fd 时的唤醒问题。假设有四个 worker 线程都阻塞在同一个 epoll 实例上当其中一个 fd 有数据到达内核会尝试唤醒所有等待者但最终只有一个人能拿到事件其他三个空醒一场这就是惊群。Linux 4.5 之后可以用 EPOLLEXCLUSIVE。在 epoll_ctl 添加 fd 时把 events 同时设置 EPOLLEXCLUSIVE内核层面保证只唤醒一个等待线程是抑制惊群比较直接的手段。注意调用时必须是在多个 fd 共享同一个 epoll 实例的前提下具体限制要看内核版本。另外一套方式是 SO_REUSEPORT多个进程各自监听同一个端口各自有自己的 epoll 实例让内核四层负载均衡把连接分散到不同进程从源头上减少共享 fd 的场景。这两种方案各有取舍但都比禁掉一个 worker 来“绕过”问题更靠谱。4.4 LT 与 ET 混合使用时的操作细节同一个 epoll 实例中同时存在 LT 和 ET 的 fd 是合法的。有些设计会故意把 listen fd 设成 LT把连接 fd 设成 ET或者反过来。混合模式通常没问题但代码里要非常清晰地标注每个 fd 的模式避免后续维护者把两者的处理方式混用。特别要注意的是在 epoll_ctl 里重新注册 fd 的事件类型时它之前的模式不会自动继承。你必须在每次 EPOLL_CTL_ADD 时显式带上 EPOLLET 标记。如果某个 fd 从 ET 改成 LT 丢掉了 EPOLLET 标志后续行为会回到“反复通知”的状态如果 LT 改成 ET 没带 EPOLLET它还是 LT。这类细节最容易在重构时被忘记。还有一个容易被忽略的点ET 模式下如果同一个 fd 同时注册了 EPOLLIN 和 EPOLLOUT状态跳变时内核可能只返回其中一种事件。处理时最好在一个事件回调里同时检查当前是否还能读、是否能写不要因为只返回了 IN 就放弃写操作免得下次 OUT 事件不来。5. 选型分析业务场景下的 LT 与 ET 决策5.1 稳定性优先时选 LT如果你的系统需要常年在复杂网络环境里运行并且团队不打算对每个事件的完整处理流程做严格保证LT 是更稳的选择。LT 模式下即使某个事件处理不彻底内核会继续提醒这相当于给了代码一个“重试”机会。对于追求系统整体稳定、不希望因为偶发逻辑缺陷就丢连接的场景这种冗余带来的价值很高。我用过一个长期运行的推送网关连接基数几十万业务报文不大当时就是用 LT 跑了好几年。中间经历过多次代码重构和线上流量波动从来没出现过“因为触发模式导致事件丢失”的问题。LT 在这里虽然不算极致高效但胜在皮实。5.2 性能优先且处理能力强时选 ET如果你确认自己的事件处理循环能做到“一次事件把所有数据搬干净”且业务上有高吞吐、低延迟的要求ET 更合适。高速转发、数据流透传、代理缓存这类场景ET 能减少事件通知次数和系统调用明显提升 CPU 利用率。不过选择 ET 之前要做一次诚实的评估团队是否有能力在一个事件循环里写清楚状态机运维能否接受“修改 fd 事件模式后因读漏导致连接假死”的风险如果答案是否定ET 的潜在收益可能不值得冒险。5.3 一个真实项目的 LT/ET 对比数据这里放一个我之前做过的网关改造数据供参考压测环境同机器、同流量模型、同连接数报文平均长度约 1.2KBPPS 约 18 万。严格讲这份数据只代表那个场景但可以说明为什么选型必须看流量特征指标LT 模式ET 模式CPU 占用约 68%约 53%epoll_wait 调用次数/秒约 42 万约 31 万read 系统调用次数/秒约 35 万约 34 万P99 延迟(ms)8.17.6可以看到 ET 模式下 CPU 下降了 15 个百分点但不是说所有项目都能有这个收益。后来我又换了一批平均报文只有 200 字节的流量两个模式差异就小到接近噪声。这就是我一直强调的性能优化必须贴着自己的业务数据做 AB 对比别迷信理论推算。5.4 最终决策先 LT 后 ET带着指标切换我的建议是先 LT 把业务跑稳再选择性地往 ET 迁移。迁移不是一刀切可以把部分连接、部分路径切到 ET并在监控里对比系统调用次数、事件处理耗时和错误率用数据决定要不要全量开放。这样可以最大程度避免“为性能上线 ET最后被一堆隐性 bug 拖垮”的尴尬。最后说点个人体会。我真正把 ET 用顺手是在理解两件事之后一是 ET 事件是一次性的机会抓住就等于要处理干净处理不干净就是灾难二是所谓的高性能并不来自触发模式本身而是来自“更少而更完整的处理”。所以现在我写 epoll 服务时会先把 LT 版本跑通再根据实测情况决定要不要加 ET。如果要用 ET代码里所有循环到 EAGAIN 的纪律都要写清楚并且在上线前用随机报文反复压测。这套原则帮我避掉了很多线上事故希望也能帮到你。