4. 互斥锁原理(mutex):工作机制、睡眠等待与适用场景

互斥锁(mutex)是 Linux 内核中用于保护临界区的另一种基本同步原语。与自旋锁不同,当互斥锁已被其他任务持有时,试图获取锁的任务会主动进入睡眠状态,而不是原地忙等。这一特性决定了互斥锁在特定场景下比自旋锁更高效,但也带来了更严格的上下文使用限制。

核心区别速览:自旋锁是“等一会再试”,互斥锁是“我先睡,你叫醒我”。互斥锁的睡眠等待特性使其适用于长临界区和可睡眠上下文,但禁止在中断上下文使用。

4.1 互斥锁的工作机制

互斥锁在 Linux 内核中由 struct mutex 结构体表示,其核心机制基于睡眠-唤醒模型。当一个任务尝试获取已被持有的互斥锁时,该任务会被放入一个等待队列,并调度出 CPU,进入睡眠状态。当持有锁的任务释放锁时,内核会唤醒等待队列中的一个或多个任务,使其重新竞争锁。

互斥锁的典型操作流程如下:

  1. 初始化:使用 mutex_init() 或静态宏 DEFINE_MUTEX() 初始化互斥锁。
  2. 获取锁:调用 mutex_lock() 尝试获取锁。如果锁可用,任务立即获得锁并继续执行;如果锁已被持有,任务进入睡眠。
  3. 释放锁:调用 mutex_unlock() 释放锁,并唤醒等待队列中的任务。
  4. 尝试获取(非阻塞):调用 mutex_trylock() 尝试获取锁,如果锁不可用则立即返回非零值,不会睡眠。
关键设计:互斥锁的睡眠等待特性意味着它必须在可以睡眠的上下文中使用。中断处理程序、软中断、tasklet 等不可睡眠的上下文中禁止使用互斥锁。

以下是一个典型的内核互斥锁使用示例:

#include <linux/mutex.h>

/* 静态初始化互斥锁 */
static DEFINE_MUTEX(my_mutex);

/* 共享资源 */
static int shared_counter = 0;

void my_critical_function(void)
{
    /* 获取互斥锁 —— 如果锁被持有,当前任务会睡眠 */
    mutex_lock(&my_mutex);

    /* 临界区:安全访问共享资源 */
    shared_counter++;
    pr_info("Counter value: %d\n", shared_counter);

    /* 释放互斥锁 —— 唤醒等待的任务 */
    mutex_unlock(&my_mutex);
}

4.2 睡眠等待特性详解

互斥锁的睡眠等待机制涉及内核调度器、等待队列和锁的公平性策略。理解这一机制对于避免死锁和性能问题至关重要。

互斥锁的睡眠等待流程可以用下图表示:

互斥锁睡眠等待机制流程图 任务A:mutex_lock() 锁可用? 获取锁,继续执行 加入等待队列 调度出CPU,睡眠 任务B:mutex_unlock() 唤醒等待队列中的任务 睡眠等待关键点 • 任务主动放弃CPU • 减少CPU空转消耗 • 上下文切换开销较大 唤醒机制 • 释放锁时唤醒等待队列 • 被唤醒任务重新竞争锁

从流程图中可以看出,互斥锁的睡眠等待机制涉及两次上下文切换:一次是任务获取锁失败时主动睡眠,另一次是锁释放后被唤醒的任务重新获得 CPU。这种机制在临界区较长时,相比自旋锁的忙等可以显著节省 CPU 资源。

4.3 互斥锁与自旋锁的对比

理解互斥锁和自旋锁的差异是正确选择同步原语的基础。下表从多个维度对比了两种锁的特性:

对比维度 互斥锁(mutex) 自旋锁(spinlock)
等待行为 睡眠等待,主动让出 CPU 忙等(spin),占用 CPU 循环检测
上下文限制 只能用于可睡眠上下文(进程上下文) 可用于中断上下文、进程上下文
临界区长度 适合长临界区(微秒级以上) 适合短临界区(几微秒以内)
CPU 消耗 等待时不消耗 CPU,但有上下文切换开销 等待时持续消耗 CPU,无上下文切换
中断安全 不安全,不能在中断上下文使用 安全,可使用 spin_lock_irqsave() 变体
递归获取 不支持递归加锁,会导致死锁 不支持递归加锁,会导致死锁
公平性 有等待队列,通常保证公平(FIFO) 无等待队列,不保证公平性
常见误区:不要认为互斥锁“效率更高”就盲目使用。互斥锁的上下文切换开销(约 1-10 微秒)在临界区极短时可能超过自旋锁的忙等开销。选择原则是:临界区短且频繁访问时用自旋锁,临界区长或需要睡眠时用互斥锁。

4.4 适用场景分析

互斥锁的睡眠等待特性使其在以下场景中具有明显优势:

4.4.1 长临界区保护

当临界区执行时间较长(例如超过 100 微秒)时,使用自旋锁会导致 CPU 长时间空转,浪费处理器资源。互斥锁让等待任务睡眠,可以在这段时间内运行其他任务,提高系统整体吞吐量。

4.4.2 可睡眠上下文中的资源保护

在进程上下文中,许多操作本身就会导致睡眠(如 copy_from_user()、kmalloc(GFP_KERNEL)、msleep() 等)。如果临界区中包含这些操作,必须使用互斥锁,因为自旋锁持有期间不允许睡眠。

4.4.3 需要公平锁的场景

互斥锁的等待队列机制通常保证先等待的任务先获得锁(FIFO 顺序),避免了自旋锁可能出现的“锁饥饿”问题。在实时性要求较高的系统中,公平性是一个重要考量。

4.4.4 保护复杂数据结构

对于链表遍历、树操作、哈希表维护等需要较长时间的操作,互斥锁是更合适的选择。这些操作通常涉及多个步骤,临界区较长,且可能在操作过程中需要分配内存或访问用户空间。

以下是一个在进程上下文中使用互斥锁保护复杂操作的示例:

static DEFINE_MUTEX(device_list_lock);
static LIST_HEAD(device_list);

int add_device_to_list(struct device *dev)
{
    /* 互斥锁保护链表操作 */
    mutex_lock(&device_list_lock);

    /* 临界区:可能包含耗时操作 */
    if (list_empty(&device_list)) {
        /* 首次添加,可能需要分配内存 */
        struct device *head = kmalloc(sizeof(*head), GFP_KERNEL);
        if (!head) {
            mutex_unlock(&device_list_lock);
            return -ENOMEM;
        }
        /* 初始化头节点... */
    }

    list_add_tail(&dev->list, &device_list);

    /* 释放锁 */
    mutex_unlock(&device_list_lock);
    return 0;
}

4.5 互斥锁使用注意事项

正确使用互斥锁需要遵循以下规则,否则可能导致死锁或系统崩溃:

  • 禁止在中断上下文使用:中断处理程序、tasklet、软中断中不能使用互斥锁,因为这些上下文不可睡眠。此时应使用自旋锁。
  • 禁止递归加锁:同一个任务不能对同一个互斥锁多次加锁,否则会导致死锁。如果需要递归锁,应使用 struct rwsem 或自定义计数锁。
  • 加锁和解锁必须在同一上下文:在进程上下文中加锁,必须在同一进程上下文中解锁。不能在进程上下文中加锁,在中断上下文中解锁。
  • 注意锁的持有时间:虽然互斥锁允许长临界区,但过长的持有时间仍会影响系统实时性。应尽量缩短临界区。
  • 使用 mutex_trylock() 避免死锁:在复杂的锁依赖场景中,可以使用 mutex_trylock() 尝试获取锁,如果失败则释放已持有的锁并重试,避免死锁。
调试建议:在开发阶段,可以开启内核的 CONFIG_DEBUG_MUTEXES 选项,该选项会检测互斥锁的非法使用(如中断上下文中使用、递归加锁、锁泄漏等),并在检测到问题时打印警告信息。

4.6 互斥锁的内部数据结构

理解互斥锁的内部结构有助于深入掌握其工作原理。互斥锁的核心数据结构如下:

struct mutex {
    atomic_long_t       owner;          /* 锁持有者,低1位表示是否有等待者 */
    spinlock_t          wait_lock;      /* 保护等待队列的自旋锁 */
    struct list_head    wait_list;      /* 等待队列链表 */
};

/* 静态初始化宏 */
#define __MUTEX_INITIALIZER(lockname) \
    { .owner = ATOMIC_LONG_INIT(0) \
    , .wait_lock = __SPIN_LOCK_UNLOCKED(lockname.wait_lock) \
    , .wait_list = LIST_HEAD_INIT(lockname.wait_list) }

#define DEFINE_MUTEX(mutexname) \
    struct mutex mutexname = __MUTEX_INITIALIZER(mutexname)

互斥锁的内部结构可以用下图表示:

互斥锁内部数据结构示意图 struct mutex owner (atomic_long_t) wait_lock (spinlock_t) wait_list (list_head) 等待队列 (wait_list) 任务A (睡眠中) 任务B (睡眠中) 任务C (睡眠中) owner 字段编码 • 位0:0=无等待者,1=有等待者 • 高63位:指向锁持有者的 task_struct 指针 • 0 表示锁未被持有 wait_lock 的作用 保护等待队列操作,防止并发 修改等待队列链表

从数据结构可以看出,互斥锁内部使用一个自旋锁(wait_lock)来保护等待队列的操作。这意味着互斥锁的实现本身依赖于自旋锁来保证内部数据的一致性。当多个任务同时尝试获取或释放互斥锁时,wait_lock 确保等待队列的修改是原子的。

4.7 互斥锁的变体与扩展

Linux 内核还提供了互斥锁的几种变体,以适应不同的使用场景:

变体 API 特点 适用场景
标准互斥锁 mutex_lock() 可睡眠,支持调试,性能优化 大多数进程上下文临界区
互斥锁尝试 mutex_trylock() 非阻塞,失败立即返回 避免死锁的复杂场景
互斥锁中断 mutex_lock_interruptible() 可被信号中断,返回 -ERESTARTSYS 允许用户态信号中断等待
互斥锁内核 mutex_lock_killable() 只被致命信号中断 需要响应 SIGKILL 的场景
最佳实践:在驱动开发中,如果临界区可能被用户态信号中断(例如在系统调用路径中),优先使用 mutex_lock_interruptible()。这允许用户进程在等待锁时被信号终止,避免进程陷入不可中断的睡眠状态。