4. 自旋锁实现原理:spinlock_t结构、raw_spinlock、自旋等待与中断上下文使用

自旋锁(Spinlock)是Linux内核中最基础的同步原语之一,专为短临界区设计。与睡眠锁不同,当锁被持有时,等待线程不会主动调度出去,而是在原地“自旋”(忙等待),直到锁被释放。这种设计避免了上下文切换的开销,但要求临界区执行时间极短。

核心原则:自旋锁的持有者不允许睡眠或主动调度,否则会导致死锁。自旋锁适用于CPU核数较少、临界区执行时间在几十到几百纳秒的场景。

4.1 spinlock_t 与 raw_spinlock 数据结构

在Linux内核中,spinlock_t 是提供给绝大多数驱动和子系统使用的“高级”自旋锁类型,而 raw_spinlock_t 是底层实现。两者的关系如下表所示:

类型 定义位置 核心字段 使用场景
spinlock_t <linux/spinlock_types.h> raw_lock (raw_spinlock_t) + owner (调试用) 普通进程上下文、软中断、tasklet
raw_spinlock_t <linux/spinlock_types.h> arch_spinlock_t (体系结构相关) 实时内核(PREEMPT_RT)、中断处理、调度器内部

spinlock_t 在非实时内核中直接映射为 raw_spinlock_t,但在实时内核中,spinlock_t 会被替换为可睡眠的 rt_mutex 实现,而 raw_spinlock_t 始终保持真正的自旋语义。因此,中断处理程序必须使用 raw_spinlock_t。

// spinlock_t 的核心结构(简化版,非调试模式)
typedef struct {
    raw_spinlock_t raw_lock;   // 底层自旋锁
#ifdef CONFIG_DEBUG_SPINLOCK
    unsigned int owner;        // 当前持有者 CPU 编号
#endif
} spinlock_t;

// raw_spinlock_t 的核心结构
typedef struct {
    arch_spinlock_t raw_lock;  // 体系结构相关的锁实现
} raw_spinlock_t;
关键区别:在标准内核中,spin_lock() 最终调用 raw_spin_lock();在 PREEMPT_RT 内核中,spin_lock() 可能调用 rt_mutex_lock(),而 raw_spin_lock() 始终是真正的自旋。

4.2 自旋等待的底层机制

自旋等待的核心是 arch_spin_lock() 函数,它由体系结构代码实现。以 ARM64 为例,其使用 独占加载/存储指令(LDXR/STXR)实现原子操作:

  1. 加载并检查:使用 LDXR 读取锁变量,检查是否为 0(未锁定)。
  2. 尝试获取:如果为 0,使用 STXR 尝试写入 1(锁定)。
  3. 条件判断:如果 STXR 返回 0(成功),则获取锁;否则跳回步骤 1。
  4. 自旋等待:如果锁已被持有,CPU 持续执行 WFE(Wait For Event)指令降低功耗,直到其他 CPU 释放锁时发出事件信号。
WFE 优化:现代 CPU 在自旋等待时会使用 WFE 或 PAUSE 指令,减少总线竞争和功耗,而不是空转执行 NOP。
自旋锁获取流程(ARM64 架构) 调用 arch_spin_lock() LDXR:独占加载锁变量 锁 == 0 ? WFE 自旋等待 锁释放后唤醒 STXR:尝试写入 1 成功 ? 失败,重试 获取锁成功,返回 独占指令对 (LDXR/STXR) 保证原子性,无需关闭中断

4.3 中断上下文使用自旋锁的规则

在中断上下文(硬中断、软中断、tasklet)中使用自旋锁时,必须考虑死锁风险。核心规则是:如果一个自旋锁可能在中断处理程序中被获取,那么在任何持有该锁的代码路径中,都必须先关闭本地中断。

Linux 内核提供了以下 API 来安全地在中断上下文使用自旋锁:

API 函数 作用 中断状态
spin_lock_irq() 获取锁并关闭本地中断 中断被禁用
spin_unlock_irq() 释放锁并恢复中断状态 中断恢复
spin_lock_irqsave() 保存当前中断状态,关闭中断,获取锁 中断被禁用,状态已保存
spin_unlock_irqrestore() 释放锁,恢复之前保存的中断状态 中断恢复到保存前的状态
spin_lock_bh() 获取锁并禁止软中断 软中断被禁用
spin_unlock_bh() 释放锁并恢复软中断 软中断恢复
常见错误:在进程上下文中持有自旋锁时,如果发生中断,中断处理程序尝试获取同一把锁,将导致递归自旋死锁。必须使用 spin_lock_irq() 或 spin_lock_irqsave() 来避免。

4.4 自旋锁在中断上下文中的典型使用模式

以下代码展示了在设备驱动中,如何在进程上下文和中断上下文之间安全地共享数据:

// 定义一个自旋锁和保护的数据
static spinlock_t my_lock;
static struct my_data shared_data;

// 初始化
void init_module(void) {
    spin_lock_init(&my_lock);
}

// 中断处理程序(硬中断上下文)
irqreturn_t my_interrupt_handler(int irq, void *dev_id) {
    unsigned long flags;

    // 必须使用 irqsave 变体,因为中断可能嵌套
    spin_lock_irqsave(&my_lock, flags);

    // 操作共享数据
    shared_data.count++;

    spin_unlock_irqrestore(&my_lock, flags);

    return IRQ_HANDLED;
}

// 进程上下文(系统调用或文件操作)
ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) {
    unsigned long flags;
    int val;

    // 进程上下文也需要关闭中断,防止与中断处理程序死锁
    spin_lock_irqsave(&my_lock, flags);
    val = shared_data.count;
    spin_unlock_irqrestore(&my_lock, flags);

    // 将数据拷贝到用户空间
    if (copy_to_user(buf, &val, sizeof(val)))
        return -EFAULT;

    return sizeof(val);
}
最佳实践:始终使用 spin_lock_irqsave() / spin_unlock_irqrestore() 对,而不是 spin_lock_irq() / spin_unlock_irq()。前者可以安全地处理中断嵌套场景,而后者假设中断总是开启的,在中断中调用会导致问题。

4.5 自旋锁与读写锁的对比

自旋锁是读写锁(rwlock)的基础。下表对比了二者的关键差异:

特性 自旋锁 (spinlock) 读写自旋锁 (rwlock)
并发读 不允许,读操作互斥 允许多个读者同时持有
写者优先级 N/A(只有一种锁) 写者可能饥饿(取决于实现)
临界区大小 极短(纳秒级) 较短(微秒级)
适用场景 保护简单标志、链表操作 读多写少的统计、路由表
中断安全 需使用 _irqsave 变体 需使用 _irqsave 变体
自旋锁 vs 读写锁:并发访问模式 自旋锁 (spinlock) 写者 1(持有) 写者 2(自旋) 读者 1(自旋) 读者 2(自旋) 读写锁 (rwlock) 读者 1(持有) 读者 2(持有) 读者 3(持有) 写者(等待) 多个读者可并发持有 写者必须等待所有读者释放 自旋锁:任何时刻只有一个执行单元可以持有锁 读写锁:多个读者可以同时持有,写者独占

4.6 自旋锁使用避坑指南

  • 禁止睡眠:持有自旋锁期间,不能调用 kmalloc(GFP_KERNEL)、copy_to_user()、mutex_lock() 等可能睡眠的函数。
  • 临界区要短:自旋等待浪费 CPU 周期,临界区应控制在几十条指令以内。
  • 注意嵌套顺序:如果必须嵌套使用多个自旋锁,应始终按照相同的顺序获取,避免死锁。
  • 使用 raw_spinlock 的场景:在实时内核(PREEMPT_RT)中,中断处理程序、调度器代码必须使用 raw_spinlock_t。
  • 调试支持:开启 CONFIG_DEBUG_SPINLOCK 和 CONFIG_PROVE_LOCKING 可以在开发阶段检测死锁和误用。
性能数据:在 3GHz x86 CPU 上,一次自旋锁获取/释放大约需要 10-30 纳秒(无竞争时),而一次上下文切换需要 1-5 微秒。自旋锁在短临界区场景下比睡眠锁快 100-500 倍。