--- 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 特殊事件