I/O 多路复用完全指南 —— select、poll、epoll
I/O 多路复用完全指南 —— select、poll、epoll
一句话总结:I/O 多路复用就是"一个保安盯多个门"——让单个进程/线程同时监控多个 I/O 通道,有数据来了再处理,没有就歇着,避免了一个通道配一个人的资源浪费。
目录
为什么需要 I/O 多路复用?
I/O 多路复用概述
select 方案详解
poll 方案详解
epoll 方案详解
核心数据结构对比
知识对比表
总结与选型建议
一、为什么需要 I/O 多路复用?
1.1 传统方案的困境
在没有多路复用 I/O 之前,处理多个 I/O 通道只有两种方式:
方案一:阻塞 I/O + 多进程/多线程
每个进程/线程处理一路 I/O,来了连接就创建一个新进程/线程去处理。
// 阻塞 I/O + 多线程的经典模式(伪代码) while (1) { int client_fd = accept(server_fd, ...); // 阻塞等待连接 pthread_create(&tid, NULL, handle_client, &client_fd); // 每个连接一个线程 }- 类比
:就像银行柜台,来一个客户就开一个窗口。客户多了,大厅全是窗口,成本暴增。
- 缺点
:客户端越多,需要创建的进程/线程越多,内存开销巨大。每个线程默认栈空间约 8MB,10000 个连接就需要 80GB 内存,显然不可行。
方案二:非阻塞 I/O + 轮询
单个进程不断遍历所有连接,尝试读写。
// 非阻塞 I/O 轮询(伪代码) for (int i = 0; i < n; i++) { int ret = read(fds[i], buf, sizeof(buf)); // 非阻塞模式 if (ret > 0) 处理数据; // 否则立即返回,继续检查下一个 }- 类比
:就像老师每隔 1 秒点一次名,问每个同学"作业写完了吗?",即使 90% 的人还没写,也要一个一个问。
- 缺点
:CPU 空转,大量时间浪费在"检查是否就绪"上,消耗巨大。
1.2 多路复用的核心思想
一句话:内核做"保安"——应用程序把要监控的文件描述符告诉内核,内核负责盯着,有数据了再通知。这样应用程序就不用自己一个个去问了。
基本流程:
应用程序将要监控的文件描述符注册到内核
内核监控这些描述符的 I/O 事件
当有描述符就绪(可读/可写),内核通知应用程序
应用程序只处理就绪的描述符,无需遍历所有
二、I/O 多路复用概述
2.1 定义
I/O 多路复用(I/O Multiplexing)本质上是通过复用一个进程来处理多个 I/O 请求的技术。它让内核来监控多个文件描述符是否可以执行 I/O 操作,如果有就绪的描述符,将结果告知用户进程,用户进程再进行实际的 I/O 操作。
2.2 Linux 下的三种方案
方案 | 核心数据结构 | 时间复杂度 | 文件描述符上限 |
|---|---|---|---|
select | 位图(fd_set) | O(n) | 1024 |
poll | pollfd 数组 + 内核链表 | O(n) | 无限制(受内存限制) |
epoll | 红黑树 + 就绪链表 + 回调机制 | O(1) | 无限制(受内存限制) |
一句话对比:
- select
是老式人工巡检 —— 拿着一张 1024 格的签到表,每次从头到尾扫一遍
- poll
是升级版人工巡检 —— 签到表不限长度了,但还得从头扫到尾
- epoll
是智能传感器系统 —— 谁有事谁主动报告,没事不打扰
三、select 方案详解
一句话总结:select 像是一个只能管 1024 个座位的剧场管理员,每次查票都要把全场从头到尾检查一遍,即使只有 3 个人来了。
3.1 基本原理
select 通过单进程创建一个文件描述符集合(fd_set),将需要监控的文件描述符添加到这个集合中,由内核负责监控这些描述符是否可以进行读写操作。
3.2 select 函数详解
函数原型:
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);参数说明:
参数 | 含义 |
|---|---|
nfds | 最大文件描述符值 + 1 |
readfds | 监控可读的文件描述符集合 |
writefds | 监控可写的文件描述符集合 |
exceptfds | 监控异常的文件描述符集合 |
timeout | 超时时间结构体指针 |
返回值:
- > 0
:就绪的文件描述符数量
- = 0
:超时,没有文件描述符就绪
- = -1
:出错,并设置 errno
操作文件描述符集合的宏:
宏 | 功能 |
|---|---|
FD_ZERO(fd_set *set) | 清空集合 |
FD_SET(int fd, fd_set *set) | 将 fd 添加到集合 |
FD_CLR(int fd, fd_set *set) | 将 fd 从集合中删除 |
FD_ISSET(int fd, fd_set *set) | 判断 fd 是否在集合中 |
3.3 应用示例
示例:使用 select 监控标准输入
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/select.h> int main(void) { int ret; int maxfd = 0; fd_set readfds, tmpfds; struct timeval tv = {3, 0}, tmp_tv; char buffer[64] = {0}; FD_ZERO(&readfds); FD_SET(0, &readfds); // 监控标准输入(fd=0) for (;;) { tmp_tv = tv; // 每次循环重新赋值(Linux 上 select 会修改 timeout) tmpfds = readfds; // 每次循环重新复制集合,因为 select 会修改 ret = select(maxfd + 1, &tmpfds, NULL, NULL, &tmp_tv); if (ret == -1) { perror("[ERROR] select(): "); exit(EXIT_FAILURE); } else if (ret == 0) { printf("Timeout.\n"); } else if (ret > 0) { if (FD_ISSET(0, &tmpfds)) { fgets(buffer, sizeof(buffer), stdin); // 去除末尾换行符 buffer[strcspn(buffer, "\n")] = '\0'; printf("buffer : %s\n", buffer); // 输入 "quit" 时退出程序 if (strcmp(buffer, "quit") == 0) { printf("Bye!\n"); break; } } } } return 0; }执行结果:
./poll_demo (等待 1 秒,无输入) Timeout. (等待 1 秒,无输入) Timeout. Hello Poll! ← 用户在键盘输入 buffer : Hello Poll! (等待 1 秒,无输入) Timeout. quit ← 输入 "quit" 退出 buffer : quit Bye!与 select 对比:
✅ 无需每次重新复制集合(events 不会被修改)
✅ 没有 1024 的限制
❌ 底层仍然是 O(n) 的轮询
4.4 poll 的底层原理
内核中的 poll_list 链表结构:
struct poll_list { struct poll_list *next; // 链表下一节点 int len; // 本节点 pollfd 数量 struct pollfd entries[0]; // 柔性数组,存放 pollfd };poll 在内核中使用链表来组织 pollfd 数组,突破了 select 的 1024 限制。栈空间预先分配 256 字节(约 32 个 pollfd),不够时用 kmalloc 动态分配,每次分配最多一个内存页(PAGE_SIZE)。
4.5 社会类比
类比:升级版签到会
还是那个 1000 人的会场。poll 的方式是:
把签到表从"1024 格白板"换成"不限长度的电子表格"
表格上每个人有"应到"(events)和"实到"(revents)两个格子
但是,点名方式没变——工作人员还得从头跑到尾一个个看
改进:签到表不限容量了(无 1024 限制),也不用每次重写整张表(events/revents 分离)
没变:还是 O(n) 的体力活——人越多,点名越慢
五、epoll 方案详解
一句话总结:epoll 像智能门禁系统——谁刷卡谁进来,不用保安站在门口一个个问"你刷卡了吗?"
5.1 epoll 的革命性改进
epoll 针对 select/poll 的三大痛点做了根本性改进:
select/poll 的痛点 | epoll 的解决方案 |
|---|---|
每次调用都要拷贝 fd 集合到内核 | 通过 epoll_ctl 注册,只需拷贝一次 |
每次都要轮询所有 fd(O(n)) | 使用回调机制,只处理就绪的 fd(O(1)) |
需要遍历整个集合来找就绪的 fd | 维护一个就绪链表,直接取走 |
5.2 epoll 的核心 API
epoll 提供三个核心函数:
函数 | 功能 | 类比 |
|---|---|---|
epoll_create | 创建 epoll 实例 | 搭建一个"监控中心" |
epoll_ctl | 控制 epoll 实例(增/删/改) | 告诉监控中心"盯住谁" |
epoll_wait | 等待事件发生 | 问监控中心"谁有动静了?" |
5.2.1 epoll_create
#include <sys/epoll.h> int epoll_create(int size);- 功能
:创建 epoll 实例,分配核心数据结构
- 参数
size:从 Linux 2.6.8 开始,size 参数被忽略,只需填一个大于 0 的值
- 返回值
:成功返回 epoll 文件描述符,失败返回 -1
5.2.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);op 操作命令字:
命令 | 含义 | 说明 |
|---|---|---|
EPOLL_CTL_ADD | 添加 | 将 fd 添加到 epoll 实例(加入到红黑树) |
EPOLL_CTL_MOD | 修改 | 修改 fd 关联的监控事件 |
EPOLL_CTL_DEL | 删除 | 从 epoll 实例中删除 fd |
struct epoll_event 结构体:
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; // epoll 事件(EPOLLIN, EPOLLOUT, EPOLLET 等) epoll_data_t data; // 用户数据(通常存 fd) };常用事件:
事件 | 含义 |
|---|---|
EPOLLIN | 读事件有效 |
EPOLLOUT | 写事件有效 |
EPOLLET | 边缘触发模式(Edge Triggered) |
EPOLLERR | 发生错误 |
EPOLLHUP | 挂起(连接断开) |
5.2.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);- 功能
:等待文件描述符关联的事件发生
- 参数
:
epfd:epoll 实例
events:存储就绪事件的数组
maxevents:最多返回的事件数
timeout:超时时间(毫秒)
- 返回值
:
- > 0
:就绪的文件描述符数量
- = 0
:超时
- = -1
:出错
- > 0
5.3 应用示例
示例:使用 epoll 监控标准输入
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/epoll.h> #define MAXEVENTS 10 int main(void) { int epfd, ret; struct epoll_event ev; struct epoll_event ret_ev[MAXEVENTS]; char buffer[64] = {0}; // 1. 创建 epoll 实例 epfd = epoll_create(1); if (epfd == -1) { perror("[ERROR] epoll_create(): "); exit(EXIT_FAILURE); } printf("epfd = %d\n", epfd); // 输出 epoll 文件描述符 // 2. 将标准输入添加到 epoll 实例 ev.data.fd = 0; // 存储要监控的 fd ev.events = EPOLLIN; // 监控可读事件 ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &ev); if (ret == -1) { perror("[ERROR] epoll_ctl(): "); exit(EXIT_FAILURE); } // 3. 循环等待事件 for (;;) { ret = epoll_wait(epfd, ret_ev, MAXEVENTS, 1000); if (ret == -1) { perror("[ERROR] epoll_wait(): "); exit(EXIT_FAILURE); } else if (ret == 0) { printf("Timeout.\n"); } else if (ret > 0) { // 遍历就绪事件数组(ret 为就绪数量) for (int i = 0; i < ret; i++) { // 检查就绪的 fd 是否是我们关心的标准输入 if (ret_ev[i].data.fd == 0 && (ret_ev[i].events & EPOLLIN)) { fgets(buffer, sizeof(buffer), stdin); // 去除末尾换行符 buffer[strcspn(buffer, "\n")] = '\0'; printf("buffer : %s\n", buffer); // 输入 "quit" 时退出程序 if (strcmp(buffer, "quit") == 0) { printf("Bye!\n"); goto out; // 跳出外层循环 } } } } } out: close(epfd); // 关闭 epoll 实例 return 0; }执行结果:
$ ./epoll_demo epfd = 3 (等待 1 秒,无输入) Timeout. (等待 1 秒,无输入) Timeout. Hello Epoll! ← 用户在键盘输入 buffer : Hello Epoll! (等待 1 秒,无输入) Timeout. quit ← 输入 "quit" 退出 buffer : quit Bye!5.4 epoll 的底层原理
5.4.1 核心数据结构
epoll 在内核中维护了两个关键数据结构:
epoll 实例的核心结构(内核源码):
struct eventpoll { wait_queue_head_t wq; // 等待队列(epoll_wait 中的进程在此休眠) wait_queue_head_t poll_wait; // poll 等待队列 struct list_head rdllist; // 就绪链表(已就绪的文件描述符在此) struct rb_root rbr; // 红黑树根节点(管理所有注册的 fd) };红黑树节点(每个注册的 fd 对应一个):
struct epitem { struct list_head rdllink; // 就绪链表链接指针 struct epitem *next; // 用于 overflow 链表 struct epoll_filefd ffd; // 文件描述符信息 struct epoll_event event; // 注册的监控事件 };5.4.2 三大核心流程
最关键的设计——回调机制:
回调函数 ep_poll_callback 的核心逻辑:
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key) { struct epitem *epi = ep_item_from_wait(wait); struct eventpoll *ep = epi->ep; // 检查事件是否匹配 if (key && !((unsigned long)key & epi->event.events)) goto out_unlock; // 最重要的事情:将就绪 fd 添加到就绪链表 if (!ep_is_linked(&epi->rdllink)) { list_add_tail(&epi->rdllink, &ep->rdllist); } // 如果有进程在 epoll_wait 中休眠,唤醒它 if (waitqueue_active(&ep->wq)) wake_up_locked(&ep->wq); return 1; }核心思想:epoll 不是主动去问"谁就绪了",而是让设备驱动在数据到达时主动调用回调函数,把就绪的 fd 放入就绪链表。这就是 O(1) 性能的根源。
5.5 LT 模式 vs ET 模式
epoll 支持两种触发模式,这是另一个关键概念:
特性 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
触发条件 | 只要缓冲区有数据,每次都通知 | 只有状态变化时才通知一次 |
默认模式 | ✅ 默认 | 需要设置 EPOLLET 标志 |
编程难度 | 简单 | 较复杂 |
性能 | 一般 | 更高 |
使用建议 | 适合新手、简单场景 | 适合高性能服务器 |
类比:
- LT(水平触发)
= 家中烟雾报警器——只要还有烟,就一直响
- ET(边缘触发)
= 门铃——只在你按下的那一刻响一次,不重复提醒
5.6 社会类比
类比:现代化医院叫号系统
想象一家大医院:
select 方案:护士每隔 10 分钟在所有诊室间跑一圈,记录"谁看完了"(就绪),回到前台翻她那只能记 1024 人的小本本。每次跑完,本本还要重新写。
poll 方案:护士换了一个不限页数的点名册,但还是得跑圈。
epoll 方案:每个诊室装了一个呼叫铃(回调机制)。病人看完病,医生按铃,护士站的大屏幕(就绪链表)立刻显示"3 号诊室空出来了"。护士只处理按铃的诊室,不用跑圈。诊室再多也不影响效率。
六、核心数据结构对比
6.1 select 的内核数据结构
6.2 poll 的内核数据结构
6.3 epoll 的内核数据结构
七、知识对比表
7.1 基本特性对比
对比维度 | select | poll | epoll |
|---|---|---|---|
底层数据结构 | 位图(fd_set) | pollfd 数组 + 内核链表 | 红黑树 + 就绪链表 |
时间复杂度(监控) | O(n) | O(n) | O(1) |
时间复杂度(就绪返回) | O(n) | O(n) | O(1)✅ |
最大 fd 数 | 1024(硬编码) | 无限制(受内存限制) | 无限制(受内存限制) |
是否支持回调机制 | ❌ | ❌ | ✅ |
每次是否需要拷贝集合 | ✅ 每次都要拷贝 | ✅ 每次都要拷贝 | ✅ 只需注册时拷贝一次 |
7.2 使用细节对比
使用注意点 | select | poll | epoll |
|---|---|---|---|
是否需要重新设置集合 | ✅ 每次必须重新赋值 | ❌ events 不会被修改 | ❌ 注册一次即可 |
就绪 fd 的获取方式 | 遍历整个集合检查 FD_ISSET | 遍历整个数组检查 revents | 直接读取就绪事件数组 |
超时精度 | 微秒(timeval) | 毫秒 | 毫秒 |
跨平台支持 | Windows、Linux、macOS | 主流 Linux/Unix | Linux 2.6+ |
触发模式 | 仅水平触发(LT) | 仅水平触发 | 支持 LT 和 ET 两种模式 |
7.3 性能对比
场景 | select | poll | epoll |
|---|---|---|---|
少量连接(< 100) | ✅ 可用 | ✅ 可用 | ✅ 可用 |
中等连接(100~1000) | ⚠️ 性能下降 | ⚠️ 性能下降 | ✅ 性能优 |
海量连接(> 1000) | ❌ 不可用(1024 限制) | ❌ 性能急剧下降 | ✅ 性能稳定 |
连接频繁增删场景 | ❌ 必须重新设置整个集合 | ❌ 必须重新设置整个数组 | ✅ 通过 epoll_ctl 动态调整 |
CPU 占用 | 高(空轮询) | 高(空轮询) | 低(回调驱动) |
7.4 典型应用场景
场景 | 推荐方案 | 原因 |
|---|---|---|
嵌入式系统、fd 少且固定 | select | 简单、跨平台、够用 |
普通桌面应用、中等规模 fd | poll | 无 1024 限制,API 更友好 |
高并发网络服务器(Nginx、Redis) | epoll | 海量连接、高性能、O(1) |
需要跨平台(如 Windows + Linux) | select / poll | epoll 仅限 Linux |
需要边缘触发模式 | epoll | 唯一支持 ET 的方案 |
实时性要求高的场景 | epoll(ET 模式) | 事件驱动,响应最快 |
7.5 各方案的"槽点"总结
方案 | 最受诟病的问题 |
|---|---|
select | “1024 上限太抠门” + “每次都要重新赋值” |
poll | “虽然不限人数了,但还得一个个问”(O(n) 轮询) |
epoll | “Linux only,Windows 不带你玩” |
八、总结与选型建议
8.1 一句话概括三兄弟
方案 | 一句话总结 |
|---|---|
| select | “1024 个座的小剧场管理员,每次查票跑全场” |
| poll | “不限座位的大体育场管理员,但还得跑全场” |
| epoll | “智能感应系统——谁有事谁按铃,没事不打扰” |
8.2 演进路线
8.3 选型决策树
8.4 关键记忆点
一条发展主线:从 select→poll→epoll 的演进,本质是**“被动轮询 → 主动通知”**的变革。
一个核心指标:时间复杂度从 O(n) → O(1),这是 epoll 能支撑高并发的根本原因。
一个最重要结论:如果没有 epoll,就没有今天互联网上千万级的并发连接,也就没有 Nginx、Redis、Node.js 这些高性能基石。
本文档基于 Linux 内核源码分析整理,示例代码可在任何 Linux 环境下编译运行。
编译命令示例:gcc -o select_demo select_demo.c