I/O 多路复用完全指南 —— select、poll、epoll

I/O 多路复用完全指南 —— select、poll、epoll

I/O 多路复用完全指南 —— select、poll、epoll

一句话总结:I/O 多路复用就是"一个保安盯多个门"——让单个进程/线程同时监控多个 I/O 通道,有数据来了再处理,没有就歇着,避免了一个通道配一个人的资源浪费。


目录

  1. 为什么需要 I/O 多路复用?

  2. I/O 多路复用概述

  3. select 方案详解

  4. poll 方案详解

  5. epoll 方案详解

  6. 核心数据结构对比

  7. 知识对比表

  8. 总结与选型建议


一、为什么需要 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 多路复用的核心思想

一句话:内核做"保安"——应用程序把要监控的文件描述符告诉内核,内核负责盯着,有数据了再通知。这样应用程序就不用自己一个个去问了。

基本流程

  1. 应用程序将要监控的文件描述符注册到内核

  2. 内核监控这些描述符的 I/O 事件

  3. 当有描述符就绪(可读/可写),内核通知应用程序

  4. 应用程序只处理就绪的描述符,无需遍历所有


二、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

      :出错

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(硬编码)

无限制(受内存限制)

无限制(受内存限制)

是否支持回调机制

每次是否需要拷贝集合

✅ 每次都要拷贝

✅ 每次都要拷贝
(但 events 无需重置)

✅ 只需注册时拷贝一次

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