Files
cs-note/hhs/NETWORK/06-Socket编程/02-epoll深度解析ETvsLT.md
T
2026-05-24 11:42:38 +08:00

299 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [计算机网络, epoll, select, poll, ET模式, LT模式, I/O多路复用]
create time: 2026-05-18 04:35
---
# I/O 多路复用:select → poll → epoll 深度解析
## 概述
I/O 多路复用是让一个线程管理成千上万个 Socket 连接的核心技术。它经历了 select → poll → epoll 三阶段进化,每一代的核心理念都在解决上一代的瓶颈。理解它们的区别,是在高并发场景下做出正确选型的前提。
## I/O 模型全景图
```mermaid
flowchart LR
BIO["Blocking I/O<br/>阻塞在读/写系统调用<br/>每连接一个线程"] -->|"问题:"| Scalability["扩展性差<br/>万级连接就需要万级线程"]
AIO["Non-blocking +<br/>I/O 多路复用<br/>单线程管理万级 FD"] -->|"优势:"| HighConc["高并发 ✅"]
AsyncIO["真正的 Async I/O<br/>aio / io_uring<br/>内核完成通知"] -->|"终极形态:"| ZeroWait["零等待 ✅✅"]
style BIO fill:#FF6B6B,color:#fff
style AIO fill:#98FB98,color:#000
style AsyncIO fill:#B0C4DE,color:#000
```
| 模型 | 说明 | 典型框架 |
|------|------|---------|
| **Blocking (BIO)** | read()/write() 阻塞,每连接一线程 | Python threading, PHP-FPM |
| **Non-blocking + 多路复用** | 注册多个 FD,等待就绪事件 | Nginx, Node.js, Go runtime |
| **Async I/O (aio/io_uring)** | 内核完成 I/O 后回调通知 | Linux io_uring, Windows IOCP |
| **Signal-driven (SIGIO)** | 数据就绪时 SIGIO 信号通知 | 较少使用 |
> [!NOTE] Go 的独特路线
> Go 选择了第三条路:**goroutine + epoll 后台轮询**。每个请求一个 goroutine(看起来像 BIO),但底层由少量 OS 线程通过 epoll 调度。这被称为 **"M:N 调度"** —— M 个 goroutine 映射到 N 个 OS 线程(通常 N=GOMAXPROCS)。
## select —— 第一代多路复用
### C API 原型
```c
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
// 返回值:就绪的 fd 总数;-1 = 错误
```
### 核心缺陷
```
┌─────────────────────────────────────┐
│ select 三大缺陷 │
├─────────────────────────────────────┤
│ 1. FD_SETSIZE 固定 1024 │
│ 必须 recompile 内核才能扩大 │
│ │
│ 2. O(n) 线性扫描 │
│ 每次调用都要遍历全部注册的 fd │
│ │
│ 3. 用户态 ↔ 内核态拷贝整个 fd_set │
│ n=10000 时每次拷贝 12.5KB │
└─────────────────────────────────────┘
```
```go
// Go 中几乎不会直接用 select 做多路复用
// Go 的 select 关键字是语言级语法糖,底层走的是自己的 netpoller
select {
case <-ch1:
// channel ch1 可读
case <-ch2:
// channel ch2 可读
default:
// 都没有就绪
}
```
## poll —— 第二代改进
### C API 原型
```c
struct pollfd {
int fd; /* file descriptor */
short events; /* 关注的事件: POLLIN/POLLOUT/POLLERR */
short revents; /* 返回的实际事件 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
```
### 改进了什么?
```
✅ 不再有固定的 FD_SETSIZE 上限(改用动态数组)
❌ 仍然是 O(n) 线性扫描
❌ 每次仍需将整个 fds 数组拷贝到内核
❌ 返回时需要逐个检查 revents 位
结论:适合中等规模(几百连接),不适合万级
```
### poll 事件类型速查
| 宏 | 数值 | 含义 |
|----|------|------|
| `POLLIN` | 0x001 | 有数据可读 |
| `POLLRDNORM` | 0x040 | 正常可读(等效 POLLIN)|
| `POLLOUT` | 0x002 | 可写 |
| `POLLERR` | 0x008 | 错误条件 |
| `POLLHUP` | 0x010 | 挂起(对端关闭)|
| `POLLNVAL` | 0x020 | 无效请求(fd 未打开)|
## epoll —— Linux 王者方案
### 三大系统调用
```c
// 1. 创建 epoll 实例
int epfd = epoll_create(1); // size 参数已被忽略,传 1 即可
// 2. 增删改 fd 的事件注册
int epoll_ctl(epfd, EPOLLADD, fd, &event); // 添加
int epoll_ctl(epfd, EPOLLMOD, fd, &event); // 修改
int epoll_ctl(epfd, EPOLLDEL, fd, NULL); // 删除
// 3. 等待就绪事件(只返回活跃的 fd!)
int ready = epoll_wait(epfd, events, maxevents, timeout);
// 返回就绪数量,events 数组被填充
```
### 内核数据结构 —— O(1) 的秘密
```mermaid
flowchart TD
subgraph "内核空间 (Kernel)"
RBTree["红黑树 rb_root<br/>管理所有注册的 fd<br/>O(log n) 增删查"]
CallbackList["就绪链表 ready_list<br/>只放真正活跃的 fd<br/>O(1) 插入"]
DeviceCallback["中断回调函数<br/>当网卡收到数据包时<br/>自动将 fd 加入 ready_list"]
end
subgraph "用户空间 (User)"
App["用户应用<br/>epoll_wait()"] -->|"仅复制就绪项"| EventArray["epoll_event events[]<br/>只包含就绪 fd"]
end
DeviceCallback -.->|"fd 有数据时触发"| CallbackList
style RBTree fill:#DDA0DD,color:#000
style CallbackList fill:#98FB98,color:#000
style App fill:#FFD700,color:#000
```
```
┌──────────────────────────────────────────┐
│ epoll 三大优势 │
├──────────────────────────────────────────┤
│ ✅ O(1) 时间复杂度 │
│ 就绪事件通过回调链表直接给出 │
│ │
│ ✅ 内存拷贝极小 │
│ 只拷贝就绪事件的个数 × sizeof(event) │
│ │
│ ✅ fd 无上限 │
│ 只受系统可用文件描述符数限制 (~百万级) │
│ │
│ ❌ Linux 专属 │
│ macOS/BSD 用 kqueue, Windows 用 IOCP │
└──────────────────────────────────────────┘
```
## ET vs LT 模式深度对比
epoll 支持两种触发模式,这是面试和生产中最常涉及的话题。
### LT(Level Triggered)—— 默认模式
```
📥 fd 上有数据到达
↓
epoll_wait() 返回 ✅ 通知一次
↓
应用只读了部分数据(还剩一半)
↓
下次 epoll_wait() 仍然返回 ✅ 继续通知
↓
一直到该 fd 上的数据全部读完毕才停止
```
```c
// LT 模式下可以安全地只读一部分
n = read(fd, buf, sizeof(buf)); // 可能还有剩余数据
if (n > 0) handle(buf, n);
// 下次 epoll_wait 还会提醒你有数据没读完
```
> [!tip] LT 类比
> LT 就像**消息通知**——只要你没读完,消息就一直亮着未读红点。
### ET(Edge Triggered)—— 边缘触发
```
📥 fd 状态从「无数据」变为「有数据」 ← 仅在此刻通知!
↓
epoll_wait() 返回 ✅
↓
你必须一次性读完所有数据!⚠️
↓
如果有剩余?下次不通知了!🚨
```
```c
// ET 模式的标准 read 循环模板
while (1) {
n = read(fd, buf, sizeof(buf));
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 数据已全部读完,退出循环
}
perror("read error");
close(fd);
break;
} else if (n == 0) {
close(fd); // EOF = 对端关闭
break;
}
// 处理 n 字节数据
}
```
> [!warning] ET 模式的常见陷阱
>
> 1. **必须配合 non-blocking fd** —— blocking 模式下 read 会在无数据时永久阻塞,破坏 ET 语义
> 2. **必须用 while 循环读到底** —— `read()` 返回 EAGAIN 前一直读
> 3. **write 也要同理** —— 写缓冲区满时要重新注册 POLLOUT
```
┌────────────────────────────────────────────┐
│ ET vs LT 决策表 │
├────────────────┬────────────────┬───────────┤
│ 特性 │ LT │ ET │
├────────────────┼────────────────┼───────────┤
│ 默认模式 │ ✅ 是 │ ❌ 否 │
│ 编程难度 │ 简单 │ 较复杂 │
│ 性能 │ 略低 │ 更高 ⭐ │
│ 误通知 │ 无(有数据就通知)│ 无 │
│ 遗漏风险 │ 低 │ 高(漏读即丢失)│
│ 推荐场景 │ 大多数场景 │ 极致性能追求 │
└────────────────┴────────────────┴───────────┘
```
### Go 的 netpoller 使用哪种模式?
```
Linux: epoll 的 ET 模式
macOS: kqueue 的边缘触发语义
Windows: IOCP 本身就是边缘触发
```
Go 的 net 包在底层做了大量封装,你不需要关心 ET/LT 的区别。但当你在 Go 中写自定义网络框架(如 RPC 框架、游戏服务器)时,就会直面这个问题。
## 实战:最小可用的 epoll 框架
```go
package main
import (
"fmt"
"golang.org/x/sys/unix"
"time"
)
func main() {
// 1. 创建 epoll 实例
epfd, _ := unix.EpollCreate1(0)
defer unix.Close(epfd)
// 2. 打开文件并注册 EPOLLIN
fd, _ := unix.Open("/tmp/test.txt", unix.O_RDONLY|unix.O_NONBLOCK, 0)
ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(fd)}
unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, fd, &ev)
// 3. 等待就绪
events := make([]unix.EpollEvent, 128)
ready, _ := unix.EpollWait(epfd, events, 5000) // 5s timeout
for i := 0; i < ready; i++ {
fmt.Printf("fd %d is ready!\n", events[i].Fd)
}
}
```
> [!tip] 实际项目建议
> 除非有特殊需求,否则直接使用 Go 的 `net`、`bufio`、`http` 包即可。epoll 的细节已被这些成熟库封装好了。
## 关联笔记
- [[hhs/NETWORK/SocketAPI与backlog详解]] — Socket 创建后的第一步:listen + accept
- [[hhs/NETWORK/零拷贝与GoNetpoller]] — epoll 之上的零拷贝技术与 Go runtime 集成
- [[hhs/NETWORK/TCP段结构与状态机]] — 何时关注 EPOLLRDHUP 等 TCP 特殊事件