299 lines
11 KiB
Markdown
299 lines
11 KiB
Markdown
---
|
||
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 特殊事件
|