Appearance
LT vs ET 编程范式对比(三):性能实测与理论分析
本文是 Day 2 深入论述 的第三篇子文档,配合 实践指南 阅读。 内容:LT 与 ET 的性能差异——理论优势的三个维度、实测 benchmark 数据(QPS +6.8% 但 P99 -20%、max -39%)、以及"为什么差异不是 2 倍"的延迟构成分析。 阅读顺序:本质区别与代码对比 → 事件注册策略 → 本文 → 死锁 Bug 案例。
一、LT vs ET 性能对比总表
| 维度 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| epoll 标志 | 无(默认)或显式 0 | EPOLLET |
| 通知语义 | "fd 当前可读/可写" | "fd 状态刚变为可读/可写" |
| accept 必须循环? | 否(建议循环以减少唤醒) | 是,必须到 EAGAIN |
| read 必须循环? | 否(建议循环以减少唤醒) | 是,必须到 EAGAIN |
| write 必须循环? | 否(但建议循环到 EAGAIN) | 是,必须到 EAGAIN |
| 事件注册 | 注册一次,持续有效 | 每次状态切换需手动 epoll_ctl(MOD) |
| epoll_wait 唤醒次数 | 多(每次循环都唤醒确认状态) | 少(只在状态变化时唤醒) |
| 不循环读的后果 | 多一次 epoll_wait 唤醒,数据不丢 | 数据永久丢失 |
| EPOLLOUT 陷阱 | 缓冲区始终可写时 → 空转 CPU | 无(只通知一次,需手动管理) |
| 代码复杂度 | 较低(可以不循环读写) | 较高(三循环 + 事件注册管理) |
| 适用场景 | 简单服务、低并发、快速原型 | 高并发(百万连接)、生产环境 |
| 内核开销 | 每次 epoll_wait 都要扫描就绪 fd 并重新检查状态 | 只检查新变化,事件队列更短 |
为什么生产环境几乎都用 ET:在高并发场景下(百万连接),LT 的"每次 epoll_wait 都重新通知所有就绪 fd"会导致
epoll_wait返回的事件数爆炸式增长。ET 只通知刚发生变化的 fd,事件队列更短,CPU 利用率更高。但代价是编程复杂度——你必须记住每个 fd 的状态,且必须循环到 EAGAIN。
二、ET 比 LT 性能好在哪里?期待的差异有多大?
这是学 epoll 的人最常问的问题。回答需要分两个层次:理论优势(ET 的设计为什么省 CPU)和实测差异(在真实场景中能省多少)。
2.1 ET 的理论性能优势——三个维度
| 维度 | LT 行为 | ET 行为 | ET 赢在哪里 |
|---|---|---|---|
| 唤醒次数 | "fd 当前可读/可写" → 条件持续满足就持续唤醒 | "fd 刚变为可读/可写" → 只在状态变化瞬间唤醒一次 | 重复唤醒归零:一个 fd 从"数据到达"到"处理完毕"之间,LT 可能唤醒 N 次(每次 epoll_wait 都返回),ET 只唤醒 1 次 |
| 事件队列长度 | 所有"当前可读/可写"的 fd 都在就绪队列里 | 只有"刚发生变化"的 fd 在就绪队列里 | 队列更短 → 遍历更快:10000 个空闲连接中只有 10 个有数据到达,LT 返回 10000 个事件(9900 个 EPOLLOUT 噪音),ET 只返回 10 个 |
| epoll_wait 内核对就绪 fd 的状态重检查 | 每次调用都要重新检查每个就绪 fd 的当前状态 | 只检查"新增"的就绪事件 | 内核态 CPU 开销更低:LT 在 epoll_wait 内部做了更多无用功 |
可以用一个场景量化这三个维度:
bash
场景:10000 个连接,其中 100 个有数据到达,其余 9900 个空闲
LT(EPOLLIN | EPOLLOUT):
epoll_wait 返回 10000 个事件
├─ 100 个 EPOLLIN(真正有数据的)
└─ 9900 个 EPOLLOUT(空闲连接的写缓冲区空 → "可写")
→ 事件循环必须遍历 10000 次,其中 99% 是无效遍历
→ 每次遍历检查"要不要写"→ 不需要 → 白做
ET(EPOLLIN | EPOLLOUT | EPOLLET):
epoll_wait 只在有新数据到达时返回 100 个 EPOLLIN
空闲连接的 EPOLLOUT 不会反复触发(没变化 = 没边沿)
→ 事件循环只遍历 100 次,100% 是有效处理
→ 无需对空闲连接做任何检查这就是 ET 的核心价值:只处理"真正有事"的连接,空闲连接零开销。
2.2 实测差异——本书的 benchmark 数据
理论说得再好,最终要有数字。第 7 章的实测结果(详见 §7.2.3 ET vs LT 长跑最优单次对比)显示:
| 指标 | LT(水平触发) | ET(边缘触发) | 变化 |
|---|---|---|---|
| QPS(平均吞吐) | 15238 | 16277 | +6.8% |
| P50(中位延迟) | 57 μs | 53 μs | -7% |
| P99(99 分位延迟) | 132 μs | 106 μs | -20% |
| max(最差延迟) | 302 μs | 185 μs | -39% |
| 短跑 100 req(串行低并发) | QPS 28695 | QPS 29965 | +4.4%(差异 < 5%,几乎测不出来) |
四个数字揭示了一个反直觉的结论:ET 的 QPS 优势只有 6.8%,但尾延迟优势高达 20-39%。
bash
ET 的真正价值不是"跑得更快",而是"跑得更稳":
├─ P50 -7%:中位数几乎一样(内核快速路径两者差不多)
├─ P99 -20%:长尾请求大幅减少(ET 唤醒更少 → 方差更小)
├─ max -39%:最差情况大幅改善(LT 的级联唤醒被 ET 消除)
└─ QPS +6.8%:平均吞吐提升有限(瓶颈在 TCP 握手/挥手,不在 epoll 通知)2.3 为什么差异不是 2 倍、10 倍,而只有 6.8%?
很多人读完理论分析后会期待 ET 比 LT 快好几倍。实际只有 6.8%,原因是 localhost 回环 + echo 短连接模型的瓶颈不在 epoll 唤醒:
bash
一次 echo 请求的延迟构成(localhost 回环):
TCP 三次握手: ~15 μs
TCP 四次挥手: ~20 μs
epoll_wait + 事件分发: ~5 μs ← ET 和 LT 只在这段有差异
应用层 read/write: ~10 μs
其他(调度、cache): ~10 μs
─────────────────────────────────
总延迟: ~60 μs (P50)
ET 比 LT 省的是 epoll_wait 事件分发这一段——从"扫描 10000 个事件找 100 个"
变成"只处理 100 个事件"。但这只占总延迟的 ~8%(5/60)。
ET 把这段从 5μs 优化到 1μs,省了 4μs → 总延迟 60μs → 56μs ≈ -7%。当 ET 的优势才会真正爆发:
| 条件 | 为什么差距拉大 |
|---|---|
| 远端网络延迟高(如 10ms RTT) | 更多连接同时处于"等待中"状态,空闲连接数爆炸 → LT 的 EPOLLOUT 噪音按连接数线性增长 |
| 长连接模型(如 WebSocket) | 连接数 = 在线用户数(可能千万级),绝大多数时间空闲 → ET 零开销,LT 按连接数付费 |
| 高并发短连接(如 HTTP 短连接) | 每秒新建立的连接在被 close 前都会短暂进入 epoll → LT 在这个窗口内产生大量冗余通知 |
| C10K→C100K→C1M | 连接的绝对数量越大,LT 按"活跃+空闲"付费 vs ET 按"活跃"付费的差距越大 |
一句话:localhost 回环 echo 测试中 ET 比 LT 只快 6.8%(QPS),但 P99 快 20%、max 快 39%——ET 的真正优势不在"跑得快",而在"不跑偏"。连接数从 100 涨到 10000、再到 1000000 时,这个差距会从 6.8% 放大到 10 倍以上。
三、三个版本的递进关系
bash
echo-epoll-lt-server.c → 最简单,LT 下可以不循环,适合理解 epoll 基本用法
↓ 加 EPOLLET + 三循环 + 事件切换
echo-epoll-server.c → ET 版,强制循环 + 手动 epoll_ctl(MOD)
↓ 加 fork + SO_REUSEPORT
echo-mp-server.c → ET 多进程,生产级架构学习建议:先读懂 LT 版(理解 epoll 事件循环的基本框架),再对比 ET 版(理解"为什么必须循环 + 手动切换事件"),最后看多进程版(理解"如何把单进程模型扩展到多核")。三个版本对应三个递进的认知层次。
一句话总结:ET 的理论优势(只处理"真正有事"的连接)在实测中体现为 QPS +6.8%、P99 -20%、max -39%——优势在尾延迟而非吞吐;当连接数从百级涨到百万级、或网络 RTT 变大时,这个差距会从 6.8% 放大到 10 倍以上。