3. 自旋锁原理(spinlock)

自旋锁是 Linux 内核中最基础的同步原语之一,专为短临界区和不可睡眠上下文设计。理解其工作机制、忙等待特性与适用边界,是掌握内核并发调试的第一步。

3.1 自旋锁的工作机制

自旋锁的核心思想是:当一个线程尝试获取已被其他线程持有的锁时,它不会进入睡眠状态,而是原地循环忙等待,不断检查锁是否已被释放。一旦持有者释放锁,等待线程立即获得锁并进入临界区。

关键行为:自旋锁的“自旋”本质是一个紧凑的 while 循环,循环体内执行原子化的读-修改-写操作(如 atomic_test_and_set_bit),直到成功获取锁为止。

自旋锁在单核与多核系统上的实现有显著差异:

系统类型 实现方式 说明
单核(UP) 空操作或禁止抢占 单核上不存在真正的并行,自旋锁退化为抢占禁用,防止中断上下文与进程上下文竞争
多核(SMP) 原子指令 + 内存屏障 使用 LOCK 前缀指令(x86)或 ldrex/strex(ARM)实现原子性,配合 dmb 保证内存序
内核开发者提示:在单核系统上,spin_lock() 实际上只调用 preempt_disable(),不会产生任何循环等待。这是理解自旋锁行为差异的重要前提。

3.2 忙等待特性与性能影响

忙等待(busy-waiting)是自旋锁最鲜明的特征,也是其最大的性能风险点。等待线程在自旋期间持续占用 CPU,无法执行其他任务。

自旋锁忙等待 vs 互斥锁睡眠等待 时间 线程A(持有锁) 线程B(自旋等待) 线程A释放锁 B进入 线程C(持有锁) 线程D睡眠 线程C释放锁 D唤醒 D进入 自旋锁:线程B在等待期间持续占用CPU,无法执行其他任务 互斥锁:线程D在等待期间睡眠,CPU可调度其他任务,但存在上下文切换开销

忙等待带来的直接后果:

  • CPU 空转:等待线程消耗完整的 CPU 时间片,无法被调度出去
  • 缓存颠簸:多核场景下,自旋锁的原子操作导致缓存行在核间频繁失效
  • 优先级反转:低优先级线程持有锁,高优先级线程自旋等待,低优先级线程无法获得 CPU
性能陷阱:如果临界区执行时间超过 2 次上下文切换的开销(约 1-5 μs),自旋锁的忙等待效率将低于互斥锁的睡眠等待。这是选择同步原语的核心判断依据。

3.3 适用场景

自旋锁的适用场景由其忙等待特性严格限定:

场景 是否适用 原因
短临界区(< 10 μs) ✅ 适用 自旋开销小于上下文切换,整体效率更高
中断上下文(ISR) ✅ 适用 中断上下文不可睡眠,只能使用自旋锁
软中断 / tasklet ✅ 适用 软中断上下文同样不可睡眠
长临界区(> 100 μs) ❌ 不适用 忙等待浪费 CPU,应使用互斥锁
需要睡眠的操作 ❌ 不适用 自旋锁持有期间禁止睡眠,否则导致死锁
用户态内存访问 ❌ 不适用 用户态访问可能触发缺页中断,导致睡眠

3.4 自旋锁的变体与选择

Linux 内核提供了多种自旋锁变体,以适应不同的并发场景:

自旋锁变体层级结构 spinlock_t spin_lock_irqsave spin_lock_bh raw_spinlock_t 禁用本地中断 + 获取自旋锁 禁用软中断 + 获取自旋锁 实时内核专用 不可被抢占

选择自旋锁变体的核心原则:

  • spin_lock() / spin_unlock():仅用于进程上下文,不涉及中断保护
  • spin_lock_irqsave() / spin_unlock_irqrestore():中断上下文与进程上下文共享数据时使用,保存并禁用本地中断
  • spin_lock_bh() / spin_unlock_bh():软中断与进程上下文共享数据时使用,禁用软中断
  • raw_spinlock_t:实时内核(RT-PREEMPT)中,spinlock_t 可能被实现为可睡眠锁,raw_spinlock_t 保证真正的自旋行为
最佳实践:在中断上下文中访问共享数据时,必须使用 spin_lock_irqsave(),否则中断处理程序可能打断正在持有锁的进程上下文,导致死锁。

3.5 自旋锁使用示例

以下代码展示了一个典型的中断上下文自旋锁使用模式:

#include <linux/spinlock.h>

struct my_device {
    int             data;
    spinlock_t      lock;
};

/* 中断处理函数 */
irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = dev_id;
    unsigned long flags;

    spin_lock_irqsave(&dev->lock, flags);
    /* 临界区:访问共享数据 */
    dev->data++;
    /* 临界区结束 */
    spin_unlock_irqrestore(&dev->lock, flags);

    return IRQ_HANDLED;
}

/* 进程上下文接口 */
ssize_t my_read(struct file *filp, char __user *buf,
                size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    unsigned long flags;
    int val;

    spin_lock_irqsave(&dev->lock, flags);
    val = dev->data;
    spin_unlock_irqrestore(&dev->lock, flags);

    return copy_to_user(buf, &val, sizeof(val)) ? -EFAULT : sizeof(val);
}
关键点:中断处理函数与进程上下文共享同一把锁时,双方都必须使用 spin_lock_irqsave() / spin_unlock_irqrestore(),确保中断状态被正确保存和恢复。

3.6 自旋锁的调试与注意事项

内核提供了 CONFIG_DEBUG_SPINLOCK 和 CONFIG_DEBUG_LOCK_ALLOC 选项,用于在开发阶段检测自旋锁的误用:

调试选项 检测能力 性能影响
CONFIG_DEBUG_SPINLOCK 检测未初始化锁、重复解锁、锁泄漏 低
CONFIG_DEBUG_LOCK_ALLOC 检测锁顺序反转、死锁风险、睡眠时持有锁 中
CONFIG_LOCK_STAT 统计锁竞争次数、等待时间、持有时间 低
常见错误:
  • 在持有自旋锁时调用可能睡眠的函数(如 copy_from_user、kmalloc(GFP_KERNEL)、mutex_lock)
  • 在中断上下文中使用 spin_lock() 而非 spin_lock_irqsave()
  • 忘记在错误路径中释放自旋锁,导致系统死锁