Linux内核IO多路复用poll机制简介
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
内核IO多路复用poll机制简介
作为系统工程师,深入理解 Linux 内核中的 I/O 多路复用机制是优化高并发网络架构和文件系统驱动的基础。poll 系统调用作为承上启下的经典设计(介于传统的阻塞式 I/O 与高性能的 epoll 之间),其核心逻辑在于在内核层对一组文件描述符进行非阻塞遍历,并通过统一的等待队列机制实现无事件时的进程挂起与事件到达时的精准唤醒。
下面将基于 Linux 内核核心源码(主要位于 fs/select.c、include/linux/poll.h 中),对 poll 的底层运作机理进行深度剖析。
1. 内核 Poll 整体架构与控制流
在进入源码之前,首先需要明确 poll 在内核中的执行生命周期。其核心链路可以用如下调用图谱来抽象:
整个生命周期可以划分为四个阶段:
- 上下文构建与拷贝:将用户态的
pollfd数组拷贝至内核态,初始化动态管理链表。 - 首次轮询遍历:遍历所有被监听的 fd,依次调用每个文件对应的驱动
.poll接口。 - 挂载等待队列:若无就绪事件,将当前进程作为等待节点,挂载到所有被监听设备的底层等待队列中。
- 睡眠与唤醒:进程让出 CPU 进入睡眠;当任一设备因硬件中断或数据到达触发唤醒时,进程苏醒并进行二次遍历,最终带着结果返回用户态。
2. 核心内核数据结构
理解 poll 的源码,必须先吃透内核中专门为其设计的几个关键数据结构。它们通过巧妙的指针嵌套,在进程、驱动队列、以及返回事件之间构建了桥梁。
2.1 动态描述符链表:struct poll_list
由于用户态传入的 pollfd 数量可能非常庞大,内核为了避免一次性分配过大的连续物理内存(容易导致全局内存碎片化),采用了分块链表结构。
struct poll_list {
struct poll_list *next; // 指向下一个分块
int len; // 当前分块中 pollfd 的数量
struct pollfd entries[]; // 柔性数组,真正存放 pollfd 数据的区域
};
工程师视角:内核在处理时,会首先尝试使用栈上的一个小数组(通常为
FRONTEND_STACK_ALLOC字节,约能容纳十几个pollfd)。只有当用户传入的nfds超过这个阈值时,才会循环调用kmalloc分配多个poll_list并通过next指针串联起来。
2.2 监控容器与回调基石:struct poll_wqueues 与 poll_table
这是 poll 机制的核心包装盒。它不仅保存了与当前进程关联的上下文,还包含了一个关键的回调函数。
typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);
struct poll_table_struct {
poll_queue_proc _qproc; // 核心回调函数指针,在 poll 初始化时被赋予 __pollwait
__poll_t _key; // 关注的事件掩码(如 POLLIN | POLLOUT)
};
typedef struct poll_table_struct poll_table;
struct poll_wqueues {
poll_table pt; // 内嵌的 poll_table,会被传递给底层驱动
struct poll_table_page *table; // 存储 poll_table_entry 的物理页链表
struct task_struct *polling_task; // 指向当前正在执行 poll 的进程上下文 (current)
int triggered; // 标记是否被唤醒
int error; // 错误码记录
};
2.3 等待队列连接纽带:struct poll_table_entry
每一个被监听的文件描述符,如果当前没有就绪事件,都需要在底层驱动的等待队列中挂载一个节点。这个节点就是 poll_table_entry。
struct poll_table_entry {
struct list_head wait; // 链表节点,用于挂载到设备的 wait_queue_head_t
struct file *filp; // 被监听文件的 file 结构体指针
wait_queue_head_t *wait_address; // 指向底层驱动的等待队列头
};
3. 内核入口与环境初始化:do_sys_poll
用户态调用 poll() 后,经由系统调用陷阱进入内核的 sys_poll,并迅速移交给 do_sys_poll 进行核心逻辑处理。
do_sys_poll 源码逐行精解
int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
struct timespec64 *end_time)
{
// 1. 在栈上分配初始内存,避免小并发下的内存申请开销
alloc_front_end_aligned_buf(stack_pps, FRONTEND_STACK_ALLOC);
struct poll_wqueues table;
struct poll_list *head;
struct poll_list *walk;
int err;
if (nfds > rlimit(RLIMIT_NOFILE)) // 基础安全检查:不能超过进程打开文件上限
return -EINVAL;
// 2. 初始化 poll_wqueues 容器
poll_initwait(&table);
// 3. 将用户态数据拷贝到内核态的 poll_list 链表中
head = NULL;
walk = NULL;
unsigned int i = nfds;
while (i > 0) {
struct poll_list *pp;
unsigned int num = min_t(unsigned int, i, POLLFD_PER_PAGE);
pp = kmalloc(struct_size(pp, entries, num), GFP_KERNEL);
if (!pp) goto out_fds;
pp->next = NULL;
pp->len = num;
if (head == NULL) head = pp;
else walk->next = pp;
walk = pp;
// 从用户空间拷贝数据
if (copy_from_user(pp->entries, ufds + nfds - i, sizeof(struct pollfd) * num)) {
err = -EFAULT;
goto out_fds;
}
i -= num;
}
// 4. 进入核心轮询与休眠的主循环
for (;;) {
// 调用 do_poll 遍历所有的描述符,返回值 fdcount 代表当前就绪的 fd 数量
int fdcount = do_poll(head, &table, end_time);
if (fdcount) { // 如果有文件就绪,或者遭遇超时/信号中断,跳出大循环
err = fdcount;
break;
}
// 如果没有事件就绪,且检测到非阻塞或已超时,直接退出
if (!end_time || !end_time->tv_sec && !end_time->tv_nsec) {
err = 0;
break;
}
// 检查当前进程是否有挂起的信号需要处理(如 Ctrl+C)
if (signal_pending(current)) {
err = -EINTR;
break;
}
// 5. 让出 CPU,使进程进入可中断的睡眠状态,等待底层驱动唤醒或超时
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, end_time)) {
err = 0; // 返回 0 代表超时
break;
}
}
// 6. 清理工作:将当前进程从所有设备的等待队列中移除(解绑)
poll_freewait(&table);
// 7. 将最终的 revents 结果写回用户空间
for (walk = head; walk; walk = walk->next) {
struct pollfd *pfd = walk->entries;
struct pollfd *pfd_end = pfd + walk->len;
for (; pfd < pfd_end; pfd++, ufds++) {
if (__put_user(pfd->revents, &ufds->revents)) {
err = -EFAULT;
goto out_fds;
}
}
}
out_fds:
// 释放内核动态分配的链表内存
free_poll_list(head);
return err;
}
poll_initwait 的核心赋能
在上面的步骤 2 中,poll_initwait(&table) 完成了一个至关重要的绑定:
void poll_initwait(struct poll_wqueues *pwq)
{
// 将 poll_table 内部的函数指针 _qproc 显式指向 __pollwait 函数
init_poll_funcptr(&pwq->pt, __pollwait);
pwq->polling_task = current; // 绑定当前进程上下文
pwq->triggered = 0;
pwq->error = 0;
pwq->table = NULL;
}
这一步建立的 _qproc 钩子,是后面驱动层能够将进程顺藤摸瓜挂载到设备的秘密所在。
4. 遍历与驱动联动:do_poll 与 vfs_poll
do_poll 负责真正遍历整个 poll_list 链表。它不直接判断文件是否可读写,而是将决策权下放给 VFS(虚拟文件系统),通过文件系统的通用抽象层调用具体设备的驱动。
do_poll 源码实现
static int do_poll(struct poll_list *list, struct poll_wqueues *wait,
struct timespec64 *end_time)
{
poll_table *pt = &wait->pt;
int count = 0;
// 遍历外层的 poll_list 链表
for ( ; list ; list = list->next) {
struct pollfd *pfd = list->pfd;
struct pollfd *end = pfd + list->len;
// 遍历当前分块内的每一个 pollfd
for (; pfd != end; pfd++) {
// 1. 根据 fd 索引号,在当前进程的文件描述符表中查找对应的 struct fd
struct fd f = fdget(pfd->fd);
__poll_t mask = 0;
if (f.file) {
// 设置当前期望监控的事件掩码
pt->_key = pfd->events | POLLERR | POLLHUP;
// 2. 调用 VFS 层的 poll 包装函数,进而深入驱动内部
mask = vfs_poll(f.file, pt);
fdput(f); // 释放文件引用计数
}
// 3. 将驱动返回的实际状态与用户期望的事件进行位与过滤
mask &= pfd->events | POLLERR | POLLHUP;
if (mask) {
pfd->revents = mask; // 将实际发生的事件(如 POLLIN)写入 revents
count++; // 发现就绪文件,计数器加 1
/* * 关键优化:一旦在遍历中发现了就绪的 fd,就将 _qproc 置为空!
* 这样后续尚未遍历到的 fd 在调用 vfs_poll 时,就不会再无谓地
* 将当前进程挂载到其底层的等待队列中了,从而极大减轻了系统开销。
*/
pt->_qproc = NULL;
}
}
}
return count;
}
vfs_poll 的中介作用
vfs_poll 是标准的一线联络官,它直接读取具体文件结构体中的 file_operations 虚函数表:
static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt)
{
if (unlikely(!file->f_op->poll))
return DEFAULT_POLLMASK; // 如果驱动不支持 poll,返回默认掩码
return file->f_op->poll(file, pt);
}
5. 底层驱动的责任与 __pollwait 回调
以 Socket 网络驱动或 Pipe 管道驱动为例,任何支持 I/O 多路复用的驱动程序,其内部 .poll 实现都严格遵循两步法。以一个典型的管道驱动简化版为例:
static __poll_t inline_pipe_poll(struct file *filp, poll_table *wait)
{
__poll_t mask = 0;
struct pipe_inode_info *pipe = filp->private_data;
// 【第一步】:调用标准内核函数 poll_wait,将进程登记到驱动的等待队列头中
poll_wait(filp, &pipe->rd_wait, wait);
// 【第二步】:直接根据内存状态,计算并返回当前的就绪状态掩码
if (!pipe_empty(pipe))
mask |= POLLIN | POLLRDNORM; // 管道非空,可读
if (!pipe_full(pipe))
mask |= POLLOUT | POLLWRNORM; // 管道未满,可写
return mask;
}
poll_wait 的真面目
poll_wait 并非让进程在那里等待,它只是一个内联包装函数,负责激活我们在前面绑定的 _qproc 钩子:
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && p->_qproc && wait_address)
p->_qproc(filp, wait_address, p); // 实际调用了 __pollwait
}
挂载的实际发生地:__pollwait 源码
__pollwait 的职责是生产一个 poll_table_entry 节点,并把它塞进驱动设备的等待队列中。
static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
// 通过 container_of 宏,从小成员 pt 的指针反向推导出包裹它的大容器 poll_wqueues
struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
struct poll_table_entry *entry = poll_get_entry(pwq);
if (!entry) return;
// 增加文件引用计数,防止在等待期间文件被关闭销毁
entry->filp = get_file(filp);
entry->wait_address = wait_address;
entry->key = p->_key;
// 初始化等待队列项:绑定默认的唤醒回调函数 pollwake,并关联当前的执行进程
init_waitqueue_func_entry(&entry->wait, pollwake);
entry->wait.private = pwq->polling_task; // 绑定 polling_task (即 current)
// 将初始化好的等待节点加入到底层驱动设备的真实等待队列头中
add_wait_queue(wait_address, &entry->wait);
}
6. 精准唤醒机制:从 pollwake 到进程苏醒
如果在整个 do_poll 遍历结束后,count 依旧为 0,意味着没有任何一个描述符就绪。此时进程将由 poll_schedule_timeout 投入睡眠。
6.1 硬件中断与事件触发
当外部网络数据包到达网卡并触发 CPU 中断,或者一个进程向管道写入了数据时,驱动层会感知到状态变化,并在其内部调用标准唤醒函数:
wake_up_interruptible(&pipe->rd_wait);
该函数会沿着 pipe->rd_wait 等待队列链表,依次遍历挂载在上面的每一个等待节点,并执行节点内部注册的 entry->wait.func(即刚才在 __pollwait 中绑定的 pollwake)。
6.2 pollwake 源码解析
static int pollwake(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct poll_table_entry *entry;
// 获取 entry 指针
entry = container_of(wait, struct poll_table_entry, wait);
// 检查当前的事件类型是否是进程所关心的
if (key && !((__poll_t)key & entry->key))
return 0;
// 如果事件匹配,调用系统默认的唤醒函数将 private(即之前保存的进程)拉回就绪态
return __pollwake(wait, mode, sync, key);
}
__pollwake 最终调用 try_to_wake_up(task),将对应的进程状态从 TASK_INTERRUPTIBLE 修改为 TASK_RUNNING,并移入操作系统的 CPU 调度就绪队列中。
6.3 二次遍历与返回
进程苏醒后,由于它依然置身于 do_sys_poll 的 for(;;) 循环体内部,它会**立刻重新执行一遍 do_poll**。
在这次重新遍历中,因为引发唤醒的底层驱动已经有了数据,所以当再次执行 vfs_poll 时,驱动会返回带有正确就绪标志(如 POLLIN)的掩码。do_poll 的 count 计数器成功突破 0,大循环终止。最后,通过 poll_freewait 将进程从其他所有没有发生事件的设备队列中统统解绑,把结果拷贝回用户态,整个 poll 流程宣告结束。
7. 性能天花板分析:为何需要 epoll?
作为系统工程师,从上述源码级别的推演中可以非常直观地总结出 poll 的三个核心架构痛点:
| 痛点维度 | 源码表现形式 | 导致的性能瓶颈 |
|---|---|---|
| 状态维护缺失 | 每次调用都要执行 poll_initwait 和 poll_freewait。 | 高频重复解绑挂载:在连接数极高但活跃度极低的网络场景下,内核将大部分 CPU 耗费在频繁地对几万个队列进行无意义的 add_wait_queue 和 remove_wait_queue 操作中。 |
| 内存拷贝开销 | copy_from_user 和 __put_user 随着 nfds 线性增长。 | 用户/内核态内存颠簸:每次触发都需要将巨大的描述符数组在用户空间和内核空间之间进行二次全量拷贝。 |
| 检测算法低效 | do_poll 内部使用了双重嵌套 for 循环遍历 poll_list。 |
O
(
N
)
O(N)
O(N) 轮询开销:当监控 10 万个连接而只有一个连接产生数据时,进程苏醒后依然被迫要在 do_poll 内部把 10 万个驱动的 .poll 接口全部犁一遍,白白浪费 CPU。 |
epoll 正是针对这些源码痛点进行了针对性重构:它通过 epoll_ctl 将“等待队列维护”在内核红黑树中常驻(解决了痛点一与痛点二),并引入了独特的 ready_list 就绪链表回调机制(将状态检查从
O
(
N
)
O(N)
O(N) 降低为
O
(
1
)
O(1)
O(1),解决了痛点三),从而实现了高并发下性能的飞跃。

4020

被折叠的 条评论
为什么被折叠?



