epoll 的 LT 和 ET:Netty 为什么选边缘触发?
一句话结论(30s)
LT 与 ET 的唯一区别是 epoll_wait 返回 fd 的时机——LT 是条件触发(缓冲区有数据就反复通知,直到读完),ET 是边缘触发(只在状态从无到有变化的瞬间通知一次)。因为 ET 只通知一次,未读完的数据会永远卡在缓冲区造成「假死」,所以必须配合非阻塞 IO 循环读到 EAGAIN。核心权衡是:ET 把 epoll_wait 系统调用次数降低 50-80%(省内核态开销),代价是把「保证读完」的复杂度转嫁给程序员——Netty 在框架层封装了这套循环读,所以默认选 ET。
核心原理(2min)
LT(水平触发)只要缓冲区有数据,每次 epoll_wait 都返回该 fd,编码简单、不遗漏数据,但同一 fd 反复通知导致系统调用多;ET(边缘触发)只在 fd 从「无数据」变「有数据」的边沿通知一次,之后即使缓冲区仍有数据也不再通知。因此 ET 模式必须「循环读直到 read 返回 -1 且 errno == EAGAIN」,否则剩余数据永久卡在缓冲区、客户端超时。读空后若用阻塞 IO 会永远挂死整个事件循环,所以 ET 强制要求非阻塞 IO(O_NONBLOCK)。Netty 的 NioByteUnsafe.read() 用 do-while 循环 + continueReading() 判断(读满缓冲、未超 maxMessagesPerRead 默认 16 次、读到了数据)保证读完,从而把 ET 复杂度封装在框架内。
底层深入(5-10min)
从 select 到 epoll:为什么 select 被淘汰了?
select / poll 和 epoll 的核心区别在于”如何获取就绪 fd”:
select(O(n) 遍历):
select(maxfd+1, &readfds, NULL, NULL, NULL);
// 每次调用,内核都要遍历 fd_set 中所有 fd,检查哪些就绪
// 用户拿到 fd_set 后,还要 O(n) 遍历找出哪些 bit 被置位
epoll(O(1) 分发):
epoll_wait(epfd, events, maxevents, timeout);
// 内核直接在就绪链表 rdllist 中取出就绪 fd
// 用户遍历 events 数组,全是就绪的,不需要逐个检查
根本差异在于数据结构:epoll 用红黑树存所有注册的 fd,fd 就绪时通过回调把 fd 节点加入就绪链表,epoll_wait 直接从链表取——复杂度 O(1)。
LT 和 ET 的根本区别
两者只在 epoll_wait() 返回 fd 的条件上不同:
LT(Level Triggered,水平触发 / 条件触发)
只要缓冲区有数据,每次都通知。
fd 收到 1KB 数据:
epoll_wait → 返回该 fd → 应用读了 512B → 缓冲区剩 512B
epoll_wait → 返回该 fd → 应用读了 512B → 缓冲区空了
epoll_wait → 不返回该 fd(缓冲区空了)
优点:编码简单,不会遗漏数据(没读完下次还会通知)。缺点:epoll_wait 调用次数多(同一 fd 反复通知)。
思考:LT 反复通知同一 fd,到底「浪费」了什么?——浪费的是系统调用。缓冲区没读空,下次
epoll_wait还会返回这个 fd,应用就得再进一次内核态拿到「我上次已经知道」的通知。数据不丢是优点,但同一份数据触发多次epoll_wait,在高并发下是实打实的开销。
ET(Edge Triggered,边缘触发 / 状态触发)
只在状态变化的那一瞬间通知一次。
fd 从"无数据"变为"有数据":
epoll_wait → 返回该 fd → 应用必须循环读直到 EAGAIN
→ 如果没读完就不再通知(假死风险)
fd 从"无数据"变为"有数据"(期间又来了新数据):
epoll_wait → 返回该 fd → 应用循环读到 EAGAIN,每次都能拿到全部数据
→ 之后缓冲区空,不再通知
优点:epoll_wait 返回次数少,系统调用频率低。缺点:编码复杂,没读完就”假死”(fd 有数据但不再被通知)。
思考:ET「只通知一次」为什么反而更危险?——因为它把「数据没读完」的责任从内核甩给了程序员。LT 有个「下次再通知」的安全网,漏读会被兜住;ET 通知一次就撒手,你只读一半,剩余数据就永远卡在缓冲区,而 fd 状态一直是「有数据」,永远不会再触发边沿——这就是「假死」的来源。
ET 模式下为什么必须读 EAGAIN
epoll 注册 fd,设置为 EPOLLET:
第一次 epoll_wait → 返回 fd(因为有新数据)
应用读了一次(read 返回 256B)
→ 程序认为这次处理完了,返回事件循环继续等
问题:fd 缓冲区里还有 512B 没读
但 ET 模式下,fd 状态没有变化(一直是"有数据")
→ epoll_wait 永远不会再通知这个 fd
→ 这 512B 永远卡在缓冲区里,客户端超时
正确做法:循环读直到 errno == EAGAIN(或返回 -1)。
while (1) {
n = read(fd, buf, sizeof(buf));
if (n > 0) {
process(buf, n);
} else if (n == -1 && errno == EAGAIN) {
break; // 缓冲区空了,退出循环,回到事件循环
} else {
// 错误或连接关闭
close(fd);
break;
}
}
Netty 为什么默认选 ET?
Netty 的 EpollEventLoop 默认注册 EPOLLET。看一下 NioByteUnsafe.read()(Netty 的读循环):
@Override
public final void read() {
// ...
do {
byteBuf = allocHandle.allocate(allocator);
allocHandle.lastBytesRead(doReadBytes(byteBuf)); // Native read()
allocHandle.incMessagesRead(1);
} while (allocHandle.continueReading()); // 判断是否继续
allocHandle.readComplete();
pipeline.fireChannelReadComplete();
}
continueReading() 的判断条件:
- 本次
read()读满了整个缓冲区(可能还有数据) - 自选不超过
maxMessagesPerRead(默认 16 次,防止单个 channel 饿死其他) totalBytesRead > 0(至少读到了一些数据)
ET 模式下 Netty 为什么不依赖”再通知一次”的安全网,而是选择主动循环读?
原因:当系统有 10 万个长连接时,ET 模式将 epoll_wait 调用次数减少 50-80%。每个 epoll_wait 本身有系统调用开销(进内核态),在几十万 fd 下,省一次调用就省一次开销。Netty 的设计哲学是用”正确编写的循环读”换取内核开销的降低。
思考:ET 省下的系统调用,究竟值不值它带来的复杂度?——取决于谁来写这段代码。对裸写 epoll 的开发者,ET 的循环读 + EAGAIN 判断很容易写错;对 Netty 这类框架,把正确循环封装一次、全站复用,用户只写 Handler,复杂度被隔离,省下的系统调用就是纯收益。所以「选 LT 还是 ET」本质是「复杂度放哪层」的问题。
什么时候用 LT?
- 简单网络库/原型开发:不想处理 EAGAIN 循环的细节,LT 省心
- 单个 fd 读频率不高:多一次
epoll_wait回来没那么大影响 - NGINX 默认 LT:原因是一般情况下读完一次返回的数据量后,很少需要重读,且 LT 对模块开发者更友好
ET 配合非阻塞 IO 的必要性
ET 模式要求循环读到 EAGAIN,而阻塞 IO 在没有数据时会卡住——如果缓冲区已经读完,read() 会永远阻塞,整个事件循环被杀死。所以 ET 必须和非阻塞 IO(O_NONBLOCK)一起使用,read() 没数据时直接返回 EAGAIN 而不是阻塞挂死。
ET 模式 = 非阻塞 IO + 循环读到 EAGAIN
这是强制性要求,不是可选的。
epoll 的性能优势来自数据结构,不是触发模式
select:
fd_set: 位图,1024 bit 上限(FD_SETSIZE)
每次 select: 内核遍历所有 fd → 检查就绪 → 写回 fd_set
用户遍历 fd_set → O(n)
epoll:
rbtree: 红黑树存所有 fd(无上限)
rdllist: 就绪链表(fd 就绪时通过回调加入)
epoll_wait: 直接从 rdllist 取 → O(1)
| select | epoll | |
|---|---|---|
| 最大 fd 数 | 1024(可调但上限受限) | 无限制 |
| 获取就绪 fd | O(n) 内核 + O(n) 用户遍历 | O(1) 直接取链表 |
| 内核数据结构 | fd_set 位图 | 红黑树 + 就绪链表 |
| 内存拷贝 | 每次调用完整拷贝 fd_set | 只拷贝就绪 events |
| C10K | 不可行 | 可行 |
总结
| 维度 | LT | ET |
|---|---|---|
| 通知时机 | 缓冲区有数据就通知(条件) | 状态变化才通知(边沿) |
| 未读完后果 | 下次还会通知 | 永不再通知(假死) |
| epoll_wait 次数 | 多 | 少 |
| 编码难度 | 低 | 高(循环 + EAGAIN) |
| 必须非阻塞 IO | 不强制(但推荐) | 强制 |
| Netty 默认 | ❌ | ✅ |
| Nginx 默认 | ✅ | ❌ |
ET 的核心代价是编程复杂度——你必须自己保证读完所有数据。但在高性能网络框架(Netty)中,这层复杂度被封装好了,用户只享有 ET 带来的系统调用频率降低的好处。
章末提问
追问 1:LT 和 ET 的本质区别是什么?
回答思路:结论先行——区别只在 epoll_wait 返回 fd 的时机:LT 是条件触发(缓冲区有数据就反复通知),ET 是边缘触发(只在「无→有」变化的瞬间通知一次)。因为 LT 以「当前状态」判断,ET 以「状态变化边沿」判断,所以未读完时 LT 下次还会通知,ET 则永不再通知。
追问 2:ET 模式为什么必须循环读到 EAGAIN?不这么做会怎样?
回答思路:结论先行——因为 ET 只通知一次,不读完剩余数据会永久卡在缓冲区,客户端超时。因为 ET 只在「无数据→有数据」的边沿通知一次,之后缓冲区仍有数据但状态没变化,epoll_wait 不会再返回该 fd;所以必须循环 read 到返回 -1 且 errno == EAGAIN,确认读空才能回事件循环。
追问 3:Netty 为什么默认选 ET,而 Nginx 默认选 LT?
回答思路:结论先行——因为 Netty 在框架层封装了 ET 的循环读复杂度,而 Nginx 追求模块开发友好。因为 ET 能把 epoll_wait 调用次数降 50-80%,Netty 用 NioByteUnsafe.read() 的 do-while + continueReading()(默认最多 16 次)把复杂度吞掉,用户只享受收益;Nginx 模块众多,LT 省心、不易漏数据,一般读完一次返回的数据量后很少需要重读,所以选 LT。