好的,我们来详细解析 add_waitqueue 这个函数。
核心结论先行
add_waitqueue 是Linux内核中一个非常基础且核心的函数,它的意思是:将一个进程(通过其 wait_queue_t 结构体)添加到一个等待队列(wait_queue_head_t)中,使该进程进入睡眠状态,等待某个特定事件的发生。
简单来说,它就是实现**"进程等待"**这一功能的关键入口。
一、 为什么需要 add_waitqueue?
在操作系统中,进程经常需要等待某些资源或事件。例如:
- 一个读文件的进程需要等待磁盘I/O完成。
- 一个网络服务器进程需要等待客户端的连接请求。
- 一个进程需要等待另一个进程释放它持有的锁。
如果一个进程需要等待,但它不主动放弃CPU,就会进入**"忙等待"(Busy Waiting)**状态,即不断地检查条件是否满足。这会浪费大量的CPU资源。
add_waitqueue 提供了一种高效的解决方案:让进程主动放弃CPU,进入睡眠状态,直到它所等待的事件发生时,再由其他代码将它唤醒。
二、 关键数据结构
要理解 add_waitqueue,必须先了解它操作的两个核心数据结构。
1. wait_queue_head_t (等待队列头)
这是一个队列的"管理者"或"句柄"。它代表了一个特定的等待事件。任何想等待这个事件的进程,都必须把自己加入到这个队列头所管理的队列中。
c
// 简化的定义
struct wait_queue_head {
spinlock_t lock; // 保护队列的自旋锁
struct list_head task_list; // 等待队列的链表头
};
typedef struct wait_queue_head wait_queue_head_t;
lock:因为可能有多个CPU上的进程同时尝试加入或离开队列,所以必须有锁来保证操作的原子性。task_list:这是一个双向链表,用来链接所有等待在这个队列上的进程。
2. wait_queue_t (等待队列元素)
这代表了一个正在等待的进程。每个想要等待的进程都必须创建一个 wait_queue_t 结构,并将其与自己关联起来。
c
// 简化的定义
struct wait_queue_entry {
unsigned int flags;
void *private; // 通常指向等待的进程 task_struct
wait_queue_func_t func; // 唤醒时要调用的回调函数
struct list_head entry; // 用于链接到 wait_queue_head_t 的链表节点
};
typedef struct wait_queue_entry wait_queue_t;
private:这是一个通用指针,几乎总是被设置为指向当前进程的task_struct。func:这是一个回调函数。当事件发生,需要唤醒等待者时,内核会调用这个函数。最常见的函数是autoremove_wake_function,它会唤醒进程并将其从等待队列中自动移除。entry:这是链表节点,通过它,wait_queue_t才能被挂接到wait_queue_head_t的task_list链表上。
三、 add_waitqueue 的工作流程
add_waitqueue 函数的原型如下:
c
void add_waitqueue(wait_queue_head_t *q, wait_queue_t *wait);
它的工作流程可以分解为以下几个步骤:
- 加锁 :获取
wait_queue_head_t中的lock自旋锁,确保在操作队列期间不会被其他CPU中断。 - 设置标志位 :将
wait_queue_t中的flags设置为WQ_FLAG_EXCLUSIVE。这表示这是一个互斥等待。当事件发生时,内核通常只会唤醒队列中第一个设置了此标志的进程。这有助于防止"惊群效应"(Thundering Herd Problem),即一个事件唤醒了所有等待的进程,但只有一个能获得资源,其他的又得回去睡眠。 - 添加到队列尾部 :将
wait_queue_t结构通过其entry成员,添加到wait_queue_head_t的task_list链表的尾部。 - 解锁 :释放
lock自旋锁。
注意 :add_waitqueue 只负责将进程的"名片"(wait_queue_t)放入队列,它本身并不会让进程睡眠。 让进程睡眠是在调用 add_waitqueue 之后,由调用者自己完成的。
四、 一个完整的等待-唤醒场景示例
下面是一个典型的、简化的内核代码模式,展示了 add_waitqueue 如何与其他函数协同工作。
场景:一个进程正在等待一个设备准备好。
c
// 1. 定义一个等待队列头,代表"设备准备好"这个事件
wait_queue_head_t my_device_waitq;
init_waitqueue_head(&my_device_waitq);
// 等待者进程的代码
void my_process_waiting()
{
// 2. 定义并初始化一个等待队列元素
DEFINE_WAIT(wait); // 这是一个宏,会自动创建并初始化 wait_queue_t
// 3. 循环检查条件,这是一个标准模式
while (my_device_is_not_ready()) {
// 4. 将自己添加到等待队列中
add_wait_queue(&my_device_waitq, &wait);
// 5. 将进程状态设置为 TASK_INTERRUPTIBLE (可中断睡眠)
// 如果设置为 TASK_UNINTERRUPTIBLE,则不可被信号中断
set_current_state(TASK_INTERRUPTIBLE);
// 6. 再次检查条件。这是一个防止"竞态条件"的关键步骤。
// 因为在 add_wait_queue 和 schedule() 之间,设备可能已经准备好了。
if (my_device_is_not_ready()) {
// 7. 放弃CPU,进入睡眠状态
schedule();
}
// 8. 被唤醒后,将自己从等待队列中移除
remove_wait_queue(&my_device_waitq, &wait);
// 9. 检查是否是被信号唤醒(例如用户按了Ctrl+C)
if (signal_pending(current)) {
// 如果是被信号中断,则处理错误并退出
return -ERESTARTSYS;
}
}
// 10. 设备已经准备好,继续执行...
printk("Device is ready!\n");
}
// 唤醒者的代码 (例如,设备驱动程序的中断处理函数)
void my_device_interrupt_handler()
{
// ... 处理硬件中断 ...
// 设备现在准备好了,唤醒等待的进程
wake_up(&my_device_waitq);
}
-
在使用DEFINE_WAIT(wait)/init_waitqueue_entry(&wait, current)中,都使用初始化了wq_entry->func为
default_wake_functioncwq_entry->func = default_wake_function; -
在使用wake_up(&waitq_head)后流程如下
c
/* Convenience macros for the sake of wake_up(): */
#define TASK_NORMAL (TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE)
#define wake_up(x) __wake_up(x, TASK_NORMAL, 1, NULL)
void __wake_up(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, void *key)
{
__wake_up_common_lock(wq_head, mode, nr_exclusive, 0, key);
}
static void __wake_up_common_lock(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
unsigned long flags;
wait_queue_entry_t bookmark;
bookmark.flags = 0;
bookmark.private = NULL;
bookmark.func = NULL;
INIT_LIST_HEAD(&bookmark.entry);
do {
spin_lock_irqsave(&wq_head->lock, flags);
nr_exclusive = __wake_up_common(wq_head, mode, nr_exclusive,
wake_flags, key, &bookmark);
spin_unlock_irqrestore(&wq_head->lock, flags);
} while (bookmark.flags & WQ_FLAG_BOOKMARK);
}
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key,
wait_queue_entry_t *bookmark)
{
wait_queue_entry_t *curr, *next;
int cnt = 0;
lockdep_assert_held(&wq_head->lock);
if (bookmark && (bookmark->flags & WQ_FLAG_BOOKMARK)) {
curr = list_next_entry(bookmark, entry);
list_del(&bookmark->entry);
bookmark->flags = 0;
} else
curr = list_first_entry(&wq_head->head, wait_queue_entry_t, entry);
if (&curr->entry == &wq_head->head)
return nr_exclusive;
list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
unsigned flags = curr->flags;
int ret;
if (flags & WQ_FLAG_BOOKMARK)
continue;
ret = curr->func(curr, mode, wake_flags, key); //default_wake_function
if (ret < 0)
break;
if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
if (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) &&
(&next->entry != &wq_head->head)) {
bookmark->flags = WQ_FLAG_BOOKMARK;
list_add_tail(&bookmark->entry, &next->entry);
break;
}
}
return nr_exclusive;
}
int default_wake_function(wait_queue_entry_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
总结与类比
add_waitqueue 的过程可以比作在餐厅排队等位。
wait_queue_head_t:是餐厅的"等位名单"。wait_queue_t:是你填写的**"等位号牌"**,上面有你的信息(private指向task_struct)。add_waitqueue():是你把号牌交给服务员,服务员把它放到名单的末尾。set_current_state():是你告诉服务员你现在要去旁边的沙发上休息(进入睡眠状态)。schedule():是你闭上眼睛开始休息,不再关注叫号。wake_up():是服务员叫到你的号,把你从睡眠中唤醒。remove_wait_queue():是你听到叫号后,从名单上取回自己的号牌,准备去就餐。
bookmark(书签)
核心结论先行
bookmark(书签)在这段代码中的意思是:在遍历一个非常长的等待队列时,为了避免单次唤醒操作耗时过长,而设置的一个"断点"。
当等待队列中的进程数量超过一个阈值(WAITQUEUE_WALK_BREAK_CNT,通常是16)时,唤醒操作会主动中断 当前的遍历,并将这个bookmark插入到队列中。当下一次唤醒操作发生时,内核会从这个bookmark的位置继续遍历,而不是从头开始。
这是一种**"分而治之"**的策略,用于处理可能包含成千上万个等待进程的极端场景。
一、 为什么需要 bookmark?
想象一个场景:一个热门的网络服务器套接字(socket)上,有成千上万个客户端进程在 accept() 调用中阻塞,等待新的连接。
当一个新的连接到达时,内核会调用 wake_up() 来唤醒等待队列上的进程。
如果没有 bookmark,会发生什么?
wake_up()会从头开始遍历整个等待队列。- 它会逐个调用每个
wait_queue_t元素的func回调函数(即try_to_wake_up)。 - 这个过程可能需要很长时间,在此期间:
- CPU被长时间占用:唤醒操作本身变成了一个耗时的任务。
- 等待队列锁被长时间持有 :在遍历期间,
wait_queue_head_t的自旋锁一直被持有。这会阻塞其他CPU上试图添加或移除等待者的操作,可能成为系统的性能瓶颈。
对于这种"惊群效应"(Thundering Herd)的场景,一次性唤醒所有等待者是低效且不必要的(通常只有一个进程能成功 accept 连接)。bookmark 机制就是为了缓解这个问题。
二、 bookmark 的工作流程
我们来逐行分析这段代码:
c
// curr: 当前遍历到的元素
// next: 下一个要遍历的元素
// wq_head: 等待队列头
// entry: 链表节点成员名
list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
// ... 省略部分代码 ...
// 检查是否需要设置书签
if (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) &&
(&next->entry != &wq_head->head)) {
// 1. 标记这是一个书签
bookmark->flags = WQ_FLAG_BOOKMARK;
// 2. 将书签插入到下一个元素之前
list_add_tail(&bookmark->entry, &next->entry);
// 3. 中断遍历
break;
}
}
阶段一:触发书签
if (bookmark && ...):bookmark参数必须不为NULL。这通常在__wake_up_common函数中由调用者(如wake_up)提供。++cnt > WAITQUEUE_WALK_BREAK_CNT:cnt是一个计数器,记录本次遍历已经唤醒了多少个进程。当这个数量超过一个小的阈值(如16)时,条件成立。&next->entry != &wq_head->head:确保我们还没有遍历完整个队列。
阶段二:设置书签
-
bookmark->flags = WQ_FLAG_BOOKMARK;:- 这是最关键的一步。它将一个普通的
wait_queue_t结构体bookmark标记为一个特殊的"书签"节点。在后续的遍历中,代码会检查这个标志并跳过它。
- 这是最关键的一步。它将一个普通的
-
list_add_tail(&bookmark->entry, &next->entry);:- 这行代码将
bookmark节点插入到链表中,位置正好在next节点的前面。 - 效果:它在当前遍历的位置"夹"了一个书签。
- 这行代码将
-
break;:- 立即中断
list_for_each_entry_safe_from循环。本次唤醒操作到此结束。
- 立即中断
阶段三:后续唤醒(断点续传)
当下一次 wake_up() 被调用时(或者在本次唤醒的后续逻辑中),list_for_each_entry_safe_from 宏会从它上次中断的地方继续:
list_for_each_entry_safe_from:- 这个宏的
_from版本非常重要。它告诉循环从curr指针指向的元素开始遍历,而不是从头开始。
- 这个宏的
- 跳过书签 :
- 在循环的开头,会有类似
if (curr->flags & WQ_FLAG_BOOKMARK) continue;的检查。 - 当遍历到我们上次插入的
bookmark节点时,由于它的WQ_FLAG_BOOKMARK标志被设置,循环会直接跳过它,继续处理后面的节点。
- 在循环的开头,会有类似
- 移除书签 :
- 在遍历开始前,内核通常会先检查队列中是否存在
bookmark。如果存在,会先将其从队列中移除,然后再从该位置开始新的遍历。
- 在遍历开始前,内核通常会先检查队列中是否存在
总结与类比
bookmark 机制就像是阅读一本厚厚的书:
- 没有书签:你每次都必须从第一页开始读,直到找到你上次读到的地方,这非常耗时。
- 使用书签 :
- 你读到第16页(
WAITQUEUE_WALK_BREAK_CNT),觉得累了,就在第17页夹了一个书签 (bookmark)。 - 你合上书休息 (
break循环)。 - 第二天,你直接打开书签所在的第17页 (
list_for_each_entry_safe_from),然后扔掉书签 (list_del),继续从第17页往下读。
- 你读到第16页(
通过这种方式,bookmark 将一个潜在的长时间、高开销的唤醒操作,分解成了多个小的、可管理的步骤,从而显著提高了系统在高负载下的响应性和吞吐量。
排他等待
核心结论先行
一个"排他等待状态的进程",是指一个进程在调用 add_wait_queue_exclusive() 函数将自己加入等待队列时,通过一个特殊的标志位(WQ_FLAG_EXCLUSIVE)向内核声明了自己的"排他性"。
这个声明的含义是:"当我所等待的事件发生时,内核只需要唤醒我一个就足够了。请不要唤醒其他也声明了排他性的进程。"
一、 如何进入"排他等待状态"?
一个进程并不是天生就有"排他等待"这个属性的。它是通过调用特定的函数来主动申请的。
- 非排他等待 :通过
add_wait_queue()函数加入队列。 - 排他等待 :通过
add_wait_queue_exclusive()函数加入队列。
这两个函数的核心区别在于,add_wait_queue_exclusive() 在将进程的 wait_queue_t 结构加入队列时,会为其设置一个标志位:
c
// 内核源码简化版
void add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait)
{
unsigned long flags;
wait->flags |= WQ_FLAG_EXCLUSIVE; // <--- 关键在这里!
spin_lock_irqsave(&q->lock, flags);
__add_wait_queue_tail(q, wait); // 注意:排他等待者被加到队列尾部
spin_unlock_irqrestore(&q->lock, flags);
}
关键点:
- 设置
WQ_FLAG_EXCLUSIVE标志:这是"排他性"的唯一凭证。 - 添加到队列尾部 :排他等待的进程总是被添加到等待队列的末尾。这确保了在唤醒时,排在前面的排他等待者会被优先考虑。
二、 为什么需要"排他等待状态"?
这一切都是为了避免"惊群效应"(Thundering Herd Problem)。
"惊群效应"场景 :
想象有100个Web服务器进程(worker)都在 accept() 系统调用上阻塞,等待同一个网络端口上的客户端连接。
- 事件发生:一个客户端连接请求到达。
- 非排他唤醒 :如果所有进程都是非排他等待,
wake_up()会唤醒所有100个进程。 - 激烈竞争:这100个进程被同时唤醒,它们会去争抢这个唯一的连接资源。
- 资源浪费 :最终只有1个进程能成功
accept()到连接,其余99个进程发现资源已被抢占,只能再次进入睡眠状态。这个过程浪费了大量的CPU时间和系统资源。
"排他等待"如何解决这个问题?
如果这100个进程都是通过 add_wait_queue_exclusive() 进入排他等待状态:
- 事件发生:一个客户端连接请求到达。
- 排他唤醒 :
wake_up()函数开始遍历等待队列。 - 识别排他标志 :它看到队列中的进程都带有
WQ_FLAG_EXCLUSIVE标志。 - 唤醒一个,然后停止 :
wake_up()会唤醒它遇到的第一个 排他等待进程,然后立即break循环,停止唤醒。 - 高效处理 :只有这一个幸运的进程被唤醒,它可以从容地
accept()连接,没有任何竞争。其余99个进程继续在队列中安静地睡眠,等待下一个连接。
三、 唤醒逻辑的精确解析
现在我们回到 __wake_up_common 的核心唤醒循环,看看它是如何处理这两种状态的:
c
// 伪代码,简化了内核逻辑
void __wake_up_common(...)
{
// ...
int nr_exclusive = wq->nr_exclusive; // 通常是1
list_for_each_entry_safe(curr, next, &wq_head->head, entry) {
// 1. 如果是排他等待者
if (curr->flags & WQ_FLAG_EXCLUSIVE) {
// 2. 如果我们还需要唤醒排他等待者
if (nr_exclusive > 0) {
// 3. 唤醒它
curr->func(curr, ...); // 调用 try_to_wake_up
// 4. 已经唤醒了一个,计数器减1
nr_exclusive--;
}
// 5. 无论是否唤醒,只要遇到排他等待者,就停止遍历!
break;
}
// 6. 如果是非排他等待者,则直接唤醒
else {
curr->func(curr, ...);
}
}
}
这个逻辑清晰地展示了:
- 对于非排他等待者 (没有
WQ_FLAG_EXCLUSIVE标志),wake_up会逐个唤醒它们,直到遇到第一个排他等待者。 - 对于排他等待者 ,
wake_up会唤醒一个,然后立即break,不再继续唤醒其他任何进程(无论是排他的还是非排他的)。
总结与类比
- 非排他等待 :像在一个开放式的广场 上等待。一旦有消息,大喇叭会通知所有人。所有人都听到了,然后去抢一个东西。
- 排他等待 :像在一个银行排队 。大家都在排队(
WQ_FLAG_EXCLUSIVE标志),保安(wake_up函数)只允许排在最前面的一个人进入窗口办理业务,后面的人继续排队。
"排他等待状态"是进程在进入睡眠前,与内核之间达成的一个**"君子协定"**。进程告诉内核:"我只需要一个机会,请不要把所有同伴都吵醒。" 而内核则忠实地履行了这个协定,从而极大地提升了系统在高并发场景下的性能和稳定性。