HOOOS

为什么 Redis 坚持选择 epoll 的水平触发(LT)而非边缘触发(ET)?

0 5 极客架构师 LinuxRedisepoll
Apple

在程序员的面试“八股文”中,关于 Linux epoll 的讨论几乎是一个必考点。很多人在背诵答案时,会形成一个思维定势:边缘触发(ET)比水平触发(LT)更高效,因为 ET 减少了 epoll_wait 的调用次数。

然而,如果你去翻看 Redis 的源码(特别是 ae_epoll.c),会发现 Redis 默认使用的是水平触发(LT)。作为全球最成功的内存数据库之一,Redis 极其追求极致的性能,那它为什么不选择“性能更好”的 ET,反而执着于 LT 呢?

这并不是 Redis 开发者写错了,而是因为在实际的工业级网络编程中,“ET 比 LT 快”本身就是一个普遍存在的误区


一、 拨云见日:LT 与 ET 的本质区别

为了看清 Redis 的设计选择,我们需要先从底层机制上,彻底厘清这两种模式的动作逻辑。

我们把内核中的“接收缓冲区”比作一个快递柜:

  • 水平触发(Level Triggered, LT):只要快递柜里有快递(缓冲区有数据可读),快递柜的警报器就会一直响(epoll_wait 持续返回可读事件),直到你把快递拿完。
  • 边缘触发(Edge Triggered, ET):只有在快递柜放入新快递的那一瞬间,警报器才会响一次(epoll_wait 仅在状态改变时通知一次)。如果你去拿快递时没有一次性拿完,警报器绝不会再响,除非有新的快递再次送达。

为了防止在 ET 模式下漏掉数据导致连接“挂起”,开发者必须遵守两个铁律:

  1. 套接字必须是非阻塞的(Non-blocking)
  2. 读写操作必须在一个循环中进行,直到返回 EAGAINEWOULDBLOCK 错误。

二、 性能迷思:ET 真的比 LT 更快吗?

在一些微基准测试(Micro-benchmarks)中,ET 模式下 epoll_wait 的系统调用次数确实更少,这给很多人留下了“ET 更快”的印象。

但在复杂的真实生产环境中,两者的性能对比往往会发生逆转:

1. 系统调用次数的“守恒”

在 ET 模式下,为了保证数据读干净,你必须循环调用 read() 直到它返回 EAGAIN。这意味着每一次事件触发,你都必然会多调用一次 read() 系统调用来做“兜底检测”

而在 LT 模式下,如果应用层有良好的缓冲区设计,通常一次 read() 读完了想要的数据,就可以直接返回,等待下一次 epoll_wait

  • ET 的代价:减少了 epoll_wait 的调用,但增加了 read/write 的调用次数。
  • LT 的代价:可能会多一次 epoll_wait 的调用,但减少了无效的 read/write

在 Linux 内核中,read/writeepoll_wait 都是系统调用,其上下文切换的开销是完全同量级的。因此,在实际吞吐量上,ET 很难占到绝对优势。

2. 用户态缓冲区的管理成本

使用 ET 模式,用户态必须自己维护极其复杂的缓冲区状态。如果一次没有读完,需要自己记录哪些 Socket 还有残留数据,并在下一次循环中手动处理。这种维护状态机的逻辑,会消耗可观的 CPU 周期,抵消了 ET 带来的那一丝微弱的内核态优势。


三、 Redis 为什么坚定选择 LT 模式?

结合 Redis 的自身定位和架构设计,选择 LT 模式几乎是一个必然的决定,主要体现在以下四个核心维度:

1. 彻底规避“单线程饥饿(Starvation)”问题

这是最关键的原因。Redis 是单线程反应器(Reactor)模型。

如果 Redis 使用 ET 模式,为了防止数据丢失,它必须在一个循环中,把当前就绪 Socket 上的数据全部读完。

试想一下:如果某个客户端正在发送一个数 GB 的大文件,或者有海量的并发请求疯狂涌入该 Socket。Redis 就会在这个 Socket 的 read() 循环中无法自拔。因为数据源源不断,read() 永远不会返回 EAGAIN

这会导致什么后果?
Redis 唯一的单线程被这个连接“绑架”了,其他所有客户端的请求都得不到响应,整个 Redis 实例陷入假死状态。

而在 LT 模式下,Redis 拥有极高的控制权:
Redis 限制了单次读取的最大字节数(例如最多读取 16KB)。读取完毕后,即使缓冲区还有数据,Redis 也会大方地让出 CPU 资源,去处理下一个就绪的连接。因为是 LT 模式,下一次 epoll_wait 还会继续汇报这个连接,Redis 可以在下一轮事件循环中接着读。这种类似“时间片轮转”的机制,确保了多连接之间的绝对公平性,完美避免了单线程饥饿

2. 写操作的优雅处理

在网络编程中,**写事件(Writable)**的维护比读事件要棘手得多。

因为只要内核的发送缓冲区没满,Socket 就是一直可写的。

  • LT 模式下,如果无事可做,绝对不能注册写事件,否则 epoll_wait 会瞬间陷入死循环(Busy Loop)。Redis 的做法是:平时不注册写事件,只有当有数据要发送,且一次性没发完时,才动态注册写事件;一旦数据发完,立即注销写事件。这种按需注册的方式在 LT 下极其清晰自然。
  • ET 模式下,写事件只在缓冲区由“满”变为“不满”的一瞬间触发。如果错过了这一瞬间,或者由于代码逻辑问题没有写完,你就再也没有机会发送数据了,除非你手动用 epoll_ctl 重新触发。这会让发送逻辑变得异常诡异且极易出错。

3. 多路复用器的平台抽象(Cross-platform Abstraction)

Redis 的定位是高度可移植的。为了在不支持 epoll 的平台上(如 macOS、BSD、Windows)运行,Redis 抽象了一套事件驱动框架(ae.c),底层可以无缝切换不同的多路复用实现:

  • Linux 下用 epoll
  • macOS/BSD 下用 kqueue
  • 其他系统用 select / poll

selectpoll 原生只支持 LT 模式。如果 Redis 的上层事件处理逻辑是基于 ET 的状态机设计的,那么在没有 epoll 的平台上,Redis 将极难进行向后兼容和统一抽象。选择 LT,可以让多路复用层的抽象变得极其干净、统一。

4. 极端场景下的容错性

在网络环境极其恶劣的生产环境中,程序随时可能因为资源耗尽、协议栈异常而出现意料之外的错误。

  • ET 模式下,一旦有一次状态丢失(例如某次 read 出错后没有读到 EAGAIN 就退出了循环),该 Socket 就会永远“失联”,成为无法被回收的死连接,造成严重的连接泄露。
  • LT 模式下,即使代码在某一次处理中出现了 Bug、遗留了部分数据,下一次循环依然能够兜底感知到,并提供自我修复的机会。这种高容错性对于一个追求高可用性的基础设施软件来说,具有压倒性的优势。

四、 那么,什么场景才适合用 ET?

既然 Redis 用 LT,是不是意味着 ET 就没有用武之地了?

当然不是。代表作 Nginx 默认使用的就是 ET 模式。

Nginx 能够驾驭 ET 模式,是因为它的业务场景和架构与 Redis 截然不同:

  1. 多进程 vs 单线程:Nginx 是多工作进程(Multi-worker)架构。即使某一个进程因为 ET 的循环读写稍微多花了一些时间,其他 Worker 进程依然可以高效工作,不会导致整个服务器陷入单线程假死。
  2. 大并发连接与静态文件传输:Nginx 需要应对极其恐怖的并发连接数(动辄数十万甚至百万级)。在这种极端场景下,绝大多数连接都是空闲的。ET 模式的内核红黑树和就绪队列的事件更新机制,在这种极端海量空闲连接的场景下,确实能比 LT 节省出微弱但累积起来很可观的系统 CPU 开销。
  3. 无状态的代理转发:Nginx 主要是做数据转发,它不需要像 Redis 那样在内存中进行复杂的 KV 计算和事务处理,数据流向非常直观,状态机更容易控制。

五、 总结

架构设计从来都不是盲目追求某种“技术先进性”,而是权衡(Trade-off)的艺术

  • ET 模式是一把双刃剑,它上限高、性能极限好,但逻辑复杂、极易出错,适合多进程、高连接数、逻辑相对简单的网络代理类应用(如 Nginx)。
  • LT 模式则代表着极高的稳定性、编程的友好性以及完美的控制力。对于单线程、需要保证公平响应、追求绝对稳定性的内存数据库 Redis 来说,水平触发(LT)才是那个最完美的黄金解。

点评评价

captcha
健康