1 什么是多路复用
多路复用(Multiplexing) 是一种让单个线程同时处理多个 I/O 通道的技术,核心是通过系统调用将 I/O 状态查询的工作交给操作系统内核,应用程序只需等待内核通知哪些通道就绪。
- 多路:指的是多个socket网络连接
- 复用:指的是复用一个线程,使用一个线程来检查多个文件描述符(Socket)的就绪状态
- 多路复用主要使用的是三种技术:select、poll、epoll。epoll是最新的,也是目前最好的多路复用
2 三种模式的对比
特性 | select | poll | epoll |
数据结构 | 固定大小位图(FD_SETSIZE) | 动态链表 | 红黑树 + 就绪链表 |
FD 数量限制 | 通常 1024 | 无限制 | 无限制 |
事件通知机制 | 遍历所有 FD | 遍历所有 pollfd | 回调函数直接加入就绪列表 |
时间复杂度 | O(n) | O(n) | O(1) |
用户 / 内核空间复制 | 每次调用复制整个 FD 集合 | 每次调用复制整个 pollfd 数组 | 仅注册时需要复制 |
触发模式 | 仅支持水平触发(LT) | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
3 select
3.1 基本概念
select 是 Linux 系统中最早的 I/O 多路复用机制,它使用位图(fd_set)来表示要监控的文件描述符集合。用户空间调用 select 方法后,通过系统调用进入内核,内核会遍历位图中设置为 1 的每一位,对每个对应的文件描述符调用其 poll 方法来检查是否有数据可读、可写或发生异常。当有事件就绪时,内核会将结果位图拷贝回用户空间,用户程序通过检查结果位图来确定哪些文件描述符发生了事件
3.2 实现机制
- 数据结构:使用固定大小的位图(bitmap)存储文件描述符集合,默认最大支持 1024 个 FD。
- 调用流程:
- 用户程序创建 fd_set 并添加关注的 FD
- 调用
select()
将 fd_set 从用户空间复制到内核空间 - 内核遍历所有 FD,检查是否有就绪事件
- 将就绪状态写入 fd_set 并复制回用户空间
- 用户程序遍历 fd_set 检查哪些 FD 就绪
- 关键缺点:
- FD 数量限制(通常 1024)
- 每次调用需复制整个 fd_set
- 遍历检查就绪状态的时间复杂度为 O (n)
3.3 select源码分析
3.3.1 关键数据结构
// include/uapi/asm-generic/select.h
typedef struct {unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} fd_set;// fs/select.c
struct fdtable {unsigned int max_fds; // 最大文件描述符数fd_set *open_fds; // 打开的文件描述符集合fd_set *close_on_exec; // 执行exec时关闭的集合fd_set *reserve_fds; // 保留集合
};
3.3.2 核心调用链
sys_select() // 用户调用的系统调用-> core_sys_select() // 核心处理函数-> do_select() // 执行select逻辑-> poll_initwait() // 初始化等待队列-> f_op->poll() // 调用每个文件描述符的poll方法-> __pollwait() // 将当前进程添加到等待队列-> check_events() // 检查事件是否就绪
3.3.3 do_select 核心逻辑
// fs/select.c
int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{// 初始化等待队列poll_initwait(&table);// 遍历所有文件描述符for (i = 0; i < n; i++) {// 获取文件描述符的poll方法const struct file_operations *f_op = file->f_op;if (f_op && f_op->poll) {// 调用驱动的poll方法,检查事件并注册回调wait_key_set(wait, in, out, exc);mask = (*f_op->poll)(file, wait);}}// 如果没有就绪事件,进入睡眠if (!retval) {retval = poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,to, slack);}// 清理并返回就绪描述符数量poll_freewait(&table);return retval;
}
3.4 优缺点
- 优点:
- 跨平台支持(几乎所有操作系统都实现了 select)
- 实现简单,易于理解
- 缺点:
- FD 数量限制(通常 1024)
- 每次调用需复制整个 FD 集合,开销大
- 遍历检查就绪状态的时间复杂度为 O (n),性能随 FD 数量增长而下降
4 poll
4.1 基本概念
poll 的机制与 select 类似,本质上没有太大差别,也是用于管理多个描述符并进行轮询,根据描述符的状态进行处理。它使用 pollfd 结构来描述文件描述符集合,相比 select,poll 没有最大文件描述符数量的限制。
4.2 实现机制
- 数据结构:使用链表代替固定大小的位图,每个节点包含 FD、关注事件和就绪事件。
- 调用流程:
- 用户程序创建 pollfd 数组并填充 FD 和关注事件
- 调用
poll()
将 pollfd 数组从用户空间复制到内核空间 - 内核遍历所有 pollfd 检查就绪状态
- 将就绪事件写入 revents 字段并复制回用户空间
- 用户程序遍历 pollfd 数组检查就绪事件
- 改进点:取消了 FD 数量限制,采用链表存储 FD 可动态扩展。
- 未解决的问题:仍需复制整个 pollfd 数组,遍历检查时间复杂度仍为 O (n)。
4.3 poll源码分析
4.3.1 关键数据结构
// include/uapi/linux/poll.h
struct pollfd {int fd; // 文件描述符short events; // 关注的事件short revents; // 就绪的事件
};
4.3.2 核心函数调用链
sys_poll() // 用户调用的系统调用-> do_sys_poll() // 核心处理函数-> poll_initwait() // 初始化等待队列-> do_poll() // 执行poll逻辑-> f_op->poll() // 调用每个文件描述符的poll方法-> __pollwait() // 将当前进程添加到等待队列
4.3.3 do_poll 核心逻辑
// fs/select.c
static int do_poll(unsigned int nfds, struct poll_list *list,struct timespec64 *end_time)
{// 遍历所有pollfdfor (;;) {struct poll_list *walk;int fdcount = 0;// 遍历每个pollfdfor (walk = list; walk != NULL; walk = walk->next) {struct pollfd * pfd, * pfd_end;pfd = walk->entries;pfd_end = pfd + walk->len;for (; pfd != pfd_end; pfd++) {// 获取文件描述符的poll方法if (file->f_op && file->f_op->poll) {// 调用驱动的poll方法mask = file->f_op->poll(file, wait);}// 检查事件是否匹配if ((mask & pfd->events) &&!test_and_set_bit(pfd->fd, bitmap))fdcount++;}}// 如果有就绪事件或超时,返回if (fdcount || timed_out || signal_pending(current))break;// 否则继续睡眠fdcount = poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,to, slack);}return fdcount;
}
4.4 优缺点
- 优点:
- 取消了 FD 数量限制
- 采用动态链表存储 FD,可扩展性更好
- 缺点:
- 仍需复制整个 pollfd 数组
- 遍历检查时间复杂度仍为 O (n),不适合大量连接场景
5 epoll
5.1 基本概念
epoll 是 Linux 内核为处理大批量文件描述符而改进的 poll,是 select/poll 的增强版本。它基于事件驱动,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中。
5.2 实现机制
- 核心数据结构:
- 红黑树:存储注册的 FD,支持 O (log n) 的插入、删除和查找操作
- 就绪链表:存储就绪的 FD,通过事件回调机制直接加入
- 三个核心系统调用:
- epoll_create:创建一个新的 epoll 实例,并返回一个文件描述符 epfd,用于在 epoll 实例上执行后续操作。
- epoll_ctl:用于向 epoll 实例中添加、修改或删除文件描述符,需要 epfd、操作类型(如 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)、要操作的文件描述符 fd 和指定感兴趣的事件类型 event 这几个参数
- epoll_wait:等待 epoll 实例中的 I/O 事件到达,它监视由 epoll_ctl 添加的文件描述符上的事件,需要 epfd 和用于存储就绪事件的数组,以及可选的超时时间。
- 关键优化:
- 使用事件回调机制,无需遍历所有 FD
- 通过内存映射技术减少用户 / 内核空间的数据复制
- 高效的 FD 管理,红黑树支持大规模连接
epoll 内核实现原理中,有红黑树用于记录用户程序注册的 epoll 事件,等待队列用于唤醒 epoll 线程,就绪队列用于记录 socket 读事件和写事件。其流程如下:
- 用户程序调用 epoll_create 函数后,在内核创建 struct eventpoll 对象,同时返回一个文件描述符给用户。
- 用户程序通过 epoll_ctl 函数添加、修改、删除 socket 事件,注册成功的 socket 事件会插入红黑树。
- 如果 epoll 就绪队列有就绪事件,用户程序调用 epoll_wait 函数会成功获取到就绪事件;如果没有就绪事件,则 epoll 线程陷入休眠。
- 当 socket 接收到数据后,通过 socket 等待队列可以唤醒休眠的 epoll 线程,并将 socket 封装成 epoll 就绪事件插入就绪队列,epoll 线程将就绪事件拷贝至用户程序。
5.3 epoll源码分析
5.3.1 关键数据结构
// include/linux/epoll.h
struct eventpoll {spinlock_t lock; // 自旋锁struct mutex mtx; // 互斥锁wait_queue_head_t wq; // 等待队列头wait_queue_head_t poll_wait; // poll方法的等待队列struct list_head rdllist; // 就绪描述符链表struct rb_root rbr; // 红黑树根节点struct epitem *ovflist; // 溢出链表
};struct epitem {struct rb_node rbn; // 红黑树节点struct list_head rdllink; // 就绪链表节点struct epoll_filefd ffd; // 文件描述符信息struct eventpoll *ep; // 所属的eventpoll对象struct list_head fllink; // 文件的epitem链表struct epoll_event event; // 用户关注的事件
};
5.3.2 核心函数调用链
// epoll_create 相关
sys_epoll_create()-> epoll_create1()-> epi = kmem_cache_alloc(epi_cache, GFP_KERNEL); // 创建epitem-> init_poll_funcptr(&epi->pt, ep_ptable_queue_proc); // 初始化poll函数指针// epoll_ctl 相关
sys_epoll_ctl()-> ep_insert() // 添加文件描述符-> ep_modify() // 修改事件-> ep_delete() // 删除文件描述符// epoll_wait 相关
sys_epoll_wait()-> ep_poll()-> ep_events_available() // 检查是否有就绪事件-> fetch_events() // 将就绪事件从rdllist复制到用户空间-> schedule_hrtimeout_range() // 没有事件时进入睡眠
5.3.3 ep_poll 核心逻辑
// epoll_create 相关
sys_epoll_create()-> epoll_create1()-> epi = kmem_cache_alloc(epi_cache, GFP_KERNEL); // 创建epitem-> init_poll_funcptr(&epi->pt, ep_ptable_queue_proc); // 初始化poll函数指针// epoll_ctl 相关
sys_epoll_ctl()-> ep_insert() // 添加文件描述符-> ep_modify() // 修改事件-> ep_delete() // 删除文件描述符// epoll_wait 相关
sys_epoll_wait()-> ep_poll()-> ep_events_available() // 检查是否有就绪事件-> fetch_events() // 将就绪事件从rdllist复制到用户空间-> schedule_hrtimeout_range() // 没有事件时进入睡眠
5.4 优缺点
- 优点:
- 无 FD 数量限制,可轻松处理数万甚至数十万个连接
- 事件驱动机制,时间复杂度 O (1),性能不会随 FD 数量增长而下降
- 使用内存映射技术减少数据复制,效率高
- 支持边缘触发(ET)模式,减少不必要的系统调用
- 缺点:
- 仅 Linux 系统支持,不跨平台
- 编程模型相对复杂,边缘触发模式需要更严谨的编程逻辑