目录
- Linux 等待队列
- 背景和动机
- 核心结构和概念
- 什么是等待队列
- 其余的任务阻塞方法
- 等待队列和 select/poll/epoll 的关系
- 进程状态和等待队列的交互
- 使用等待队列时任务所处的状态
- 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,读函数中:
if(wait_queue_interruptible(dev->rq,dev->count>0))return-ERESTARTSYS;当写操作向缓冲区放入数据后,执行:
wake_up_interruptible(&dev->rq);此模式简洁且不容易出错,适合多数场景,需要注意 condition 的求值要在宏内部完成,且每次唤醒后重新检查,确保即使被误唤醒也不会返回错误结果
手工控制的适用场景
当需要在睡眠前执行额外操作(释放锁,切换状态),或需要精细控制超时信号和信号处理的时候,手工流程更加灵活:
DEFINE_WAIT(entry);prepare_to_wait(&dev->rq,&entry,TASK_INTERRUPTIBLE);if(dev->count==0)schedule();finish_wait(&dev->rq,&entry);总体示意
Linux内核中的Waitqueue(等待队列) 是一种任务等待和唤醒机制,允许多个任务在同一队列上等待特定条件,
当等待条件不满足时,任务将等待节点注册到队列中,并根据等待方式进入TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE等睡眠状态,通过schedule()让出 CPU
当其他执行上下文改变等待条件并调用wake_up()等接口时,内核遍历等待队列,调用相应等待节点的唤醒回调。对于普通任务,默认唤醒路径通过调度器的唤醒机制,使匹配的睡眠任务转为TASK_RUNNING,进入可运行状态
被唤醒的任务不会立即执行,而是等待调度器分配CPU。任务恢复执行后重新检查等待条件,若条件仍不满足,则可能再次进入睡眠;若条件满足,则退出等待过程,继续执行后续逻辑