I/O 多路复用完全指南 ------ select、poll、epoll
一句话总结:I/O 多路复用就是"一个保安盯多个门"------让单个进程/线程同时监控多个 I/O 通道,有数据来了再处理,没有就歇着,避免了一个通道配一个人的资源浪费。
目录
- [为什么需要 I/O 多路复用?](#为什么需要 I/O 多路复用? "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8")
- [I/O 多路复用概述](#I/O 多路复用概述 "#%E4%BA%8Cio-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8%E6%A6%82%E8%BF%B0")
- [select 方案详解](#select 方案详解 "#%E4%B8%89select-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
- [poll 方案详解](#poll 方案详解 "#%E5%9B%9Bpoll-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
- [epoll 方案详解](#epoll 方案详解 "#%E4%BA%94epoll-%E6%96%B9%E6%A1%88%E8%AF%A6%E8%A7%A3")
- 核心数据结构对比
- 知识对比表
- 总结与选型建议
一、为什么需要 I/O 多路复用?
1.1 传统方案的困境
在没有多路复用 I/O 之前,处理多个 I/O 通道只有两种方式:
方案一:阻塞 I/O + 多进程/多线程
每个进程/线程处理一路 I/O,来了连接就创建一个新进程/线程去处理。
c
// 阻塞 I/O + 多线程的经典模式(伪代码)
while (1) {
int client_fd = accept(server_fd, ...); // 阻塞等待连接
pthread_create(&tid, NULL, handle_client, &client_fd); // 每个连接一个线程
}
- 类比:就像银行柜台,来一个客户就开一个窗口。客户多了,大厅全是窗口,成本暴增。
- 缺点:客户端越多,需要创建的进程/线程越多,内存开销巨大。每个线程默认栈空间约 8MB,10000 个连接就需要 80GB 内存,显然不可行。
方案二:非阻塞 I/O + 轮询
单个进程不断遍历所有连接,尝试读写。
c
// 非阻塞 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 函数详解
函数原型:
c
#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 监控标准输入
c
#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;
}
执行结果:
erlang
$ ./select_demo
(等待 3 秒,无输入)
Timeout.
(等待 3 秒,无输入)
Timeout.
hello world ← 用户在键盘输入 "hello world" 并回车
buffer : hello world
(等待 3 秒,无输入)
Timeout.
quit ← 输入 "quit" 退出程序
buffer : quit
Bye!
⚠️ 核心注意点 :select 会修改传入的 fd_set (将就绪结果写回集合),且在 Linux 上还会修改 timeout 值(反映剩余时间),所以每次调用前必须重新复制集合和超时时间。这是 select 最容易被忽视的陷阱。
3.4 select 的底层原理
3.4.1 fd_set ------ 位图数据结构
select 使用 fd_set 类型来管理文件描述符集合,其内核定义如下:
c
typedef long int __fd_mask;
#define __NFDBITS (8 * (int) sizeof(__fd_mask)) // 64 位系统下为 64
#define __FD_SETSIZE 1024
typedef struct {
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS]; // 1024 / 64 = 16
} fd_set;
在 64 位系统中:
__fd_mask是long int,大小为 8 字节(64 位)__NFDBITS= 64(每个元素用 64 个 bit 表示 64 个文件描述符)- 数组大小 = 1024 / 64 = 16 个元素
- 总共:16 × 64 = 1024 个 bit,对应 1024 个文件描述符
位图存储原理:
- 数组不直接存储文件描述符的值,而是用某一位来表示某个文件描述符是否需要监控
- 每个元素 64 bit,对应 64 个 fd;
__fds_bits[i]的第 j bit 代表 fd = i × 64 + j - fd = 60:
60/64=0→__fds_bits[0],60%64=60→ 第 60 bit,设为 1 - fd = 64:
64/64=1→__fds_bits[1],64%64=0→ 第 0 bit,设为 1
fd 到数组位置的映射关系:
| fd | 数组索引 (fd ÷ 64) | 元素内偏移 (fd % 64) | 实际存储位置 |
|---|---|---|---|
| 0 | 0 | 0 | bits[0] 的第 0 bit |
| 5 | 0 | 5 | bits[0] 的第 5 bit |
| 60 | 0 | 60 | bits[0] 的第 60 bit |
| 63 | 0 | 63 | bits[0] 的第 63 bit |
| 64 | 1 | 0 | bits[1] 的第 0 bit ✅ |
| 100 | 1 | 36 | bits[1] 的第 36 bit |
| 127 | 1 | 63 | bits[1] 的第 63 bit |
| 128 | 2 | 0 | bits[2] 的第 0 bit |
| 1023 | 15 | 63 | bits[15] 的第 63 bit |
💡 记忆口诀 :
bits[i]的第 j bit = 文件描述符 i × 64 + j 。bit 从 0 开始编号,所以 fd = 64 落在bits[1]的 bit 0,不要被"第 64 个"的直觉误导了。
结论 :select 最大只能监控 1024 个文件描述符 ,这是由fd_set结构的硬编码决定的。
3.4.2 select 在内核中的执行流程
核心源码(精简):
c
// sys_select → core_sys_select → do_select
int do_select(int n, fd_set_bits *fds, struct timespec *end_time)
{
for (;;) {
// 遍历所有 bitmap
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
// ... 检查每位对应的 fd 是否就绪
// 如果该 fd 有 poll 函数,调用它检测状态
mask = (*f_op->poll)(f.file, wait);
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit; // 标记就绪
retval++;
}
}
if (retval || timed_out || signal_pending(current))
break;
// 没有就绪的 fd,进程休眠
poll_schedule_timeout(...);
}
return retval;
}
3.5 select 的缺点
- 每次调用都要将整个 fd_set 从用户空间拷贝到内核空间------fd 越多,拷贝开销越大
- 每次都要遍历所有文件描述符------O(n) 的时间复杂度,fd 越多越慢
- 最大支持 1024 个文件描述符------硬编码限制
- 会修改传入的 fd_set------每次调用前必须重新赋值
3.6 社会类比
类比:老式人工签到会
想象一个 1000 人的大会场。主席台要统计"谁到了"。
- select 的方式:工作人员拿着一块只能写 1024 个名字的白板,每隔 5 分钟就在会场里跑一圈,一个个对名字。下次点名还得重新写一遍白板。
- 痛点:白板容量有限(1024 限制),每次都要全场跑(遍历所有 fd),每次点名都要重写白板(重新拷贝 fd_set)。
四、poll 方案详解
一句话总结:poll 把 select 的"1024 格签到板"换成了"不限长度的签到表",但还是要从头到尾一个个检查。
4.1 poll 与 select 的核心区别
| 对比维度 | select | poll |
|---|---|---|
| 数据结构 | 位图(fd_set) | pollfd 结构体数组 |
| 文件描述符上限 | 1024(硬编码) | 无限制(受内存限制) |
| 请求与就绪分开 | ❌ 在同一集合中 | ✅ 用 events 和 revents 分开 |
| 跨平台性 | 所有主流平台(Windows、Linux、macOS) | 主流 Linux/Unix 系统(POSIX) |
poll 最大的改进是:
- 用
struct pollfd结构体管理文件描述符,不再受 1024 限制 - 将请求事件(events)和就绪事件(revents)分离,无需每次重新赋值
4.2 poll 函数详解
函数原型:
c
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
| 参数 | 含义 |
|---|---|
| fds | struct pollfd 结构体数组指针 |
| nfds | fds 数组的元素个数 |
| timeout | 超时时间(毫秒) |
struct pollfd 结构体:
c
struct pollfd {
int fd; // 文件描述符
short events; // 监控的事件(由应用程序设置)
short revents; // 返回的就绪事件(由内核设置)
};
常用事件:
| 事件 | 含义 |
|---|---|
| POLLIN | 普通数据可读 |
| POLLOUT | 普通数据可写 |
| POLLERR | 发生错误 |
| POLLRDNORM | 普通数据可读(同 POLLIN) |
| POLLHUP | 挂起(连接断开) |
返回值:
- > 0:就绪的文件描述符数量
- = 0:超时,没有文件描述符就绪
- = -1:出错,并设置 errno
4.3 应用示例
示例:使用 poll 监控标准输入
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
int main(void)
{
int ret;
struct pollfd pfds;
char buffer[64] = {0};
pfds.fd = 0; // 监控标准输入
pfds.events = POLLIN; // 监控可读事件
for (;;) {
// 监控 1 个文件描述符,超时 1 秒
ret = poll(&pfds, 1, 1000);
if (ret == -1) {
perror("[ERROR] poll(): ");
exit(EXIT_FAILURE);
} else if (ret == 0) {
printf("Timeout.\n");
} else if (ret > 0) {
if (pfds.revents & POLLIN) {
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;
}
执行结果:
erlang
$ ./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 链表结构:
c
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
c
#include <sys/epoll.h>
int epoll_create(int size);
- 功能:创建 epoll 实例,分配核心数据结构
- 参数
size:从 Linux 2.6.8 开始,size 参数被忽略,只需填一个大于 0 的值 - 返回值:成功返回 epoll 文件描述符,失败返回 -1
5.2.2 epoll_ctl
c
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 结构体:
c
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
c
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
- 功能:等待文件描述符关联的事件发生
- 参数 :
epfd:epoll 实例events:存储就绪事件的数组maxevents:最多返回的事件数timeout:超时时间(毫秒)
- 返回值 :
- > 0:就绪的文件描述符数量
- = 0:超时
- = -1:出错
5.3 应用示例
示例:使用 epoll 监控标准输入
c
#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;
}
执行结果:
erlang
$ ./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 实例的核心结构(内核源码):
c
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 对应一个):
c
struct epitem {
struct list_head rdllink; // 就绪链表链接指针
struct epitem *next; // 用于 overflow 链表
struct epoll_filefd ffd; // 文件描述符信息
struct epoll_event event; // 注册的监控事件
};
5.4.2 三大核心流程
最关键的设计------回调机制:
回调函数 ep_poll_callback 的核心逻辑:
c
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