select、poll、epoll的IO多路复用底层原理究竟是怎样的?
摘要:IO多路复用是解决高并发IO的核心技术(Java NIO的Selector、Redis、Nginx等都基于它实现),select、poll、epoll是Linux系统下三种主流的多路复用机制,本质都是让一个线程管理多个IO文件描述符(FD)
IO多路复用是解决高并发IO的核心技术(Java NIO的Selector、Redis、Nginx等都基于它实现),select、poll、epoll是Linux系统下三种主流的多路复用机制,本质都是让一个线程管理多个IO文件描述符(FD),仅在FD就绪(可读/可写/异常)时才进行IO操作,避免了BIO的“一连接一线程”和纯非阻塞IO的“空轮询”问题。
本文会从核心概念、底层原理、对比分析、使用场景四个维度,由浅入深讲清楚这三种机制的本质区别和实现逻辑。
一、前置基础:文件描述符(FD)与IO就绪
1. 文件描述符(FD)
Linux中“一切皆文件”,网络套接字(Socket)、磁盘文件、管道等都对应一个整数型的文件描述符(File Descriptor),内核通过FD管理所有IO资源。
标准输入:FD=0
标准输出:FD=1
标准错误:FD=2
新创建的Socket/文件:从3开始递增
2. IO就绪
IO操作分为两个阶段(以读Socket为例):
数据准备阶段:内核从网卡/磁盘读取数据到内核缓冲区(耗时,可能阻塞);
数据拷贝阶段:内核将数据从内核缓冲区拷贝到用户缓冲区(耗时短)。
“IO就绪”指第一阶段完成,此时FD可无阻塞地进行读写操作。IO多路复用的核心就是“监听多个FD的就绪状态”。
二、select:第一代多路复用
1. 底层原理
select是最早的多路复用接口(POSIX标准),核心逻辑是内核遍历用户传入的FD集合,检查是否就绪。
执行流程
graph TD
A[用户进程] -->|1 传入fd_set(FD集合)+ 超时时间| B[内核];
B -->|2 遍历fd_set中的所有FD,检查是否就绪| C{FD就绪?};
C -->|否| D[将用户进程挂起,等待数据准备];
C -->|是| E[标记就绪的FD,返回就绪数量];
D -->|数据准备完成| E;
E -->|3 返回就绪FD数量+标记就绪FD| A;
A -->|4 遍历所有FD,排查出就绪的FD进行IO操作| F[处理数据];
核心细节
FD集合存储:用户进程通过fd_set结构体(位图)传入要监听的FD,内核修改该位图标记就绪的FD;
FD数量限制:fd_set的大小固定(默认1024),因此select最多监听1024个FD;
内核遍历逻辑:每次调用select,内核必须遍历所有传入的FD(即使只有1个就绪),时间复杂度$O(n)$;
数据拷贝:每次调用select,用户需重新传入FD集合(内核会清空就绪标记),存在重复的用户态→内核态拷贝。
2. 核心API(C语言)
#include <sys/select.h>
// 参数:最大FD+1、读FD集合、写FD集合、异常FD集合、超时时间
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 辅助宏:操作fd_set
FD_ZERO(fd_set *set); // 清空FD集合
FD_SET(int fd, fd_set *set); // 将FD加入集合
FD_ISSET(int fd, fd_set *set); // 检查FD是否就绪
FD_CLR(int fd, fd_set *set); // 从集合移除FD
3. 缺点
FD数量限制:默认最大1024(可修改内核参数,但不推荐);
性能低效:内核每次需遍历所有FD,FD越多,遍历耗时越长;
重复拷贝:每次调用select都要重新传入FD集合,且就绪FD需用户进程自己遍历排查;
内核/用户态切换成本:每次调用都要切换,且无就绪FD时进程会被挂起。
三、poll:select的改进版
1. 底层原理
poll解决了select的“FD数量限制”问题,核心逻辑与select一致,但存储FD的结构不同。
