3、互斥锁(Mutex)原理与实现

互斥锁(Mutex)是内核中最基础的睡眠锁之一,用于保护临界区,确保同一时间只有一个任务可以持有锁。与自旋锁不同,当互斥锁被占用时,等待的任务会进入睡眠状态,从而避免忙等浪费 CPU 资源。本章深入剖析互斥锁的睡眠等待机制、乐观自旋优化以及其与信号量的核心区别。

3.1 互斥锁的睡眠等待机制

当任务尝试获取一个已被持有的互斥锁时,它不会原地自旋,而是将自己放入一个等待队列,并调用调度器让出 CPU。这个过程称为“睡眠等待”。其核心流程如下:

  1. 快速路径(Fast Path):尝试原子性地将锁的计数器从 1(未锁定)减为 0(锁定)。如果成功,则直接获得锁。
  2. 中速路径(Mid Path / MCS 自旋):如果快速路径失败(锁已被持有),且锁的持有者正在运行(没有睡眠),则尝试在 MCS 锁上短暂自旋,期望持有者很快释放锁。这是乐观自旋优化的基础。
  3. 慢速路径(Slow Path):如果中速路径也失败(持有者已睡眠或自旋超时),任务会创建一个 mutex_waiter 结构体,将其加入等待队列尾部,然后调用 schedule() 进入睡眠状态。
  4. 唤醒路径:当持有者释放锁时,它会从等待队列头部唤醒一个等待者。被唤醒的任务会重新尝试获取锁。
关键数据结构: struct mutex 的核心字段是 atomic_long_t owner,它编码了锁的状态和持有者信息。低 3 位用作标志位(如是否处于等待状态),高位指向持有者任务的 task_struct。
性能要点: 睡眠等待避免了 CPU 空转,但代价是两次上下文切换(等待者睡眠 + 唤醒)。因此,互斥锁适用于临界区较长或锁竞争不激烈的场景。
互斥锁睡眠等待流程(慢速路径) 尝试获取锁 快速路径成功? 获得锁,进入临界区 中速路径(乐观自旋) 自旋成功? 慢速路径:加入等待队列 调用 schedule() 睡眠 被唤醒,重新竞争锁 是 否 否 是

3.2 Mutex 的乐观自旋优化

为了减少不必要的睡眠-唤醒开销,Linux 内核在互斥锁中引入了 乐观自旋(Optimistic Spinning) 机制。其核心思想是:当锁被持有时,如果持有者当前正在 CPU 上运行(而非睡眠),那么等待者可以短暂地自旋等待,期望持有者很快释放锁。这避免了两次昂贵的上下文切换。

乐观自旋的实现依赖于 MCS(Mellor-Crummey and Scott)锁机制,它为每个等待者提供一个本地自旋节点,避免了传统自旋锁的“乒乓”缓存行 bouncing 问题。

特性 传统自旋锁 Mutex 乐观自旋
等待方式 所有等待者共享一个锁变量,持续原子操作 每个等待者拥有本地节点,自旋在本地变量上
缓存行竞争 高(所有 CPU 争抢同一个缓存行) 低(每个 CPU 自旋在自己的缓存行上)
适用场景 临界区极短,持有者不会睡眠 临界区较短,持有者可能在运行中
上下文切换 无(忙等) 可能避免(自旋成功则无切换)
注意事项: 乐观自旋并非总是有益。如果持有者很快睡眠(如发生缺页中断),等待者的自旋将浪费 CPU 时间。内核通过检查持有者的 task_struct 状态(TASK_RUNNING)来决定是否自旋。
乐观自旋优化:MCS 锁 vs 传统自旋锁 传统自旋锁(高竞争) CPU0 CPU1 CPU2 … 共享锁变量(缓存行 bouncing) MCS 锁(乐观自旋) CPU0 CPU1 CPU2 … 本地节点 本地节点 本地节点 MCS 锁通过链表将等待者串联,每个 CPU 只自旋在自己的本地节点上,避免全局缓存行竞争 性能对比:临界区长度与锁开销 传统自旋锁:短临界区 乐观自旋:中短临界区 睡眠锁:长临界区 MCS 锁:避免 bouncing

3.3 Mutex 与信号量的区别

互斥锁和信号量(Semaphore)都是内核中用于同步的睡眠锁,但它们在语义、使用场景和行为上存在本质区别。信号量本质上是一个计数器,允许多个任务同时访问受保护的资源(计数信号量),而互斥锁是二值信号量的特例,强调“所有权”和“递归”行为。

对比维度 互斥锁(Mutex) 信号量(Semaphore)
核心语义 互斥访问,一次只允许一个任务持有 计数同步,允许多个任务(计数为 N)
所有权 有所有权:谁加锁谁解锁,不可跨任务传递 无所有权:任何任务都可以 up/down
递归锁定 默认不支持(会死锁),但可配置 MUTEX_RECURSIVE 不支持递归
优先级继承 支持(RT 内核中用于防止优先级反转) 不支持
调试能力 强(CONFIG_DEBUG_MUTEXES 可检测死锁、泄漏) 弱
典型用途 保护临界区、数据结构、硬件寄存器 生产者-消费者模型、资源池管理
内核 API mutex_lock() / mutex_unlock() down() / up()
核心区别总结:
  • 所有权:Mutex 有所有者概念,信号量没有。这意味着 Mutex 可以实施优先级继承,而信号量不能。
  • 递归:Mutex 可以配置为递归锁,允许同一任务多次加锁;信号量不支持递归。
  • 调试:Mutex 有丰富的调试支持,如所有者跟踪、睡眠期间原子性检查;信号量调试能力有限。
  • 性能:Mutex 的乐观自旋优化使其在中等竞争下性能优于信号量。
常见误区: 不要将二值信号量(计数为 1 的信号量)等同于互斥锁。二值信号量没有所有权概念,不能用于保护临界区,否则可能导致优先级反转且无法通过优先级继承解决。在 Linux 内核中,保护临界区应始终使用 mutex 或 spinlock。
Mutex vs 信号量:核心概念对比 互斥锁(Mutex) 一次只允许一个任务进入 有所有者,支持优先级继承 可配置递归锁定 丰富的调试支持 乐观自旋优化 适用于临界区保护 API: mutex_lock/unlock 数据结构: struct mutex 信号量(Semaphore) 允许多个任务(计数为 N) 无所有者,不支持优先级继承 不支持递归锁定 调试能力有限 无自旋优化 适用于资源计数/生产者-消费者 API: down/up 数据结构: struct semaphore vs

3.4 代码示例:Mutex 与信号量使用对比

以下代码片段展示了在内核模块中使用 Mutex 和信号量的典型模式。

/* 互斥锁示例:保护共享数据结构 */
#include <linux/mutex.h>

static DEFINE_MUTEX(my_mutex);
static struct my_data shared_data;

void update_data(struct my_data *new) {
    mutex_lock(&my_mutex);
    /* 临界区开始 */
    shared_data = *new;
    /* 临界区结束 */
    mutex_unlock(&my_mutex);
}

/* 信号量示例:控制资源池访问 */
#include <linux/semaphore.h>

static DEFINE_SEMAPHORE(pool_sem, MAX_RESOURCES);

struct resource *get_resource(void) {
    struct resource *res;

    if (down_interruptible(&pool_sem)) {
        return ERR_PTR(-ERESTARTSYS);
    }
    /* 从池中获取资源 */
    res = acquire_from_pool();
    return res;
}

void put_resource(struct resource *res) {
    release_to_pool(res);
    up(&pool_sem);
}
选择建议:
  • 保护临界区、数据结构、硬件访问 → 使用 Mutex。
  • 管理有限资源数量、生产者-消费者模型 → 使用 信号量。
  • 需要优先级继承防止反转 → 必须使用 Mutex(RT 内核)。
  • 临界区极短且持有者不会睡眠 → 考虑 自旋锁。