深蓝黑背景封面:左侧大标题「ET 模式:只敲门一次」,副标题「epoll 水平触发与边沿触发的语义差别」;右侧原创示意图——一段缓冲区里堆着未读数据块,LT 一侧的门铃旁画出连续三道声波线表示反复提醒,ET 一侧只有一道声波线表示只通知一次;左上标注「面试经典 · 网络」,右上标注「Day 06 · 10-24」 图 1:本文封面,LT 反复提醒,ET 只敲门一次。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面很具体:水平触发和边沿触发有什么区别;边沿触发下,一个 socket 有 500 字节数据,读了 200 就不再处理,剩下 300 是不是永远读不到了?第二问才是杀招——它逼你把「通知」和「数据」两件事分开。

第一步,两种触发模式的语义边界。

水平触发(LT)是默认模式,语义和 poll 一致:只要文件描述符处于就绪状态——读缓冲区里还有数据,每次 epoll_wait 都会继续报告它。读 200 剩 300,下一轮还通知,你可以从容地分多次读完。代价也写在语义里:残留数据会让它每轮都被报告一次,连接多、残留频繁时,事件分发的总量明显放大。编程负担上 LT 是宽容的——一次读不完没关系,下一轮的提醒会兜底,所以它适合作为默认选择。

边沿触发(ET)只在状态发生跳变的那个边沿通知一次:「无数据」变「有数据」时敲门一声,之后哪怕缓冲区还剩数据,只要没有新的数据到达,就不再通知。Linux 内核 man 手册用几乎相同的例子描述这个行为:写入 2KB、读走 1KB,下一次 epoll_wait 很可能挂起,尽管缓冲区里还躺着 1KB——因为对端可能在等你的响应,而你已经在等一个不会来的通知。

第二步,回答题面:剩下的 300 字节哪去了。

数据没丢,丢的是通知。那 300 字节还在内核接收缓冲区里,任何时刻主动 read 都能读出来。但在事件驱动程序里,读动作是被通知驱动的——ET 不会再为这 300 字节发通知,程序就不会再去读,表现上就是「永远读不到」:连接饿死了。所以准确答案是:数据可读,但不会再有事件提醒你去读。这 300 字节要等对端再发新数据、触发新边沿时才可能被顺带读走——把正确性寄托在对端行为上,本身就是事故。

第三步,ET 的正确写法:非阻塞 + 循环读到 EAGAIN。

man 手册给出的建议用法两条:文件描述符必须设为非阻塞;只在 read 或 write 返回 EAGAIN 之后才回去等下一次事件。翻译成读循环:

// ET 模式下的标准读循环(依 Linux man 手册范式改写)
// fd 已设为非阻塞,已用 EPOLLET 注册进 epoll
for (;;) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        // 处理读到的 n 字节,继续读——不许停
    } else if (n == -1 && errno == EAGAIN) {
        break; // 缓冲区取尽:这时才回去 epoll_wait
    } else if (n == 0) {
        // 对端关闭
        break;
    } else if (errno == EINTR) {
        continue; // 被信号打断,重试
    } else {
        // 真正的错误
        break;
    }
}

为什么必须是非阻塞?假设 fd 是阻塞的,最后一次 read 在缓冲区空的时候会挂住整个线程——而事件循环线程通常要伺候成千上万个连接,一个连接读空就卡住,等于全体连接陪葬。EAGAIN 则是「取尽」的唯一可靠信号:读到它就表示这个边沿的数据消费完了,可以安心回去等下一个边沿。

左右对照加陷阱图:左侧水平触发——数据到达通知一次,只要缓冲区里还有剩余数据,下一轮 epoll_wait 会继续通知,所以一次读不完也没关系,编程简单但通知次数多。右侧边沿触发——只在「无数据变有数据」这个边沿通知一次,之后哪怕缓冲区还剩数据也不再提醒;下方画出必须配合的写法:socket 设为非阻塞,收到通知后循环 read 直到返回 EAGAIN 才算取尽。右下角陷阱卡:ET 配阻塞 IO 或只 read 一次,剩余数据将永远等不到下一次通知,表现为连接「饿死」。底部提示:ET 用更少通知换更高性能,代价是把「读干净」的责任转移给应用层 图 2:两种触发模式的语义对照与 ET 的必备写法。

可能的追问与易错点。

「ET 为什么还要存在?」通知次数少。LT 在缓冲区有残留时每轮 epoll_wait 都报告一次,ET 只在边沿敲门——高并发下减少的是事件分发的总量。man 手册还提到一个配套优化:多线程在同一个 epoll 实例上等待时,ET 标记的描述符就绪只唤醒其中一个线程,天然抑制惊群;另外还可以用 EPOLLONESHOT 让一个描述符触发一次后自动失效,处理完再用 EPOLL_CTL_MOD 重新武装,适合多线程下防止同一连接被并发处理。「流式套接字有捷径吗?」有:read 返回值小于请求的字节数,也说明读空间已取尽——但这是流式文件专属的判据,报文型套接字只能靠读到 EAGAIN。易错点三个:ET 配阻塞 IO,最后一次 read 卡死事件循环;ET 下只 read 一次就回去等事件,剩余数据饿死连接;把 LT 的「会反复通知」当成可以偷懒的理由,大量残留数据会让事件循环空转。ET 的本质是一场交易:内核用更少的通知换更高的性能,代价是把「读干净」的责任整个移交给应用层。你写网络代码时踩过边沿触发的坑吗?欢迎分享。