11 KiB
11 KiB
tags, create time
| tags | create time | |||||||
|---|---|---|---|---|---|---|---|---|
|
2026-05-18 04:35 |
I/O 多路复用:select → poll → epoll 深度解析
概述
I/O 多路复用是让一个线程管理成千上万个 Socket 连接的核心技术。它经历了 select → poll → epoll 三阶段进化,每一代的核心理念都在解决上一代的瓶颈。理解它们的区别,是在高并发场景下做出正确选型的前提。
I/O 模型全景图
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 原型
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 中几乎不会直接用 select 做多路复用
// Go 的 select 关键字是语言级语法糖,底层走的是自己的 netpoller
select {
case <-ch1:
// channel ch1 可读
case <-ch2:
// channel ch2 可读
default:
// 都没有就绪
}
poll —— 第二代改进
C API 原型
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 王者方案
三大系统调用
// 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) 的秘密
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 上的数据全部读完毕才停止
// LT 模式下可以安全地只读一部分
n = read(fd, buf, sizeof(buf)); // 可能还有剩余数据
if (n > 0) handle(buf, n);
// 下次 epoll_wait 还会提醒你有数据没读完
[!tip] LT 类比 LT 就像消息通知——只要你没读完,消息就一直亮着未读红点。
ET(Edge Triggered)—— 边缘触发
📥 fd 状态从「无数据」变为「有数据」 ← 仅在此刻通知!
↓
epoll_wait() 返回 ✅
↓
你必须一次性读完所有数据!⚠️
↓
如果有剩余?下次不通知了!🚨
// 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 模式的常见陷阱
- 必须配合 non-blocking fd —— blocking 模式下 read 会在无数据时永久阻塞,破坏 ET 语义
- 必须用 while 循环读到底 ——
read()返回 EAGAIN 前一直读- write 也要同理 —— 写缓冲区满时要重新注册 POLLOUT
┌────────────────────────────────────────────┐
│ ET vs LT 决策表 │
├────────────────┬────────────────┬───────────┤
│ 特性 │ LT │ ET │
├────────────────┼────────────────┼───────────┤
│ 默认模式 │ ✅ 是 │ ❌ 否 │
│ 编程难度 │ 简单 │ 较复杂 │
│ 性能 │ 略低 │ 更高 ⭐ │
│ 误通知 │ 无(有数据就通知)│ 无 │
│ 遗漏风险 │ 低 │ 高(漏读即丢失)│
│ 推荐场景 │ 大多数场景 │ 极致性能追求 │
└────────────────┴────────────────┴───────────┘
Go 的 netpoller 使用哪种模式?
Linux: epoll 的 ET 模式
macOS: kqueue 的边缘触发语义
Windows: IOCP 本身就是边缘触发
Go 的 net 包在底层做了大量封装,你不需要关心 ET/LT 的区别。但当你在 Go 中写自定义网络框架(如 RPC 框架、游戏服务器)时,就会直面这个问题。
实战:最小可用的 epoll 框架
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 特殊事件