---
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
阻塞在读/写系统调用
每连接一个线程"] -->|"问题:"| Scalability["扩展性差
万级连接就需要万级线程"]
AIO["Non-blocking +
I/O 多路复用
单线程管理万级 FD"] -->|"优势:"| HighConc["高并发 ✅"]
AsyncIO["真正的 Async I/O
aio / io_uring
内核完成通知"] -->|"终极形态:"| 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
管理所有注册的 fd
O(log n) 增删查"]
CallbackList["就绪链表 ready_list
只放真正活跃的 fd
O(1) 插入"]
DeviceCallback["中断回调函数
当网卡收到数据包时
自动将 fd 加入 ready_list"]
end
subgraph "用户空间 (User)"
App["用户应用
epoll_wait()"] -->|"仅复制就绪项"| EventArray["epoll_event events[]
只包含就绪 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 特殊事件