Appearance
select、poll、epoll 三剑客深度对比 —— 从"每次传全家福"到"谁就绪谁举手"
IO 模型总纲 把
select、poll、epoll归为"IO 多路复用"这一大类,给了一张对比表。本篇把这张表展开成完整的纵深对比:用序列图把三者的内部执行流摊开,说清楚每一帧"内核在做什么、用户态在等什么、数据怎么拷、开销从哪来",最后落到选型指南和性能量化。
本文更新于 2026-08-06。
一、本实验要回答的问题
select、poll、epoll三者的内部执行流有什么本质区别?- 为什么
select有 1024 的 fd 上限而poll没有? - 为什么
epoll在"连接多、活跃少"的场景下碾压另外两个? - 如果所有连接都很活跃(高活跃度),
epoll还有优势吗?
二、前置知识:一次网络读的两个阶段
理解三者的差异,先回到 read() 的本质——它由两个阶段组成:

- 阶段①:等数据到达——数据从网卡到内核 socket 缓冲区。数据没来,这阶段就是"空等"。
- 阶段②:数据拷贝——内核缓冲区到用户
buf,真正的 CPU 开销就在这里。
select、poll、epoll 解决的都是阶段①的问题——怎么用一个线程高效地"同时等"成千上万个 fd,而不是每连接一个线程在那空等。阶段②的拷贝三者都不参与(那是 read() 的事)。
IO 多路复用(I/O Multiplexing):用一个线程同时监视多个 fd 的就绪状态,哪个就绪了就去处理哪个,替代"每连接一线程"的旧模型。更多背景见 IO 模型总纲。
三、select —— 位图 + 全量拷入 + 全量扫描
3.1 内部执行流
select 的核心数据结构是一个位图(bitmap)——fd_set,每个 bit 代表一个 fd:
fd_set (默认 1024 bits = 128 bytes)
bit: 0 1 2 3 ... 1023
fd: [0][1][1][0] ... [0] ← 1=监视这个fd
3.2 开销分析
select 的三宗罪:
| 环节 | 做什么 | 代价 |
|---|---|---|
| ① 拷入 | 每次调用把整个 fd_set 从用户态拷贝到内核态 | O(n) 内存拷贝,n = 监视的 fd 数量 |
| ② 内核扫描 | 内核遍历整个 fd_set,对每个被监视的 fd 调用 poll() 检查 | O(n) 遍历,大部分 fd 根本没就绪 |
| ③ 用户遍历 | 返回后,用户态必须再次遍历全部 fd,用 FD_ISSET 逐一判断 | O(n) 再扫一遍 |

3.3 关键限制
FD_SETSIZE= 1024:fd_set是编译期固定大小(默认 128 字节 = 1024 bits),要突破需重新编译内核或应用。即使重新编译增大,位图线性扫描的 O(n) 本质不变。- 无状态:内核不"记住"你监视哪些 fd。每次
select调用都要重新传入完整 fd_set——上一次调用的结果,内核早已忘掉。 - 改写入参:
select直接修改传入的fd_set(将未就绪的 bit 清零),所以每次调用前必须FD_ZERO+ 重新FD_SET,无法复用。
一句话:
select像每次查房都把整栋楼的名册复印一份交给门卫,门卫挨个房间敲门,回来把名册上"不在家"的人都划掉,你还要再对着名册找一遍"谁在"。
四、poll —— 数组替代位图,O(n) 本质不变
4.1 内部执行流
poll 用动态数组 struct pollfd 代替 select 的固定位图,解决了 1024 上限问题:

4.2 poll 比 select 好在哪、差在哪
| 维度 | select | poll |
|---|---|---|
| 数据结构 | fd_set(定长位图) | struct pollfd 数组(动态分配) |
| fd 上限 | 1024(FD_SETSIZE) | 无硬上限(受内存限制) |
| 内核扫描 | O(n) 线性遍历 | O(n) 线性遍历 |
| 用户遍历 | O(n) 遍历 FD_ISSET | O(n) 遍历 revents |
| 入参复用 | 不可复用(被改写) | 可复用(events 保留,只清 revents) |
| 精度 | timeout 为微秒,但内核四舍五入到 1~10ms | timeout 为毫秒 |
poll 消除了 1024 上限和入参被改写的问题,但O(n) 拷入 + O(n) 内核扫描 + O(n) 用户遍历的三重线性开销原封不动:

一句话:
poll只不过把select的"固定大小名册"换成了"任意大小名册",复印、敲门、查名单的三步流程一点没少。
五、epoll —— "注册一次 + 回调就绪"
5.1 核心思路:从"每次都查"到"变化时通知"
epoll 的设计哲学是事不过三——fd 只注册一次,内核用数据结构记住它,就绪时靠回调主动上报:

5.2 三个系统调用的分工

5.3 为什么 O(1)
select/poll 的 O(n) 来自"每次都要传递全部 + 扫描全部"。epoll 逐个击破:
| 环节 | select/poll | epoll |
|---|---|---|
| 传入 fd | 每次调用拷贝全部 fd | epoll_ctl 注册一次,红黑树常驻内核 |
| 检查就绪 | 每次 O(n) 遍历 | 回调驱动,就绪时 fd 自动入链 |
| 取回就绪 | O(n) 遍历找就绪 | epoll_wait 只返回就绪链表里的 fd |
5.4 内核事件通知机制
epoll 的就绪链表不是凭空来的,它依托内核的**等待队列(wait queue)**机制:

这条链路中,epoll 做的事是——在 epoll_ctl(ADD) 时给 fd 的等待队列上挂一个回调函数;数据到达时,这个回调被唤醒,把 fd 节点从红黑树"提"到就绪链表。整个过程中 epoll_wait 不参与扫描,只消费就绪链表。
六、三者核心差异——序列图对比
将三次调用的完整流程并排,一眼看出谁在哪一步付出了什么样的代价:

七、全面对比表
7.1 核心维度对比
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set(固定大小位图) | struct pollfd 动态数组 | 红黑树 + 就绪链表 |
| fd 数量上限 | 1024(FD_SETSIZE 硬编码) | 无硬上限(受内存限制) | 无硬上限(受内存/ulimit -n) |
| 每次调用传递 | 拷贝全部 fd_set 进内核 | 拷贝全部 pollfd 数组进内核 | 不传(epoll_ctl 注册,常驻内核) |
| 内核检查复杂度 | O(n) 线性扫描全部 fd | O(n) 线性扫描全部 fd | O(1) 就绪链表直接取 |
| 用户取回复杂度 | O(n) 遍历全部 fd 找就绪 | O(n) 遍历全部 fd 找 revents | O(1) 只处理返回的那几个 |
| 入参复用 | 不可复用(fd_set 被改写) | 可复用(只清 revents,留 events) | 天然可复用(不传 fd 列表) |
| 内核"记不记得" fd | 不记得(无状态) | 不记得(无状态) | 记得(红黑树常驻内核) |
| 新增 fd | 重新构造整个 fd_set | 扩大数组,重新赋值 | epoll_ctl(ADD) O(log n) |
| 删除 fd | 重新构造整个 fd_set | 从数组移除 | epoll_ctl(DEL) O(log n) |
| 触发模式 | 仅水平触发 | 仅水平触发 | LT(水平触发)+ ET(边缘触发) |
| 跨平台 | POSIX 标准,所有 Unix 支持 | POSIX 标准,所有 Unix 支持 | Linux 专属(BSD 有 kqueue) |
| 适用场景 | fd < 100,跨平台 | fd < 1000,简单移植 | 海量连接,Linux 高并发首选 |
7.2 性能随连接数的变化

epoll 在"连接多、活跃少"场景下碾压 select/poll,原因是它的代价只和就绪 fd 数量成正比,与总连接数几乎无关。
但如果几乎所有 fd 都一直就绪(极高活跃度场景,如局域网内大流量转发),epoll 的优势会被削弱——因为回调频繁触发、就绪链表一直有数据,epoll_wait 不再阻塞,退化为一种高效的"取就绪 → 处理"循环。此时 epoll 相比 poll 的优势主要在"不重复传 fd 列表"上。
八、代码对比
8.1 select 典型写法
c
fd_set readfds;
int max_fd = 0;
while (1) {
FD_ZERO(&readfds);
FD_SET(listen_fd, &readfds);
max_fd = listen_fd;
// 把所有的客户端 fd 都塞进去
for (int i = 0; i < client_count; i++) {
FD_SET(clients[i].fd, &readfds);
if (clients[i].fd > max_fd)
max_fd = clients[i].fd;
}
// 阻塞等——会改写 readfds
int n = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(listen_fd, &readfds)) {
int conn = accept(listen_fd, ...);
// 加入 clients 数组
}
for (int i = 0; i < client_count; i++) {
if (FD_ISSET(clients[i].fd, &readfds)) {
read(clients[i].fd, buf, sizeof(buf));
}
}
}8.2 poll 典型写法
c
struct pollfd fds[MAX_CONN];
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
int nfds = 1;
while (1) {
// events 保留不丢, poll 只改写 revents
int n = poll(fds, nfds, -1);
if (fds[0].revents & POLLIN) {
int conn = accept(listen_fd, ...);
fds[nfds].fd = conn;
fds[nfds].events = POLLIN;
nfds++;
}
for (int i = 1; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
int r = read(fds[i].fd, buf, sizeof(buf));
if (r <= 0) {
close(fds[i].fd);
fds[i] = fds[--nfds]; // 用最后一个填充
}
}
}
}8.3 epoll 典型写法
c
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[1024];
while (1) {
// n = 就绪 fd 数,events[] 只填了就绪的那几个
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
int conn = accept(listen_fd, ...);
ev.events = EPOLLIN;
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
} else {
int r = read(events[i].data.fd, buf, sizeof(buf));
if (r <= 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
}
}
}
}对比代码也可以看出来——epoll 主循环里没有"遍历全部"的逻辑,只有 for (i = 0; i < n; i++),n 就是就绪 fd 数。
九、实验验证
实验环境:CentOS 7, AMD EPYC 7K62 4 核 VM,8GB 内存,Linux 3.10。
9.1 实验设计
用 mt-io-demo 的三类线程模拟不同负载模式,分别观测 select/poll/epoll 层面的开销特征:
| 实验 | 模拟场景 | 对应多路复用视角 |
|---|---|---|
| A:低活跃度 | 10000 个 IO 线程,每个 nanosleep(200us) 后才触发一次读写 | epoll 最擅长:大量 fd、少量活跃 |
| B:高活跃度 | 100 个 CPU 密集型线程,持续空转计算 | 接近 poll 的"全部活跃"场景 |
9.2 实验数据
| 指标 | A 低活跃度 (10000 IO) | B 高活跃度 (100 CPU) |
|---|---|---|
| 总 CPU | ~10% | ~100% × 4 核 |
| 上下文切换 | ~30,000 csw/s | ~20 csw/s |
| 系统调用特征 | nanosleep 主导 | 几乎无系统调用(纯计算) |
| 就绪 fd 比例 | < 1%(每轮就几十个) | 100%(都在忙) |
9.3 分析
| 场景 | select 表现 | poll 表现 | epoll 表现 |
|---|---|---|---|
| A(低活跃度,10000 fd) | 不可用(1024 限制) | 每次 O(10000) 扫描,极高开销 | O(活跃数) ≈ O(几十),几乎无开销 |
| B(高活跃度,100 fd) | 够用 | 够用 | 够用,优势不明显 |
9.4 实验结论
三种多路复用机制的选择取决于连接数和活跃度两个维度的交叉:
| 连接数 / 活跃度 | 推荐 | 原因 |
|---|---|---|
| 少连接(< 100) | select 或 poll | 开销差别不显著,select 跨平台更好 |
| 多连接(> 1000)、低活跃度 | epoll | O(n) 的 n 是活跃数而非总连接数,碾压级优势 |
| 多连接(> 1000)、高活跃度 | epoll 或 poll | epoll 优势缩小但仍有,核心是"不重复传 fd 列表" |
十、回答开头的问题
Q1:三者的内部执行流有什么本质区别?
- select:每次调用
用户态拷入 fd_set → 内核 O(n) 扫描全部 → 改写 fd_set → 返回 → 用户 O(n) 再扫一遍 - poll:同 select,但数据结构从固定位图变为动态数组,去掉了 1024 上限
- epoll:
epoll_ctl注册一次常驻内核红黑树 → 内核不扫描,就绪靠回调驱动 →epoll_wait只从就绪链表取数据,O(1)
Q2:为什么 select 有 1024 上限而 poll 没有?
select 用 fd_set 位图表示 fd 集合,位图大小在编译期固定为 FD_SETSIZE(默认 1024 bits)。突破需要重编译内核或应用,且即使突破也改变不了 O(n) 扫描的本质成本。
poll 用动态分配的 struct pollfd 数组,大小由调用者决定,不受内核编译常量限制。
Q3:为什么 epoll 在"连接多、活跃少"的场景下碾压另外两个?
因为它的代价和就绪 fd 数量成正比,不是总 fd 数。10000 个连接只有 5 个活跃时,select/poll 要扫 10000 次,epoll 只需处理 5 个——从 O(10000) 降到 O(5)。
Q4:如果所有连接都很活跃,epoll 还有优势吗?
优势显著缩小,但仍有一点:不需要每次调用都传递 fd 列表(epoll 红黑树常驻内核,poll 每次拷入数组)。在极高活跃度下,epoll 的回调机制本身就变成了一种开销(每个 fd 就绪都要触发一次回调)。此时两者的差距主要来自"数据结构拷贝"。
十一、选型速查

十二、与其他文档的关系
| 关注点 | 链接 |
|---|---|
| IO 多路复用在五种 IO 模型中的位置 | IO 模型总纲 |
| epoll 红黑树 + 就绪链表 + 回调的深入原理 | epoll 原理 |
| LT vs ET 触发模式详解 | epoll 原理 — LT vs ET |
| epoll 之上的并发/线程模型(Reactor/Proactor) | 并发/线程模型 |
| 系统调用开销(每次 select/poll/epoll_wait 都要陷入内核) | 系统调用原理 |
用 ss / strace 观测多路复用行为 | ss 用法 |
| IO 模型延迟量化分析 | IO 模型延迟量化 |
| 实验验证程序 | mt-io-demo 配套实验 |
一句话总结
select是"每次复印整栋楼名册,门卫挨个敲门",poll是把名册从固定页数换成任意页数但流程不变,epoll是"每个租户装了门铃,谁回来谁按铃,门卫只看响铃的名单"——三者的本质区别不在 API 形状,而在"传递成本 vs 扫描成本 vs 事件通知"三个维度的架构抉择。