目录
-
- [Linux 等待队列](#Linux 等待队列)
-
- 背景和动机
- 核心结构和概念
- 进程状态和等待队列的交互
-
- 使用等待队列时任务所处的状态
- [Linux 任务状态总览与进入原因](#Linux 任务状态总览与进入原因)
- [主要 API 和数据结构](#主要 API 和数据结构)
- 示例说明
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),对于独占节点只唤醒一个,避免惊群效应(独占节点如何处理)
手工控制流程
宏的封装隐藏了细节,但是驱动开发中有时需要手工控制,核心步骤是:
-
入队并设置状态:
prepare_to_wait(&wq, &entry, TASK_INTERRUPTIBLE)将当前的任务封装为节点加入队列,并设置进程状态 -
检查条件并调度: 在循环中检查条件,不满足则调用
schedule让出cpu -
清理:条件满足或者被唤醒后,
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);