1. 从一次线上连接数暴涨说起
凌晨两点,监控告警突然响了:某台接入层服务器的并发连接数在十分钟内从八千飙到六万,CPU的sys占用直接顶到90%以上,业务响应时间从20ms劣化到800ms。登录机器用ss -s一看,大量连接卡在ESTABLISHED状态但迟迟不释放,strace跟下去发现主线程在epoll_wait返回后疯狂空转,每次只处理极少量数据就再次陷入循环。
这个场景我遇到过不止一次,排查到最后,根因往往都指向同一个地方:I/O多路复用的事件触发模式选错了。具体来说,就是把本该用边缘触发(Edge Triggered,ET)的场景写成了水平触发(Level Triggered,LT),或者反过来,在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模式必须配合非阻塞IO
3.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 什么场景适合LT
LT模式最大的优势是编程简单、不易出错。你不需要担心数据没读完的问题,因为内核会反复提醒你。这使得LT模式非常适合以下场景:
- 连接数不多、逻辑复杂的业务服务:比如内部RPC服务、管理后台等,连接数可能就几百个,性能不是瓶颈,开发效率和代码可维护性更重要。
- 快速原型开发:用LT模式可以先把功能跑通,后续如果需要再优化为ET。
- 读写逻辑不对称的场景:比如某些协议需要先读一部分数据、解析后再决定读多少,LT模式下可以分多次读取,逻辑更自然。
我个人的经验是,大部分业务服务用LT模式完全够用。真正需要ET模式的,是那些连接数极高、对延迟极其敏感的接入层或网关服务。
4.3 什么场景必须用ET
ET模式在以下场景中有明显优势,甚至可以说是必须的:
- 高并发接入层:比如单机需要支撑十万以上连接的网关,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 | 对端已关闭 | 关闭连接,忽略SIGPIPE |
| ETIMEDOUT | 连接超时 | 关闭连接 |
| 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直到返回EAGAIN:
while (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,然后检查写事件注册时机,最后看多线程环境下是否有事件竞争。按这个顺序走,大部分问题都能定位到。