等待队列wait_queue

好的,我们来详细解析 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_ttask_list 链表上。

三、 add_waitqueue 的工作流程

add_waitqueue 函数的原型如下:

c 复制代码
void add_waitqueue(wait_queue_head_t *q, wait_queue_t *wait);

它的工作流程可以分解为以下几个步骤:

  1. 加锁 :获取 wait_queue_head_t 中的 lock 自旋锁,确保在操作队列期间不会被其他CPU中断。
  2. 设置标志位 :将 wait_queue_t 中的 flags 设置为 WQ_FLAG_EXCLUSIVE。这表示这是一个互斥等待。当事件发生时,内核通常只会唤醒队列中第一个设置了此标志的进程。这有助于防止"惊群效应"(Thundering Herd Problem),即一个事件唤醒了所有等待的进程,但只有一个能获得资源,其他的又得回去睡眠。
  3. 添加到队列尾部 :将 wait_queue_t 结构通过其 entry 成员,添加到 wait_queue_head_ttask_list 链表的尾部
  4. 解锁 :释放 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_function

    c 复制代码
    wq_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,会发生什么?

  1. wake_up() 会从头开始遍历整个等待队列。
  2. 它会逐个调用每个 wait_queue_t 元素的 func 回调函数(即 try_to_wake_up)。
  3. 这个过程可能需要很长时间,在此期间:
    • 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;
    }
}

阶段一:触发书签

  1. if (bookmark && ...)
    • bookmark 参数必须不为 NULL。这通常在 __wake_up_common 函数中由调用者(如 wake_up)提供。
    • ++cnt > WAITQUEUE_WALK_BREAK_CNTcnt 是一个计数器,记录本次遍历已经唤醒了多少个进程。当这个数量超过一个小的阈值(如16)时,条件成立。
    • &next->entry != &wq_head->head:确保我们还没有遍历完整个队列。

阶段二:设置书签

  1. bookmark->flags = WQ_FLAG_BOOKMARK;

    • 这是最关键的一步。它将一个普通的 wait_queue_t 结构体 bookmark 标记为一个特殊的"书签"节点。在后续的遍历中,代码会检查这个标志并跳过它。
  2. list_add_tail(&bookmark->entry, &next->entry);

    • 这行代码将 bookmark 节点插入到链表中,位置正好在 next 节点的前面。
    • 效果:它在当前遍历的位置"夹"了一个书签。
  3. break;

    • 立即中断 list_for_each_entry_safe_from 循环。本次唤醒操作到此结束。

阶段三:后续唤醒(断点续传)

当下一次 wake_up() 被调用时(或者在本次唤醒的后续逻辑中),list_for_each_entry_safe_from 宏会从它上次中断的地方继续:

  1. list_for_each_entry_safe_from
    • 这个宏的 _from 版本非常重要。它告诉循环从 curr 指针指向的元素开始遍历,而不是从头开始。
  2. 跳过书签
    • 在循环的开头,会有类似 if (curr->flags & WQ_FLAG_BOOKMARK) continue; 的检查。
    • 当遍历到我们上次插入的 bookmark 节点时,由于它的 WQ_FLAG_BOOKMARK 标志被设置,循环会直接跳过它,继续处理后面的节点。
  3. 移除书签
    • 在遍历开始前,内核通常会先检查队列中是否存在 bookmark。如果存在,会先将其从队列中移除,然后再从该位置开始新的遍历。

总结与类比

bookmark 机制就像是阅读一本厚厚的书

  • 没有书签:你每次都必须从第一页开始读,直到找到你上次读到的地方,这非常耗时。
  • 使用书签
    1. 你读到第16页(WAITQUEUE_WALK_BREAK_CNT),觉得累了,就在第17页夹了一个书签bookmark)。
    2. 合上书休息break 循环)。
    3. 第二天,你直接打开书签所在的第17页list_for_each_entry_safe_from),然后扔掉书签list_del),继续从第17页往下读。

通过这种方式,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);
}

关键点

  1. 设置 WQ_FLAG_EXCLUSIVE 标志:这是"排他性"的唯一凭证。
  2. 添加到队列尾部 :排他等待的进程总是被添加到等待队列的末尾。这确保了在唤醒时,排在前面的排他等待者会被优先考虑。

二、 为什么需要"排他等待状态"?

这一切都是为了避免"惊群效应"(Thundering Herd Problem)

"惊群效应"场景

想象有100个Web服务器进程(worker)都在 accept() 系统调用上阻塞,等待同一个网络端口上的客户端连接。

  1. 事件发生:一个客户端连接请求到达。
  2. 非排他唤醒 :如果所有进程都是非排他等待,wake_up() 会唤醒所有100个进程。
  3. 激烈竞争:这100个进程被同时唤醒,它们会去争抢这个唯一的连接资源。
  4. 资源浪费 :最终只有1个进程能成功 accept() 到连接,其余99个进程发现资源已被抢占,只能再次进入睡眠状态。这个过程浪费了大量的CPU时间和系统资源。

"排他等待"如何解决这个问题?

如果这100个进程都是通过 add_wait_queue_exclusive() 进入排他等待状态:

  1. 事件发生:一个客户端连接请求到达。
  2. 排他唤醒wake_up() 函数开始遍历等待队列。
  3. 识别排他标志 :它看到队列中的进程都带有 WQ_FLAG_EXCLUSIVE 标志。
  4. 唤醒一个,然后停止wake_up() 会唤醒它遇到的第一个 排他等待进程,然后立即 break 循环,停止唤醒。
  5. 高效处理 :只有这一个幸运的进程被唤醒,它可以从容地 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 函数)只允许排在最前面的一个人进入窗口办理业务,后面的人继续排队。

"排他等待状态"是进程在进入睡眠前,与内核之间达成的一个**"君子协定"**。进程告诉内核:"我只需要一个机会,请不要把所有同伴都吵醒。" 而内核则忠实地履行了这个协定,从而极大地提升了系统在高并发场景下的性能和稳定性。

相关推荐
游戏23人生1 小时前
QT linux下 虚拟键盘使用及注意事项
linux·qt·计算机外设
AAA.建材批发刘哥1 小时前
03--C++ 类和对象中篇
linux·c语言·开发语言·c++·经验分享
softshow10261 小时前
使用 Windows 子系统 WSL 安装 Ubuntu 22.04
linux·windows·ubuntu
wadesir1 小时前
简易制作LinuxShell完全指南(深入解析原理、设计与实践步骤)
linux·运维·服务器
水天需0102 小时前
HISTFILE 介绍
linux
CreasyChan3 小时前
VirtualBox 安装 CentOS 7.2
linux·运维·centos
AAA.建材批发刘哥3 小时前
04--C++ 类和对象下篇
linux·c++·经验分享·青少年编程
杰克崔3 小时前
glibc社区提问
linux·运维·服务器·车载系统
山上三树3 小时前
MMU与页表
linux·嵌入式硬件
yueguangni4 小时前
centos7虚拟机nat模式连接不上xshell方法分享
linux·运维·服务器