Skip to content
Go back

epoll的LT和ET模式

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 / pollepoll 的核心区别在于”如何获取就绪 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() 的判断条件:

  1. 本次 read() 读满了整个缓冲区(可能还有数据)
  2. 自选不超过 maxMessagesPerRead(默认 16 次,防止单个 channel 饿死其他)
  3. totalBytesRead > 0(至少读到了一些数据)

ET 模式下 Netty 为什么不依赖”再通知一次”的安全网,而是选择主动循环读?

原因:当系统有 10 万个长连接时,ET 模式将 epoll_wait 调用次数减少 50-80%。每个 epoll_wait 本身有系统调用开销(进内核态),在几十万 fd 下,省一次调用就省一次开销。Netty 的设计哲学是用”正确编写的循环读”换取内核开销的降低。

思考:ET 省下的系统调用,究竟值不值它带来的复杂度?——取决于谁来写这段代码。对裸写 epoll 的开发者,ET 的循环读 + EAGAIN 判断很容易写错;对 Netty 这类框架,把正确循环封装一次、全站复用,用户只写 Handler,复杂度被隔离,省下的系统调用就是纯收益。所以「选 LT 还是 ET」本质是「复杂度放哪层」的问题。

什么时候用 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)
selectepoll
最大 fd 数1024(可调但上限受限)无限制
获取就绪 fdO(n) 内核 + O(n) 用户遍历O(1) 直接取链表
内核数据结构fd_set 位图红黑树 + 就绪链表
内存拷贝每次调用完整拷贝 fd_set只拷贝就绪 events
C10K不可行可行

总结

维度LTET
通知时机缓冲区有数据就通知(条件)状态变化才通知(边沿)
未读完后果下次还会通知永不再通知(假死)
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。


Share this post on:

Previous Post
fork原理:进程创建的底层全链路
Next Post
brk与mmap详解:malloc底层内存分配的双引擎