﻿# Day 2 深入论述：LT vs ET 编程范式与 epoll 设计原理

> 本文是 Day 2 的**原理深度篇**，配合 [实践指南](README.md) 阅读。
> 包含 LT（水平触发，Level Triggered）与 ET（边缘触发，Edge Triggered）的完整对比、内核通知机制差异、事件注册策略、性能分析、以及典型案例的死锁 Bug 分析。

---

## 深入论述一：LT vs ET 编程范式对比

> 这是本节最核心的内容。ET 版不能只是"把 EPOLLET 标志加上就完事了"——它要求**整个编程范式**改变。

### 一、本质区别：谁在追踪"fd 是否就绪"

```bash
LT（水平触发）：内核帮你追踪
  → 只要 fd 仍然可读/可写，每次 epoll_wait() 都返回该事件
  → "你读不读是你的事，我每次都提醒你"

ET（边缘触发）：你自己追踪
  → fd 从不可读→可读时通知一次，之后不再通知
  → "我告诉你一次了，后面你自己看着办"
```

```plantuml
@startuml
skinparam shadowing false
title LT vs ET：fd 可读状态的通知差异

participant "内核" as K
participant "epoll 实例" as EP
participant "应用" as APP

== LT（水平触发）==

K -> EP: ① 数据到达 fd=5\nfd 变为"可读"
EP -> APP: ② epoll_wait 返回 EPOLLIN(fd=5)
APP -> APP: ③ read(fd=5, buf, 512) → 读了 512B\n但内核缓冲区还剩 512B
note right of APP: fd=5 仍然是"可读"状态！
EP -> APP: ④ 下次 epoll_wait 再次返回 EPOLLIN(fd=5)
APP -> APP: ⑤ read(fd=5, ...) 读完剩余数据
APP -> APP: ⑥ read(fd=5, ...) → EAGAIN

== ET（边缘触发）==

K -> EP: ① 数据到达 fd=5\nfd 从"不可读"→"可读"
EP -> APP: ② epoll_wait 返回 EPOLLIN(fd=5)
APP -> APP: ③ read(fd=5, buf, 512) → 读了 512B\n但内核缓冲区还剩 512B
note right of APP: fd=5 仍然是"可读"状态\n但 ET 不会再通知！
APP -> APP: ④ 下次 epoll_wait ... 永远等不到 fd=5 的 EPOLLIN
note right of APP #FFB3B3
  **数据永久丢失！**
  剩下的 512B 不会再触发事件
  必须循环读到 EAGAIN 才能停
end note

@enduml
```

### 二、三个关键操作的 LT vs ET 代码对比

**① accept() 循环**

| | LT 版 | ET 版 |
|------|------|------|
| 必须循环？ | 否（不循环也能工作） | **是**（不循环 = 丢连接） |
| 建议 | 循环（减少 `epoll_wait` 次数） | 必须循环到 EAGAIN |
| 不循环后果 | 下次 `epoll_wait` 还通知，连接不丢 | 剩余连接永久丢失 |

```c
/* LT 版 accept：可以不循环，但循环更高效 */
// 只 accept 一个也行——LT 下次还会通知
int fd = accept(listen_fd, NULL, NULL);
if (fd >= 0) { /* 注册到 epoll */ }

/* ET 版 accept：必须循环 */
while (1) {
    int fd = accept(listen_fd, NULL, NULL);
    if (fd < 0) {
        if (errno == EAGAIN) break;  // 必须到这里才停
        perror("accept"); break;
    }
    /* 注册到 epoll */
}
```

**② read() 循环**

| | LT 版 | ET 版 |
|------|------|------|
| 必须循环？ | 否（不循环也不会丢数据） | **是**（不循环 = 丢数据） |
| 建议 | 循环（减少 `epoll_wait` 次数） | 必须循环到 EAGAIN |
| 不循环后果 | 多一次 `epoll_wait` 唤醒，数据不丢 | 剩余数据永久丢失 |

```c
/* LT 版 read：可以不循环 */
// 读一次就返回也行——LT 下次还会通知
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { /* 处理数据 */ }
// 没读完的数据下次 epoll_wait 还会触发 EPOLLIN

/* ET 版 read：必须循环 */
while (1) {
    ssize_t n = read(fd, buf + buf_len, sizeof(buf) - buf_len);
    if (n > 0) { buf_len += n; continue; }
    if (n == 0) { /* FIN */ break; }
    if (errno == EAGAIN) break;  // 必须到这里才停
    /* 错误处理 */
}
```

**③ write() 循环**

| | LT 版 | ET 版 |
|------|------|------|
| 必须循环？ | 否（但不循环会导致 EPOLLOUT 空转） | 需要手动管理 EPOLLOUT 注册 |
| 建议 | 循环写到 EAGAIN | 循环写到 EAGAIN，然后切回 EPOLLIN |
| LT 特有陷阱 | 对端接收窗口大时，内核缓冲区始终可写 → LT 持续返回 EPOLLOUT → **CPU 空转** | 无此问题（ET 只通知一次） |

```c
/* LT 版 write：可以不循环，但循环避免了 EPOLLOUT 空转 */
while (buf_sent < buf_len) {
    ssize_t n = write(fd, buf + buf_sent, buf_len - buf_sent);
    if (n > 0) { buf_sent += n; continue; }
    if (errno == EAGAIN) break;
    /* 错误 */
}

/* ET 版 write：必须循环 + 必须手动切换事件 */
while (buf_sent < buf_len) {
    ssize_t n = write(fd, buf + buf_sent, buf_len - buf_sent);
    if (n > 0) { buf_sent += n; continue; }
    if (errno == EAGAIN) break;  // 写满了，等 EPOLLOUT
}
// 全部写完 → 必须 epoll_ctl(MOD, EPOLLIN|EPOLLET) 切回读
```

### 三、LT vs ET 事件注册策略的差异

LT 和 ET 的另一个关键差异在于**事件注册策略**：

```plantuml
@startuml
skinparam shadowing false
title LT vs ET 的事件注册策略对比

state "LT 事件注册" as lt_state {
  state "注册 EPOLLIN" as lt_in
  state "注册 EPOLLOUT" as lt_out

  [*] --> lt_in : accept 新连接
  lt_in --> lt_out : 读到数据，切 STATE_WRITE\n**epoll_ctl(MOD, EPOLLOUT)**\n避免 EPOLLOUT 空转
  lt_out --> lt_in : 写完数据，切 STATE_READ\n**epoll_ctl(MOD, EPOLLIN)**\n避免反复触发 EPOLLOUT
}

note right of lt_state
  LT 策略：
  状态切换时仍要 epoll_ctl(MOD)，
  但不需要像 ET 那样循环到 EAGAIN。
  
  优点：代码简单（不循环读）
  缺点：epoll_wait 唤醒次数更多
end note

state "ET 事件注册" as et_state {
  state "注册 EPOLLIN|EPOLLET" as et_in
  state "注册 EPOLLOUT|EPOLLET" as et_out

  [*] --> et_in : accept 新连接
  et_in --> et_out : 读到数据\nepoll_ctl(MOD, EPOLLOUT|EPOLLET)
  et_out --> et_in : 写完数据\nepoll_ctl(MOD, EPOLLIN|EPOLLET)
}

note right of et_state
  ET 策略：
  每次状态切换都要
  手动 epoll_ctl(MOD)。
  
  优点：精确控制，无空转
  缺点：代码复杂
end note

@enduml
```

上图展示了 LT 和 ET 在事件注册策略上的根本差异，下面从三个角度展开论述。

**一、为什么 LT 下必须手动切事件？——EPOLLOUT 空转问题**

LT 的语义是"当前可读/可写就通知"。假设连接一开始注册的是 `EPOLLIN | EPOLLOUT`：

```plantuml
@startuml
skinparam shadowing false
skinparam sequenceMessageAlign center
title LT 同时注册 EPOLLIN|EPOLLOUT 的空转场景

participant "事件循环" as loop <<loop>>
participant "epoll_wait" as ep <<kernel>>
participant "连接 fd" as fd <<socket>>

== 连接建立，无客户端数据，空闲期 ==
loop -> ep : epoll_wait()\n(fd 注册了 EPOLLIN|EPOLLOUT)
ep -> ep : 检查 fd 状态：\nread 缓冲区空 → 不可读\nsend 缓冲区空 → **可写！**
ep --> loop : 返回 EPOLLOUT
loop -> loop : 处理 EPOLLOUT：\n想 write，但没有数据可写\n什么也做不了
loop -> ep : epoll_wait()
ep -> ep : 检查 fd 状态：\nsend 缓冲区仍然空 → **仍可写！**
ep --> loop : 再次返回 EPOLLOUT
loop -> loop : 又没有数据可写...
note right of loop: epoll_wait 每次调用\n都立即返回 EPOLLOUT\n→ CPU 空转

== 客户端终于发来数据 ==
loop -> ep : epoll_wait()
ep -> ep : 检查 fd 状态：\nread 缓冲区有数据 → 可读\nsend 缓冲区空 → 可写
ep --> loop : 返回 EPOLLIN | EPOLLOUT
loop -> fd : read() 处理客户端数据
loop -> fd : write() 回写响应

== 响应发完，再次回到空闲 ==
loop -> ep : epoll_wait()
ep -> ep : send 缓冲区空 → 可写
ep --> loop : EPOLLOUT
note right of loop: 又来了...\n响应都发完了\nEPOLLOUT 阴魂不散

@enduml
```

**空转的根本原因——LT 不关心"你有没有数据要发"**

LT 只看一个事实：TCP 发送缓冲区有没有空闲空间？有空间 → `EPOLLOUT`。它不会（也无法）判断应用程序是否真的有数据要写。只要缓冲区不满，每次 `epoll_wait` 都会返回 `EPOLLOUT`。

这与读端不同：读端闲时缓冲区是空的 → 不可读 → `EPOLLIN` 不会触发。但写端闲时缓冲区也是空的 → **可写**（有大量空间） → `EPOLLOUT` 必然触发。

**EPOLLIN 能拯救空转吗？——不能，两者不在同一个维度上竞争。**

这是最容易被误解的地方。有人会想："客户端的请求数据总会来的呀，到时候 `EPOLLIN` 不就触发了吗？" 这个思路的问题在于：

```bash
EPOLLOUT 空转的伤害不在"有请求时"，而在"没请求时"：

单连接视角：
  空闲期（99%的时间）：epoll_wait 每调必返 EPOLLOUT
  活跃期（1%的时间）：EPOLLIN|EPOLLOUT 一起返回，处理完后继续空转

多连接视角（这才是致命的）：
  1000 个空闲连接，全部注册了 EPOLLIN|EPOLLOUT
  → 每次 epoll_wait 返回 1000 个 EPOLLOUT 事件
  → 必须遍历 1000 次，检查每个 fd"要不要写"
  → 1000 次检查中 0 次真正有数据要写
  → 而真正的 EPOLLIN 事件被淹没在这 1000 个噪音里
```

EPOLLIN 确实会来，但它和 EPOLLOUT 不是相互替代的关系——EPOLLOUT 的噪音在 EPOLIN 到来之前、之中、之后一直都在。这不是"有 POLLIN 就不怕空转"的问题，而是"POLLOUT 的噪音淹没了 POLLIN 的信号"。

解决方法就是**按需注册**：连接在"读状态"时只注册 `EPOLLIN`，等收到数据、准备写回时再 `epoll_ctl(MOD, EPOLLOUT)` 切到写状态；写完后再切回 `EPOLLIN`。这样空闲连接只会在真正有数据可读时才唤醒。

注意，LT 下不切事件的后果是**空转浪费 CPU**，数据本身不会丢——这是 LT 安全性的代价：保守通知换取不丢数据，代价是多余的唤醒。

**二、ET 下也必须手动切事件，但原因完全不同**

ET 的语义是"状态刚从不可用变为可用时才通知"。如果写完数据后不 `epoll_ctl(MOD, EPOLLIN)`：

```plantuml
@startuml
skinparam shadowing false
skinparam sequenceMessageAlign center
title ET 写完不切事件 → 连接永久沉默

participant "事件循环" as loop <<loop>>
participant "epoll_wait" as ep <<kernel>>
participant "连接 fd" as fd <<socket>>
participant "客户端" as client

== 正常阶段：写响应 ==
loop -> ep : epoll_ctl(MOD, EPOLLOUT|EPOLLET)
loop -> fd : write() 发送响应数据\n（可能多轮循环到 EAGAIN）
loop -> fd : 全部写完

== 错误：忘记切回 EPOLLIN，直接 epoll_wait ==
loop -> ep : epoll_wait()\n(fd 注册的仍是 EPOLLOUT|EPOLLET)
note right of ep: send 缓冲区空闲期就是空 → 可写状态\n一直可写 → 没有"从不可写变为可写"的边沿\n→ 不触发 EPOLLOUT

client -> fd : 客户端发来下一个请求数据
note right of fd: read 缓冲区: 空 → 有数据\n这是一个边沿变化！

ep -> ep : 但 EPOLLIN 根本没注册...\n内核不关心这个变化
note right of ep: ET 只看已注册事件的边沿\n没注册的事件，天塌了也不管

loop -> ep : epoll_wait() 阻塞中，永不返回
note right of loop: 应用程序视角：\n连接"没反应了"\n客户端：发送了请求，永远等不到回复

== 正确做法（对照） ==
loop -> ep : epoll_ctl(MOD, EPOLLIN|EPOLLET)\n← 写完就切，一步都不能省
client -> fd : 客户端发来请求
ep -> ep : EPOLLIN 已注册 + read buf 空→有数据
ep --> loop : 返回 EPOLLIN
loop -> fd : read() 拿到数据 ✓

@enduml
```

ET 下不切事件的后果是**连接永久挂死**，不是空转问题。ET 的"精确"是一把双刃剑——你得到的唤醒更少，但你的注册管理必须绝对正确。

**三、LT 和 ET 在事件切换上的本质差异**

```bash
LT：epoll_ctl(MOD) 是为了"避免噪音"
├─ 不切 → EPOLLOUT 反复触发，CPU 空转，但数据不丢
├─ 切了 → 只收到自己关心的事件，干净
└─ 本质：epoll_ctl(MOD) 是性能优化，非正确性要求

ET：epoll_ctl(MOD) 是为了"收到通知"
├─ 不切 → 不会再收到任何通知，连接永久挂死
├─ 切了 → 状态变化时重新获得通知
└─ 本质：epoll_ctl(MOD) 是正确性要求，非可选
```

这就是为什么很多人说"ET 更难写"——不是因为循环到 EAGAIN 复杂，而是因为**事件注册变成了正确性的一部分**。LT 下你忘了切事件，压测会暴露 CPU 飙高；ET 下你忘了切事件，连接就消失了，而且很难排查。

> **一句话**：LT 手动切事件是为了"别吵我"（避免噪音），ET 手动切事件是为了"叫我一声"（获取通知）。前者是性能优化，后者生死攸关。

### 四、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）和**实测差异**（在真实场景中能省多少）。

**一、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 的核心价值：**只处理"真正有事"的连接**，空闲连接零开销。

**二、实测差异——本书的 benchmark 数据**

理论说得再好，最终要有数字。第 7 章的实测结果（详见 [§7.2.3 ET vs LT 长跑最优单次对比](README.md#723-et-vs-lt-长跑最优单次对比)）显示：

```plantuml
@startuml
skinparam shadowing false
title ET vs LT 实测性能差异（echo-bench 10000 请求稳态）

left header

= 短跑 100 req（串行，低并发）=
LT QPS: 28695
ET QPS: 29965 (+4.4%)
结论: 差异 < 5%，几乎测不出来

end header

rectangle "长跑 10000 req（稳态）" {
  rectangle "QPS" #E3F2FD {
    card "LT" as lt_qps [
      **15238**
      ----
    ]
    card "ET" as et_qps [
      **16277**
      ====
      **+6.8%**
    ]
  }

  rectangle "P50" #E8F5E9 {
    card "LT" as lt_p50 [
      **57 μs**
    ]
    card "ET" as et_p50 [
      **53 μs**
      ====
      **-7%**
    ]
  }

  rectangle "P99" #FFF3E0 {
    card "LT" as lt_p99 [
      **132 μs**
      ----
    ]
    card "ET" as et_p99 [
      **106 μs**
      ====
      **-20%**
    ]
  }

  rectangle "max" #FFEBEE {
    card "LT" as lt_max [
      **302 μs**
      ----
    ]
    card "ET" as et_max [
      **185 μs**
      ====
      **-39%**
    ]
  }
}
@enduml
```

四个数字揭示了一个反直觉的结论：**ET 的 QPS 优势只有 6.8%，但尾延迟优势高达 20-39%**。

```bash
ET 的真正价值不是"跑得更快"，而是"跑得更稳"：
├─ P50 -7%：中位数几乎一样（内核快速路径两者差不多）
├─ P99 -20%：长尾请求大幅减少（ET 唤醒更少 → 方差更小）
├─ max -39%：最差情况大幅改善（LT 的级联唤醒被 ET 消除）
└─ QPS +6.8%：平均吞吐提升有限（瓶颈在 TCP 握手/挥手，不在 epoll 通知）
```

**三、为什么差异不是 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 版（理解"为什么必须循环 + 手动切换事件"），最后看多进程版（理解"如何把单进程模型扩展到多核"）。三个版本对应三个递进的认知层次。

### 六、典型案例分析：LT 状态机与事件注册脱节导致的死锁 Bug

以下是一个真实 bug：`echo-epoll-lt-server` 在处理 `echo-bench` 的压测请求时卡死不动，服务端永远发不出响应。

> 该 bug 已在代码中修复，本节做详细的根因分析，作为教学案例。

#### 一、现象描述

**服务端日志**（截取）：

```bash
Epoll echo server (single-process, LT/Level-Triggered) listening on port 9988 ...
new connection fd=5 (LT mode)    ← accept 了第一个连接
new connection fd=5 (LT mode)    ← 又 accept，fd 编号相同说明前一次已经被 close 了
...（重复多次后）
new connection fd=5 (LT mode)    ← 最后一条，之后没有 closed
                                  （服务端卡住，不再输出）
```

**bench 端**：

```bash
Bench: 127.0.0.1:9988, 100 connections x 1 rounds = 100 requests ...
（卡住，没有任何输出）
```

**解读**：
- 服务端一直在 accept → close → accept 循环，每次 fd 都是 5（因为上一个被 close 后立即被复用）
- 但最后一个连接 accept 后服务端不再 close，bench 端也卡在 `read()` 等待响应
- 说明服务端 accept 并收到了客户端的请求数据，**但从未写回响应**，客户端永远等不到数据

#### 二、根因定位：状态机切换了状态，但 epoll 不知道

**修复前的 LT 版代码**（关键位置）：

```c
/* handle_read() 中，读到 EAGAIN 且有数据待写 */
if (errno == EAGAIN || errno == EWOULDBLOCK) {
    if (c->buf_len > 0) {
        c->buf_sent = 0;
        c->state = STATE_WRITE;   // ① 状态切了
        // ② 但没有调用 epoll_ctl(MOD, EPOLLOUT)！
    }
    return;
}
```

```c
/* handle_write() 中，全部写完后 */
c->buf_len  = 0;
c->buf_sent = 0;
c->state    = STATE_READ;         // ③ 状态切了
// ④ 但没有调用 epoll_ctl(MOD, EPOLLIN)！
```

**对比：修复前的 ET 版**（正常工作）：

```c
/* handle_read() */
if (errno == EAGAIN || errno == EWOULDBLOCK) {
    if (c->buf_len > 0) {
        c->buf_sent = 0;
        c->state = STATE_WRITE;
        struct epoll_event ev;
        ev.events   = EPOLLOUT | EPOLLET;
        ev.data.ptr = c;
        epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c->fd, &ev);  // ← 有这行！
    }
    return;
}
```

```c
/* handle_write() 全部写完后 */
c->buf_len  = 0;
c->buf_sent = 0;
c->state    = STATE_READ;
struct epoll_event ev;
ev.events   = EPOLLIN | EPOLLET;
ev.data.ptr = c;
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c->fd, &ev);           // ← 有这行！
```

#### 三、完整的死锁因果链

以下按时间轴还原 LT 版 bug 的触发过程：

```plantuml
@startuml
skinparam shadowing false
title LT 版 bug 时序图：为什么状态机活得好好但 epoll 不知道

participant "echo-bench" as client
participant "内核 TCP 协议栈" as kernel
participant "epoll 实例" as epoll
participant "LT 服务端" as server
participant "connection.state" as state
participant "epoll 事件注册" as reg

== ① bench 连接、发送数据、半关闭 ==
client -> kernel: connect()、write("hello echo")、shutdown(SHUT_WR)
kernel -> kernel: 数据段 + FIN 段进入接收缓冲区

== ② epoll_wait 返回 EPOLLIN（LT 模式，fd 可读） ==
epoll -> server: epoll_wait 返回 events[i] = EPOLLIN on fd=5
note right of reg #E3F2FD: 当前注册: EPOLLIN

== ③ handle_read: 循环读到 EAGAIN ==
server -> state: state = STATE_READ
server -> server: read(fd, buf, 4096) → 10 (数据: "hello echo")
state -> state: buf_len = 10
server -> server: read(fd, buf, 4086) → EAGAIN\n(FIN 还没到，或者到了但数据已被读空)
note right of state #FFF8E1
  buf_len = 10 > 0
  应该切到 STATE_WRITE！
end note

== ④ BUG: 切状态但不切 epoll 注册 ==
state --> state: state = STATE_WRITE
note right of reg #FFB3B3
  注册仍然是 EPOLLIN！
  没有调用 epoll_ctl(MOD, EPOLLOUT)
end note
server -> server: 从 handle_read 返回

== ⑤ 主循环检查: state == STATE_WRITE ==
server -> server: switch(c->state): case STATE_WRITE
server -> server: if (events & EPOLLOUT) ...
note right of server #FFB3B3
  **当前事件是 EPOLLIN，不是 EPOLLOUT**
  条件不满足，跳过 handle_write()
end note

== ⑥ 死锁 ==
server -> epoll: epoll_wait(-1) 继续等待
note right of epoll #FFB3B3
  注册的是 EPOLLIN，但缓冲区已经读空了
  既没有新数据到达（客户端 shutdown write）
  也没有注册 EPOLLOUT（写操作永远不会被通知）
  → 永远等不到任何事件
end note
client -> client: read() 等响应 → 永远阻塞
note right of client #FFB3B3
  bench 卡死
end note

@enduml
```

步骤 ④ 到 ⑥ 是核心死锁路径：

1. `handle_read` 成功读到了 `"hello echo"`（10 字节），然后 `read()` 返回 `EAGAIN`
2. LT 版代码将 `state` 设置为 `STATE_WRITE`，但**未调用 `epoll_ctl(MOD, EPOLLOUT)`**
3. 回到主事件循环后，`switch(c->state)` 走到 `case STATE_WRITE`，但此时 `events[i].events` 是 `EPOLLIN`（epoll 通知的是"fd 可读"事件），`EPOLLOUT` 条件不满足，`handle_write()` 被跳过
4. 下一轮 `epoll_wait()` 等待时，fd 的 epoll 注册仍然是 `EPOLLIN`，但内核缓冲区已被读空——客户端已经 `shutdown(SHUT_WR)`，不会再发数据
5. `epoll_wait` 永远收不到 `EPOLLOUT`（因为没有注册），服务端永远写不出响应
6. bench 端 `read()` 等响应，也永远等不到 → **双方死锁**

#### 四、为什么直觉上的"LT 会自动通知"是错的

写 LT 版代码时，直觉是这样的：

> "LT 会持续通知 fd 的当前状态。如果 fd 可写，`epoll_wait` 就会返回 `EPOLLOUT`。那我只要把 `state` 设为 `STATE_WRITE`，下次 `epoll_wait` 自然就会带着 `EPOLLOUT` 回来。"

这个直觉的问题在于混淆了 **"内核在追踪什么"** 和 **"向 epoll 注册了什么"**。

**内核追踪的是**：fd 当前是否可读、是否可写、是否有错误。这是内核 TCP 协议栈维护的状态。

**epoll 通知的是**：你注册了哪些事件类型。如果你只注册了 `EPOLLIN`，epoll 就**只**在你注册的 `EPOLLIN` 就绪时通知你。`EPOLLOUT` 就绪了也不会告诉你的——因为你没说要。

LT vs ET 的正确理解：

| | 你都注册了 `EPOLLIN \| EPOLLOUT` | 你只注册了 `EPOLLIN` |
|---|---|---|
| **LT 模式** | fd 一直可读/可写 → 每次 `epoll_wait` 都返回两个事件 | fd 可写 → **不通知**（你没注册） |
| **ET 模式** | fd 刚变得可读/可写 → 通知一次 | fd 变得可写 → **不通知**（同上） |

所以 **LT 的"持续通知"只覆盖你注册了的事件类型**。如果你不注册 `EPOLLOUT`，LT 再持续也不会通知你"fd 可写"。

#### 五、修复方案

两处修改，与 ET 版保持一致：

```c
/* 修复 1: handle_read 中，读到 EAGAIN 且 buf_len > 0 时，注册 EPOLLOUT */
if (errno == EAGAIN || errno == EWOULDBLOCK) {
    if (c->buf_len > 0) {
        c->buf_sent = 0;
        c->state = STATE_WRITE;
        struct epoll_event ev;
        ev.events   = EPOLLOUT;          // ← 新增：注册 EPOLLOUT
        ev.data.ptr = c;
        epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c->fd, &ev);  // ← 新增
    }
    return;
}

/* 修复 2: handle_write 中，全部写完数据后，注册 EPOLLIN */
c->buf_len  = 0;
c->buf_sent = 0;
c->state    = STATE_READ;
struct epoll_event ev;
ev.events   = EPOLLIN;                  // ← 新增：切回 EPOLLIN
ev.data.ptr = c;
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c->fd, &ev);           // ← 新增
```

**修复 2 还附带避免了另一个潜在问题**：如果 `handle_write` 写完后不把注册切回 `EPOLLIN`，而 `EPOLLOUT` 仍在注册状态，那么在内核发送缓冲区一直可写（localhost 回环下这是常态）时，LT 模式下 `epoll_wait` 会**持续返回 `EPOLLOUT`**，导致 CPU 空转——这就是前面提到的 "EPOLLOUT 空转陷阱"。

#### 六、教训总结

1. **LT 不意味着"不需要 `epoll_ctl`"**。LT 帮你解决了"读一半会不会丢数据"的焦虑（答案：不会），但**不解决"事件类型切换"**的问题。状态机从读切到写时，必须告诉 epoll 现在关心的是 `EPOLLOUT` 而不是 `EPOLLIN`。
2. **状态机状态 `c->state` 和 epoll 事件注册是两个独立的维度**。改了 `c->state` 不改 epoll 注册，epoll 并不知道你的状态机已经切换到写状态。两者必须同步。
3. **bug 的隐蔽性在于它是时序敏感的**。如果是本地大消息（`write` 后 `shutdown` 间隙较大），服务端可能在 FIN 到达前就读空数据并正常切到 `EPOLLOUT`……但 benchmark 的 `write → shutdown` 非常快，数据段和 FIN 段几乎同时到达，暴露了这个 bug。
4. **LT vs ET 真正的简化在于"可以不循环读"，而不是"不用管理事件注册"**。在这个状态机架构下，LT 和 ET 都需要在状态切换时 `epoll_ctl(MOD)`。区别在于，LT 读到 `EAGAIN` 之后如果 `buf_len == 0`（没读到数据），可以直接 return 而不担心丢数据；ET 下则必须确保循环到了 `EAGAIN`。

#### 七、修复验证

修复后 LT 版服务端连续 3 次 `echo-bench` 测试均 100/100 成功（修复前会卡死），bug 已根治。完整的 LT/ET/MP 三版性能数据和 bench.sh 并发对比见 [§7.1 短跑基线](README.md#71-短跑基线100-请求串行测试)。

---

## 全文要点总结

| 要点 | 说明 |
|------|------|
| **LT 内核追踪 / ET 自己追踪** | LT 每次 epoll_wait 都重新通知所有就绪 fd，ET 只在 fd 状态变化时通知一次 |
| **ET 三循环不可少** | accept/read/write 都必须循环到 EAGAIN，否则数据/连接永久丢失 |
| **事件注册策略差异** | LT 切事件为"避免噪音"（性能优化），ET 切事件为"收到通知"（正确性要求） |
| **ET 优势在尾延迟不在 QPS** | localhost 回环测算 ET QPS +6.8%，P99 -20%，max -39% |
| **Bug 教训** | 状态机状态和 epoll 注册是两个独立维度，必须同步更新 |

> **一句话总结**：LT 以"过度通知"换简单和稳定，ET 以"精确控制"换尾延迟优势。两者的根本区别在于**谁来追踪 fd 的就绪状态**——LT 交给内核（每次提醒），ET 交给应用自己（只提醒一次）。三个版本（LT / ET 单进程 / ET 多进程）对应三个递进的认知层次。

