4. 互斥锁原理(mutex):工作机制、睡眠等待与适用场景
互斥锁(mutex)是 Linux 内核中用于保护临界区的另一种基本同步原语。与自旋锁不同,当互斥锁已被其他任务持有时,试图获取锁的任务会主动进入睡眠状态,而不是原地忙等。这一特性决定了互斥锁在特定场景下比自旋锁更高效,但也带来了更严格的上下文使用限制。
4.1 互斥锁的工作机制
互斥锁在 Linux 内核中由 struct mutex 结构体表示,其核心机制基于睡眠-唤醒模型。当一个任务尝试获取已被持有的互斥锁时,该任务会被放入一个等待队列,并调度出 CPU,进入睡眠状态。当持有锁的任务释放锁时,内核会唤醒等待队列中的一个或多个任务,使其重新竞争锁。
互斥锁的典型操作流程如下:
- 初始化:使用
mutex_init()或静态宏DEFINE_MUTEX()初始化互斥锁。 - 获取锁:调用
mutex_lock()尝试获取锁。如果锁可用,任务立即获得锁并继续执行;如果锁已被持有,任务进入睡眠。 - 释放锁:调用
mutex_unlock()释放锁,并唤醒等待队列中的任务。 - 尝试获取(非阻塞):调用
mutex_trylock()尝试获取锁,如果锁不可用则立即返回非零值,不会睡眠。
以下是一个典型的内核互斥锁使用示例:
#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 睡眠等待特性详解
互斥锁的睡眠等待机制涉及内核调度器、等待队列和锁的公平性策略。理解这一机制对于避免死锁和性能问题至关重要。
互斥锁的睡眠等待流程可以用下图表示:
从流程图中可以看出,互斥锁的睡眠等待机制涉及两次上下文切换:一次是任务获取锁失败时主动睡眠,另一次是锁释放后被唤醒的任务重新获得 CPU。这种机制在临界区较长时,相比自旋锁的忙等可以显著节省 CPU 资源。
4.3 互斥锁与自旋锁的对比
理解互斥锁和自旋锁的差异是正确选择同步原语的基础。下表从多个维度对比了两种锁的特性:
| 对比维度 | 互斥锁(mutex) | 自旋锁(spinlock) |
|---|---|---|
| 等待行为 | 睡眠等待,主动让出 CPU | 忙等(spin),占用 CPU 循环检测 |
| 上下文限制 | 只能用于可睡眠上下文(进程上下文) | 可用于中断上下文、进程上下文 |
| 临界区长度 | 适合长临界区(微秒级以上) | 适合短临界区(几微秒以内) |
| CPU 消耗 | 等待时不消耗 CPU,但有上下文切换开销 | 等待时持续消耗 CPU,无上下文切换 |
| 中断安全 | 不安全,不能在中断上下文使用 | 安全,可使用 spin_lock_irqsave() 变体 |
| 递归获取 | 不支持递归加锁,会导致死锁 | 不支持递归加锁,会导致死锁 |
| 公平性 | 有等待队列,通常保证公平(FIFO) | 无等待队列,不保证公平性 |
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)
互斥锁的内部结构可以用下图表示:
从数据结构可以看出,互斥锁内部使用一个自旋锁(wait_lock)来保护等待队列的操作。这意味着互斥锁的实现本身依赖于自旋锁来保证内部数据的一致性。当多个任务同时尝试获取或释放互斥锁时,wait_lock 确保等待队列的修改是原子的。
4.7 互斥锁的变体与扩展
Linux 内核还提供了互斥锁的几种变体,以适应不同的使用场景:
| 变体 | API | 特点 | 适用场景 |
|---|---|---|---|
| 标准互斥锁 | mutex_lock() |
可睡眠,支持调试,性能优化 | 大多数进程上下文临界区 |
| 互斥锁尝试 | mutex_trylock() |
非阻塞,失败立即返回 | 避免死锁的复杂场景 |
| 互斥锁中断 | mutex_lock_interruptible() |
可被信号中断,返回 -ERESTARTSYS | 允许用户态信号中断等待 |
| 互斥锁内核 | mutex_lock_killable() |
只被致命信号中断 | 需要响应 SIGKILL 的场景 |
mutex_lock_interruptible()。这允许用户进程在等待锁时被信号终止,避免进程陷入不可中断的睡眠状态。