
1. 从一次线上连接数暴涨说起凌晨两点监控告警突然响了某台接入层服务器的并发连接数在十分钟内从八千飙到六万CPU的sys占用直接顶到90%以上业务响应时间从20ms劣化到800ms。登录机器用ss -s一看大量连接卡在ESTABLISHED状态但迟迟不释放strace跟下去发现主线程在epoll_wait返回后疯狂空转每次只处理极少量数据就再次陷入循环。这个场景我遇到过不止一次排查到最后根因往往都指向同一个地方I/O多路复用的事件触发模式选错了。具体来说就是把本该用边缘触发Edge TriggeredET的场景写成了水平触发Level TriggeredLT或者反过来在ET模式下漏读了数据导致事件再也不来。这两个概念听起来像是操作系统课本里的名词但只要你写过基于epoll、kqueue或者select/poll的网络服务它们就直接决定了你的程序是稳定运行还是半夜把你叫起来。这篇文章不打算复述手册上的定义而是从实际工程角度把LT和ET的差异、各自的适用场景、踩坑点和调优经验一次讲透。无论你是刚接触网络编程的新手还是已经写过几万行服务端代码的老手都能从中找到可以直接用的东西。2. 水平触发与边缘触发的本质差异2.1 用一个生活场景理解两种模式先抛开代码用一个日常场景来类比。假设你家的信箱有一个提示灯。水平触发的逻辑是只要信箱里有信灯就一直亮着你每次路过都能看到灯亮直到你把信全部取走灯才熄灭。边缘触发的逻辑是只有当信箱从空变成有信的那一瞬间灯才闪一下之后不管你取不取信灯都不再亮了。如果你没注意到那一下闪烁或者只取了一部分信剩下的信就永远躺在信箱里灯也不会再提醒你。这个类比基本对应了两种模式在操作系统层面的行为LT模式只要文件描述符fd上还有未读完的数据读事件或还有可写的缓冲区空间写事件epoll_wait每次都会返回这个fd通知你继续处理。ET模式只有当fd的状态发生变化时比如从无数据变为有数据epoll_wait才返回一次。之后如果你不把数据一次性读完即使缓冲区里还有剩余数据也不会再收到通知。理解这个差异是后面所有讨论的基础。很多人觉得ET更高级、性能更好就无脑用ET结果程序行为诡异数据丢失、连接挂死排查半天找不到原因。实际上两种模式没有绝对的优劣只有适不适合你的场景。2.2 内核视角下的事件通知机制从内核实现的角度看LT和ET的差异体现在事件就绪队列的管理方式上。在LT模式下当一个fd被加入epoll实例并注册了读事件后只要该fd的接收缓冲区非空内核在每次epoll_wait调用时都会检查并把它放入就绪链表返回给用户态。这意味着即使你上一次没有处理完下一次调用仍然会被告知这个fd还有数据。在ET模式下内核只在fd的状态发生跃迁时比如从不可读变为可读才把它加入就绪链表。一旦你通过epoll_wait取走了这个事件除非fd再次发生状态变化比如你读空了缓冲区然后对端又发来新数据否则内核不会再通知你。这里有一个容易被忽略的细节ET模式下如果你没有一次性把数据读完剩余的数据不会触发新的事件通知但它们仍然在缓冲区里。这就像信箱里的信你只取了一部分灯不再亮但信还在。如果你不主动再去读这些数据就会一直滞留直到连接超时或被对端关闭。2.3 两种模式的行为对比为了更直观地对比我把关键行为整理成一张表对比维度水平触发LT边缘触发ET事件通知时机fd就绪期间每次epoll_wait都通知仅在fd状态变化时通知一次未读完数据的处理下次仍会通知不再通知需自行保证读完编程复杂度低可按需读取高必须循环读到EAGAIN系统调用次数相对较多相对较少惊群效应影响较明显相对较轻适用场景连接数适中、逻辑简单高并发、追求极致性能这张表里的每一行背后都有实际工程中的考量后面会逐一展开。这里先记住一个核心结论ET模式要求你在收到通知后必须把数据读到返回EAGAIN为止否则就会丢事件。这是ET模式所有坑的根源。3. 为什么ET模式必须配合非阻塞IO3.1 阻塞IO在ET模式下的致命问题很多教程会告诉你ET模式必须配合非阻塞IO但很少解释清楚为什么。我用一个具体的例子来说明。假设你有一个阻塞的socket注册了ET模式的读事件。某次epoll_wait返回告诉你这个fd可读。你调用read读取数据假设对端发来了4KB数据你的缓冲区也是4KB一次读完了。看起来没问题。但如果对端发来了8KB数据呢你第一次read读走4KB缓冲区里还剩4KB。由于是阻塞IO你再次调用read时如果缓冲区里还有数据它会立即返回但如果数据刚好被读完了read就会阻塞在那里直到有新数据到来。在ET模式下你为了确保读完数据通常会写一个循环反复read直到返回EAGAIN。但阻塞IO永远不会返回EAGAIN它只会阻塞。于是你的整个事件循环就被卡死在这个read调用上其他所有连接都得不到处理。这就是为什么ET模式必须配合非阻塞IO只有非阻塞IO才能在数据读完后返回EAGAIN让你知道已经读干净了可以退出循环了。3.2 非阻塞IO下ET模式的正确读取姿势正确的ET模式读取逻辑应该是这样的// 假设fd已设置为非阻塞 while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 处理读到的n字节数据 process_data(buf, n); } else if (n 0) { // 对端关闭连接 close_connection(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据已读完退出循环 break; } else if (errno EINTR) { // 被信号中断继续读 continue; } else { // 其他错误关闭连接 close_connection(fd); break; } } }这段代码的关键在于EAGAIN的处理。当read返回-1且errno为EAGAIN时说明当前缓冲区已经没有数据了可以安全退出循环。此时如果对端再发来数据内核会再次触发ET事件通知你。注意EAGAIN和EWOULDBLOCK在大多数系统上是同一个值但为了可移植性建议两个都判断。3.3 写事件在ET模式下的处理读事件的处理逻辑相对直观写事件在ET模式下则更容易出问题。LT模式下只要发送缓冲区有空间epoll_wait就会一直通知你可写。这意味着你可以只在有数据要发的时候才关注写事件发完就取消关注逻辑简单。ET模式下写事件只在发送缓冲区从满变为不满的那一刻触发一次。如果你收到通知后只写了一部分数据缓冲区又满了那么下次缓冲区有空间时还会触发。但如果你收到通知后没有写数据或者写的数据量不足以让缓冲区状态发生变化就可能错过后续的通知。实际工程中ET模式下的写事件处理通常遵循这样的模式// 当需要发送数据时 if (output_buffer_has_data()) { // 注册写事件 modify_epoll_event(fd, EPOLLOUT | EPOLLET); } // 写事件回调中 while (output_buffer_has_data()) { ssize_t n write(fd, output_buffer, output_len); if (n 0) { // 从缓冲区移除已发送的数据 consume_output_buffer(n); } else if (n -1 (errno EAGAIN || errno EWOULDBLOCK)) { // 发送缓冲区满等待下次可写事件 break; } else { // 错误处理 close_connection(fd); return; } } // 如果数据全部发送完毕取消写事件关注 if (!output_buffer_has_data()) { modify_epoll_event(fd, EPOLLIN | EPOLLET); }这个模式的核心是只在有数据要发的时候才关注写事件发完立即取消关注。这样可以避免不必要的epoll_wait唤醒也是ET模式性能优势的来源之一。4. LT与ET在实际项目中的选型考量4.1 性能差异到底有多大很多人选择ET模式的理由是性能更好但这个更好到底体现在哪里需要量化来看。ET模式相比LT模式主要的性能优势来自两个方面第一减少了epoll_wait的返回次数。在LT模式下如果一个连接持续有数据到达每次epoll_wait都会返回这个fd即使你上次已经处理过了。在ET模式下只有状态变化时才返回减少了用户态和内核态之间的切换开销。第二减少了不必要的系统调用。LT模式下如果你没有一次性读完数据下次epoll_wait还会通知你你可能需要多次read调用。ET模式下你被强制要求一次读完虽然单次处理的数据量可能更大但总的系统调用次数可能更少。但这个优势在高并发场景下才比较明显。根据我在实际项目中的测试在连接数低于一千、数据吞吐量不大的场景下LT和ET的性能差异通常在5%以内甚至因为ET模式需要更复杂的循环读取逻辑实际性能可能还不如LT。只有当连接数上万、事件通知非常频繁时ET模式的优势才会显现出来差距可能达到20%到30%。4.2 什么场景适合LTLT模式最大的优势是编程简单、不易出错。你不需要担心数据没读完的问题因为内核会反复提醒你。这使得LT模式非常适合以下场景连接数不多、逻辑复杂的业务服务比如内部RPC服务、管理后台等连接数可能就几百个性能不是瓶颈开发效率和代码可维护性更重要。快速原型开发用LT模式可以先把功能跑通后续如果需要再优化为ET。读写逻辑不对称的场景比如某些协议需要先读一部分数据、解析后再决定读多少LT模式下可以分多次读取逻辑更自然。我个人的经验是大部分业务服务用LT模式完全够用。真正需要ET模式的是那些连接数极高、对延迟极其敏感的接入层或网关服务。4.3 什么场景必须用ETET模式在以下场景中有明显优势甚至可以说是必须的高并发接入层比如单机需要支撑十万以上连接的网关ET模式减少的事件通知次数能显著降低CPU占用。需要精确控制读取时机的场景比如某些协议要求一次性读取完整报文ET模式强制你循环读取反而简化了逻辑。与特定框架或库集成时有些高性能网络库如某些基于epoll的C网络库默认使用ET模式如果你要基于它们开发就必须理解ET的行为。但即使在这些场景下我也建议先用LT模式把功能跑通确认性能瓶颈确实在事件通知上之后再考虑切换到ET。过早优化是万恶之源这句话在网络编程里同样适用。4.4 混合使用的可能性实际上epoll允许你对不同的fd使用不同的触发模式。你可以把监听socket设置为LT模式把连接socket设置为ET模式或者反过来。这种混合使用在某些场景下能兼顾两者的优势。比如一个常见的做法是监听socket用LT模式因为新连接到达的频率相对较低LT模式的额外开销可以忽略而且LT模式下处理accept更简单不用担心漏掉连接。连接socket用ET模式因为连接上的数据读写更频繁ET模式能减少事件通知次数。但混合使用也增加了代码的复杂度需要你对每个fd的模式有清晰的记录和管理。如果没有足够的理由我建议统一使用一种模式减少心智负担。5. 那些年我踩过的ET模式坑5.1 漏读导致的连接假死这是ET模式最经典的坑没有之一。现象是客户端明明发送了数据服务端却像没收到一样连接既不关闭也不响应直到超时。用tcpdump抓包能看到数据已经到了服务端的接收缓冲区但应用层就是没读。根因通常是这样的在ET模式下某次事件通知后你的读取循环因为某个条件提前退出了没有读到EAGAIN。比如// 错误示例只读一次就退出 ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { process_data(buf, n); } // 没有循环没有检查EAGAIN如果这次read没有把缓冲区读空剩余的数据就再也不会触发事件通知了。连接看起来还活着但实际上已经假死。正确的做法是必须循环读到EAGAIN这一点在3.2节已经给出了标准写法。但即使知道这个原则实际编码中还是容易因为各种原因漏掉比如在循环中处理业务逻辑时不小心break了或者错误处理分支没有正确区分EAGAIN和其他错误。我的经验在ET模式的读取函数入口处加一个断言或日志确保每次退出循环时都是因为EAGAIN或连接关闭而不是其他原因。这个习惯帮我定位过好几次类似问题。5.2 写事件注册时机不当引发的CPU空转另一个常见的坑是写事件的注册时机。在ET模式下如果你在连接建立后就一直关注写事件EPOLLOUT那么只要发送缓冲区有空间epoll_wait就会不断返回写事件。但你可能根本没有数据要发于是每次返回后什么都不做导致CPU空转。这个问题的根因是ET模式下写事件在缓冲区从满变为不满时触发但如果你一直关注写事件且缓冲区一直有空间内核会在每次状态检查时都认为可写从而不断通知你。虽然ET模式理论上只在状态变化时通知但可写这个状态在缓冲区有空间时是持续为真的所以实际上会退化成类似LT的行为。正确的做法是按需注册写事件只有在输出缓冲区有数据待发送时才注册EPOLLOUT数据发送完毕后立即取消注册。这样既避免了CPU空转也符合ET模式的设计意图。5.3 多线程环境下的ET事件丢失在多线程环境中使用ET模式还有一个容易被忽略的问题事件被一个线程取走后其他线程再也看不到了。epoll的就绪事件在epoll_wait返回时就被从就绪链表中移除了对于ET模式。如果你有多个线程同时调用epoll_wait一个事件可能被线程A取走但线程A因为某种原因没有处理完比如处理到一半被调度走了线程B再调用epoll_wait时就不会再看到这个事件。如果线程A最终没有正确处理完数据这个事件就丢失了。解决这个问题的常见方案是使用EPOLLONESHOT标志。设置了这个标志后一个fd的事件被取走后除非你显式地重新注册否则不会再触发新的事件。这样可以保证同一时刻只有一个线程在处理这个fd处理完后重新注册事件不会丢失。// 注册时加上EPOLLONESHOT event.events EPOLLIN | EPOLLET | EPOLLONESHOT; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event); // 处理完数据后重新注册 event.events EPOLLIN | EPOLLET | EPOLLONESHOT; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, event);这个模式在多线程网络服务中非常实用但要注意重新注册的时机必须在数据处理完全结束后再重新注册否则可能出现数据竞争。5.4 水平触发下的惊群与无效唤醒LT模式也不是没有问题。最常见的是无效唤醒如果一个连接上有数据但你没有及时读取epoll_wait会反复返回这个fd造成大量无效的系统调用。在多线程场景下LT模式还容易引发惊群效应多个线程同时被唤醒去处理同一个事件但最终只有一个线程能成功处理其他线程白白消耗CPU。对于惊群问题可以通过EPOLLEXCLUSIVE标志来缓解Linux 4.5它保证一个事件只唤醒一个等待的线程。但EPOLLEXCLUSIVE和EPOLLONESHOT不能同时使用需要根据场景选择。6. 从代码层面看两种模式的实现细节6.1 epoll事件注册的差异在代码层面LT和ET的差异主要体现在epoll_event的events字段上struct epoll_event event; // LT模式默认不需要额外标志 event.events EPOLLIN; event.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event); // ET模式 event.events EPOLLIN | EPOLLET; event.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event);看起来只是多了一个EPOLLET标志但行为差异巨大。需要注意的是EPOLLET是一个文件描述符级别的属性一旦设置对该fd的所有事件读、写、异常都生效。你不能只对读事件用ET、对写事件用LT。另外EPOLLET标志在EPOLL_CTL_MOD时也需要重新设置否则可能被清除。这一点在动态修改事件关注时容易出错。6.2 读取循环的边界条件处理ET模式的读取循环看似简单但边界条件很多。除了前面提到的EAGAIN还需要处理EINTR系统调用被信号中断应该继续读取而不是退出。读取到0字节表示对端关闭了连接应该关闭本地fd并清理资源。部分读取如果一次read没有读满缓冲区说明数据可能还没到齐但在ET模式下你仍然应该继续读直到EAGAIN因为可能还有后续数据。缓冲区大小选择缓冲区太小会导致循环次数增多太大则浪费内存。通常选择4KB到64KB之间根据业务特点调整。#define READ_BUFFER_SIZE 16384 void handle_read(int fd) { char buf[READ_BUFFER_SIZE]; while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { process_data(fd, buf, n); } else if (n 0) { close_connection(fd); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) { return; // 正常退出 } else if (errno EINTR) { continue; // 被中断继续 } else { close_connection(fd); return; } } } }这段代码看起来简单但每一行都有其存在的理由。特别是EINTR的处理在高负载系统中信号可能频繁到来忽略这个分支会导致连接被错误关闭。6.3 写缓冲区的管理策略ET模式下的写操作需要一个用户态的发送缓冲区来配合。基本策略是应用层调用send时数据先写入用户态缓冲区。如果当前没有注册写事件注册EPOLLOUT。写事件触发时尽可能多地从用户态缓冲区写入内核发送缓冲区。如果内核发送缓冲区满EAGAIN保留剩余数据等待下次写事件。用户态缓冲区清空后取消EPOLLOUT关注。这个策略的关键在于用户态缓冲区的管理。缓冲区需要有动态扩容能力否则当应用层发送速度超过网络发送速度时缓冲区会溢出。常见的做法是使用链表或环形缓冲区并设置一个上限超过上限时采取背压策略比如暂停读取对端数据或者直接关闭连接。6.4 错误处理与连接生命周期ET模式下的错误处理需要格外小心因为一个未处理的错误可能导致事件永久丢失。常见的错误码和处理方式错误码含义处理方式EAGAIN/EWOULDBLOCK数据读完/缓冲区满正常退出循环EINTR被信号中断继续循环ECONNRESET对端重置连接关闭连接EPIPE对端已关闭关闭连接忽略SIGPIPEETIMEDOUT连接超时关闭连接EMFILE/ENFILE文件描述符耗尽紧急处理关闭空闲连接特别要注意EPIPE当向一个已关闭的连接写入数据时内核会发送SIGPIPE信号默认行为是终止进程。网络服务必须忽略这个信号signal(SIGPIPE, SIG_IGN)否则会因为一个客户端的异常断开而导致整个服务崩溃。7. 性能调优与监控实践7.1 如何判断当前模式是否合适判断LT还是ET是否合适最直接的方法是监控epoll_wait的返回频率和每次返回的事件数。如果epoll_wait返回非常频繁但每次返回的事件数很少比如平均不到1个说明存在大量无效唤醒可能是LT模式下没有及时读取数据或者ET模式下写事件注册不当。如果epoll_wait返回频率低但每次返回的事件数很多说明事件处理效率高当前模式比较合适。我通常会在服务中埋点统计以下指标epoll_wait调用次数/秒每次返回的平均事件数读事件和写事件的触发比例因EAGAIN退出的读取循环占比这些指标能帮助快速定位事件处理是否高效。7.2 从LT迁移到ET的渐进式方案如果你现有的服务用的是LT模式想迁移到ET不建议一次性全量切换。可以采用渐进式方案第一步保持LT模式但优化读取逻辑尽量一次性读完数据。这能减少无效唤醒为后续切换打基础。第二步选择非核心连接试点ET模式。比如把某个特定端口的连接改为ET观察稳定性和性能变化。第三步逐步扩大ET模式的范围同时加强监控和告警确保出现问题时能快速回滚。第四步全量切换后持续观察重点关注连接异常断开率、数据丢失率和CPU占用变化。这个过程中最重要的是保留快速回滚的能力。ET模式的问题往往在特定条件下才暴露没有充分的观察期就全量切换风险很大。7.3 常见监控指标与告警设置针对I/O多路复用的监控我建议至少设置以下告警连接数突增短时间内连接数增长超过50%可能是ET模式漏读导致连接堆积。epoll_wait空转率如果epoll_wait返回但没有任何事件可处理的比例超过10%说明事件注册策略有问题。读取循环异常退出率如果读取循环因为非EAGAIN原因退出的比例超过1%可能存在ET模式漏读。发送缓冲区积压用户态发送缓冲区持续增长说明写事件处理不及时。这些指标不需要很精确但能帮你快速发现异常。我在实际运维中就是靠这些指标在问题扩大前及时介入的。8. 一些容易被忽略的细节8.1 accept在ET模式下的特殊处理监听socket在ET模式下有一个特殊之处新连接到达时accept可能返回多个连接。如果你只accept一次剩余的新连接可能不会再次触发事件。正确的做法是在ET模式下循环accept直到返回EAGAINwhile (1) { int conn_fd accept(listen_fd, ...); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 所有新连接已处理完 } else if (errno EINTR) { continue; } else { // 错误处理 break; } } // 处理新连接 setup_connection(conn_fd); }这个细节在低并发时不容易暴露但在高并发场景下如果只accept一次会导致部分新连接长时间得不到处理表现为客户端连接超时。8.2 文件描述符耗尽时的应对无论是LT还是ET模式文件描述符耗尽都是一个需要处理的问题。当accept返回EMFILE时说明进程的fd用完了。常见的处理策略是预留一个空闲fd当检测到EMFILE时关闭预留的fd腾出一个位置accept新连接后立即关闭它然后重新打开预留fd。这样做的目的是给客户端一个明确的连接关闭信号而不是让它一直等待。int idle_fd open(/dev/null, O_RDONLY); // accept返回EMFILE时 if (errno EMFILE) { close(idle_fd); int conn_fd accept(listen_fd, ...); if (conn_fd 0) { close(conn_fd); // 立即关闭通知客户端 } idle_fd open(/dev/null, O_RDONLY); return; }这个技巧在连接数接近上限时非常有用能避免服务完全不可用。8.3 与TCP_NODELAY的配合ET模式下由于数据读取更及时TCP_NODELAY禁用Nagle算法的效果会更明显。Nagle算法会合并小包发送增加延迟而ET模式本身就是为了降低延迟和减少系统调用两者配合使用能获得更好的效果。但TCP_NODELAY也不是万能的。如果你的应用本身就是批量发送数据禁用Nagle可能导致网络中小包增多反而降低吞吐量。是否启用需要根据业务特点测试决定。8.4 跨平台差异epoll是Linux特有的其他平台有类似的机制FreeBSD的kqueue、Windows的IOCP。kqueue也支持ET和LT模式但API和行为有差异。比如kqueue的EV_CLEAR标志对应ET模式但事件注册和读取逻辑与epoll不完全相同。如果你需要跨平台建议使用成熟的网络库如libevent、libuv来屏蔽这些差异而不是自己直接操作底层API。这些库内部已经处理了各种边界情况能节省大量调试时间。9. 写在最后的一些个人体会关于LT和ET的选择我这些年最大的体会是不要为了用ET而用ET。我见过太多项目连接数不过几千却非要用ET模式结果代码复杂度翻倍bug层出不穷性能提升却微乎其微。LT模式就像自动挡汽车开起来省心大部分路况都能应付。ET模式像手动挡操控更精细但需要你时刻注意转速和档位匹配稍不留神就熄火。选哪个取决于你要走的是什么路。如果你正在写一个连接数在几千以内的业务服务我建议直接用LT模式把精力放在业务逻辑上。如果你在写接入层或网关连接数可能上万那ET模式值得投入时间去理解和优化。但无论选哪个充分理解其行为、做好监控、保留回滚能力这三点比选什么模式更重要。还有一个实用的小建议在开发阶段可以写一个简单的测试工具模拟大量连接和突发数据观察你的服务在LT和ET模式下的行为差异。这种实测经验比看多少文档都管用。我自己就是通过反复测试才真正理解了ET模式下必须读到EAGAIN这条规则背后的深意。最后如果你在排查ET模式相关的问题记住一个排查顺序先确认fd是否非阻塞再确认读取循环是否读到EAGAIN然后检查写事件注册时机最后看多线程环境下是否有事件竞争。按这个顺序走大部分问题都能定位到。