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); // 每个连接一个线程 }类比就像银行柜台来一个客户就开一个窗口。客户多了大厅全是窗口成本暴增。缺点客户端越多需要创建的进程/线程越多内存开销巨大。每个线程默认栈空间约 8MB10000 个连接就需要 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_setO(n)1024pollpollfd 数组 内核链表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最大文件描述符值 1readfds监控可读的文件描述符集合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); // 监控标准输入fd0 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 注册只需拷贝一次每次都要轮询所有 fdO(n)使用回调机制只处理就绪的 fdO(1)需要遍历整个集合来找就绪的 fd维护一个就绪链表直接取走5.2 epoll 的核心 APIepoll 提供三个核心函数函数功能类比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 文件描述符失败返回 -15.2.2 epoll_ctlint 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 实例中删除 fdstruct 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 TriggeredEPOLLERR发生错误EPOLLHUP挂起连接断开5.2.3 epoll_waitint epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);功能等待文件描述符关联的事件发生参数epfdepoll 实例events存储就绪事件的数组maxevents最多返回的事件数timeout超时时间毫秒返回值 0就绪的文件描述符数量 0超时 -1出错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 基本特性对比对比维度selectpollepoll底层数据结构位图fd_setpollfd 数组 内核链表红黑树 就绪链表时间复杂度监控O(n)O(n)O(1)时间复杂度就绪返回O(n)O(n)O(1)✅最大 fd 数1024硬编码无限制受内存限制无限制受内存限制是否支持回调机制❌❌✅每次是否需要拷贝集合✅ 每次都要拷贝✅ 每次都要拷贝但 events 无需重置✅ 只需注册时拷贝一次7.2 使用细节对比使用注意点selectpollepoll是否需要重新设置集合✅ 每次必须重新赋值❌ events 不会被修改❌ 注册一次即可就绪 fd 的获取方式遍历整个集合检查 FD_ISSET遍历整个数组检查 revents直接读取就绪事件数组超时精度微秒timeval毫秒毫秒跨平台支持Windows、Linux、macOS主流 Linux/UnixLinux 2.6触发模式仅水平触发LT仅水平触发支持 LT 和 ET 两种模式7.3 性能对比场景selectpollepoll少量连接 100✅ 可用✅ 可用✅ 可用中等连接100~1000⚠️ 性能下降⚠️ 性能下降✅ 性能优海量连接 1000❌ 不可用1024 限制❌ 性能急剧下降✅ 性能稳定连接频繁增删场景❌ 必须重新设置整个集合❌ 必须重新设置整个数组✅ 通过 epoll_ctl 动态调整CPU 占用高空轮询高空轮询低回调驱动7.4 典型应用场景场景推荐方案原因嵌入式系统、fd 少且固定select简单、跨平台、够用普通桌面应用、中等规模 fdpoll无 1024 限制API 更友好高并发网络服务器Nginx、Redisepoll海量连接、高性能、O(1)需要跨平台如 Windows Linuxselect / pollepoll 仅限 Linux需要边缘触发模式epoll唯一支持 ET 的方案实时性要求高的场景epollET 模式事件驱动响应最快7.5 各方案的槽点总结方案最受诟病的问题select“1024 上限太抠门” “每次都要重新赋值”poll“虽然不限人数了但还得一个个问”O(n) 轮询epoll“Linux onlyWindows 不带你玩”八、总结与选型建议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