Linux 内核学习(19) --- linux waitqueue

目录

Linux 等待队列

背景和动机

操作系统内核中,进程经常因为某种资源或者事件(设备 I/O 完成,数据到达,锁释放) 而无法继续执行,如果采用忙等的设计(busy-looping) 方式,不仅浪费 CPU 时间片,还是导致系统整体的吞吐量下降,更合理的做法时让进程主动放弃 CPU 进入休眠状态,待等待条件满足时再由内核唤醒

Linux 内核为此提供了等待队列机制(wait queue),用于管理所有特定时间的等待的进程

等待队列在驱动中非常常见,例如:当用户进程通过 read() 系统调用从设备读取数据,而设备尚未准备好数据,驱动程序可以将此进程挂入等待队列并调度出去,待中断处理程序接收到数据后,再唤醒对列中的进程继续执行,这种机制支撑了 阻塞IO、select/poll 多路复用,异步信号通知等上层语义的

实现

核心结构和概念

什么是等待队列

等待队列本质上一个由等待队列头(wait_queue_head_t) 管理的双向链表,链表中的每个节点(wait_queue_entry_t 或者传统命名 wait_queue_t) 代表一个正在等待某件事情发生的任务

节点中封装了指向当前任务的 task_struct 的指针和一个唤醒回调的函数;当唤醒条件成立的时候,内核遍历链表,调用各个节点的回调将对应的任务设置为可运行状态

等待队列头通常嵌入在代表具体资源的对象中(比如设备结构体,socket、文件对象),使得谁使用这个自然资源一目了然

其余的任务阻塞方法

Linux 任务进入阻塞态并非只能经过等待队列,完成量(completion) 内部包含等待队列头和完成标志

信号量(semaphore) 也借助等待队列管理睡眠者,互斥锁(mutex) 的慢路径也将当前的任务挂入自身的等待队列,换言之,多数高层同步原语在底层复用了等待队列的睡眠-唤醒骨架

还存在不经过等待队列的阻塞状态:例如: TASK_UNINTERRUPTIABLE 状态可因 vfork 等待子进程 exec/exit 而进入,调度器直接处理,不依赖外部时间队列的现式入队操作。

因此更加准确的表述是:

等待队列是 Linux 实现可唤醒阻塞的主要且最通用的基础设施,不是唯一手段

等待队列和 select/poll/epoll 的关系

select/poll/epoll 的阻塞能力建立在等待队列之上,但是等待队列并非为它们而存在

三者的核心流程都包括:向被监视的文件 f_op->poll 注册一个回调节点到该文件对应的等待队列头,然后调用 schedule() 睡眠,当文件状态变化的时候,驱动或者协议栈唤醒该等待队列,回调就将就绪时间通知到复用层,差异在于: select/poll 每次调用都重新遍历并注册节点,而 epoll 通过红黑树与就绪链表在首次注册之后持续跟踪,等待队列回调仅仅将 epitem 移入就绪链表,避免重复遍历

因此,等待队列是它们实现阻塞等待的共用底层机制,但是 epoll 在此基础上做了显著优化

进程状态和等待队列的交互

使用等待队列时任务所处的状态

调用 wait_event_interruptiable() 系列宏的时候,任务在确定条件不成立后,被设置为 TASK_INTERRUPTIABLE 并调用 schedule 让出 CPU,如果使用 wait_event 则设置为 TASK_INTERRUPTIABLE

二者的区别在于睡眠期间是否响应信号:前者可被信号唤醒并返回 -ERESTARTSYS,后者不受信号干扰。通常可用于不可中断的硬件交互或者关键的内核路径,唤醒时,wake_up 系列函数遍历等待队列,通过回调将任务改回 TASK_RUNNING 并放入运行队列

Linux 任务状态总览与进入原因
状态 含义 典型进入原因
TASK_RUNNING 可运行(包含正在运行和就绪) 被唤醒、时间片轮转、新建任务
TASK_INTERRUPTIABLE 可中断睡眠 等待 I/O、等待信号量,wait_event_interuuptibale 等可被信号打断的等待
TASK_UNINTERRUPTIABLE 不可中断睡眠 等待硬件完成、关键内核路径中的短时等待,wait_event 系列
TASK_STOPPED 停止 收到 SIGSTOP、SIGSTP 等停止信号
TASK_TRACED 被跟踪 被 ptrace 调试器控制
TASK_DEAD/TASK_ZOBIE 僵尸/终止 调用 do_exit() 后等待父进程回收

等待队列直接涉及的是 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE,唤醒操作将任务从二者切回 TASK_RUNNING

主要 API 和数据结构

数据结构
名称 作用
wait_queue_head_t 等待队列头,内部包含自旋锁与链表头,代表一个可等待的事件源
wait_queue_entry_t 等待队列节点,包含task_struct 指针、唤醒函数 fun 和标志位

静态定义等待队列头使用 DRCLARE_WAIT_QURUE_HEAD(name),

动态初始化使用 init_waitqueue_head(&head)

等待队列和唤醒 API
API 语义
wait_event(wq, condition) 不可中断等待,直到 condition 为真
wait_event_interruptible(wq, condition) 可中断等待,信号到达时返回 -ERESTARTSYS
wait_event_timeout(wq, conditon, timeout) 带超时的不可中断等待
wait_event_interruptible_timeout(wq, conditon, timeout) 带超时的可中断等待
wake_up(&wq) 唤醒队列上所有的非独占等待者(含 TASK_UNINTERRUPTIBLE)
wake_up_interruptible(&wq) 仅仅唤醒 TASK_INTERRUPTIBLE 等待者
wake_up_all/wake_up_interruptible_all 忽略独占标志,唤醒全部等待者

wake_up 和 wake_up_interruptible 均会处理独占节点(WQ_FLAG_EXCLUSIVE),对于独占节点只唤醒一个,避免惊群效应(独占节点如何处理)

手工控制流程

宏的封装隐藏了细节,但是驱动开发中有时需要手工控制,核心步骤是:

  1. 入队并设置状态:prepare_to_wait(&wq, &entry, TASK_INTERRUPTIBLE) 将当前的任务封装为节点加入队列,并设置进程状态

  2. 检查条件并调度: 在循环中检查条件,不满足则调用 schedule 让出 cpu

  3. 清理:条件满足或者被唤醒后,finish_wait(&wq, &entry) 将节点移出队列并恢复状态

示例说明

使用宏接口的典型方式

假设一个字符设备驱动需要让读操作阻塞直到缓冲区非空,驱动结构体嵌入 wait_queue_head_t rq,读函数中:

c 复制代码
if(wait_queue_interruptible(dev->rq, dev->count > 0))
    return -ERESTARTSYS;

当写操作向缓冲区放入数据后,执行:

c 复制代码
wake_up_interruptible(&dev->rq);

此模式简洁且不容易出错,适合多数场景,需要注意 condition 的求值要在宏内部完成,且每次唤醒后重新检查,确保即使被误唤醒也不会返回错误结果

手工控制的适用场景

当需要在睡眠前执行额外操作(释放锁,切换状态),或需要精细控制超时信号和信号处理的时候,手工流程更加灵活:

c 复制代码
DEFINE_WAIT(entry);

prepare_to_wait(&dev->rq, &entry, TASK_INTERRUPTIBLE);

if(dev->count == 0)
    schedule();

finish_wait(&dev->rq, &entry);
相关推荐
junwua1 小时前
qnx hypervisor 的中文版
linux·运维·服务器·自动驾驶
m4Rk_1 小时前
【论文阅读】Agent记忆机制(111):FEB-30——别再只用问答准确率评估 Agent Memory
论文阅读·人工智能·学习·开源·github
凉、介1 小时前
深入理解 ARMv7-M | 异常/中断处理
笔记·学习·嵌入式·arm
凉、介1 小时前
深入理解 ARMv7-M|处理器模式与寄存器
笔记·学习·嵌入式·arm
by2099910 小时前
Linux 基础指令与文件操作
linux·1024程序员节
宝绿特塑业再生科技10 小时前
改性HDPE再生颗粒在户外家具挤出发泡板材中的色差控制与高品质价值重构
经验分享·科技·学习·生活·1024程序员节·户外家具
Escalating_xu11 小时前
【Linux 网络编程】手写序列化与反序列化:从 x op y 到能校验的文本协议
linux·运维·网络
todoitbo12 小时前
在 Ubuntu ARM64 上运行 Q1 V3.0 MuJoCo 模型:加载、步进和单执行器响应
linux·运维·ubuntu·ai·机器人·仿真教程·启元
AR-26710-13 小时前
Linux Day26——传输层与三次握手
linux·运维