自旋锁:轻量级锁的底层哲学

自旋锁,说白了就是让CPU在那「空转」等待。我刚开始接触内核时,觉得这玩意儿挺傻的——干嘛不直接睡过去等?后来才明白,有些场景下,睡觉的成本比空转高得多。

它的核心思想很简单:如果锁被占用,当前线程就不断循环检查锁是否释放,而不是进入睡眠状态。这就像你在等厕所,如果知道里面的人马上就出来,你宁愿站在门口等,而不是去沙发上躺一会儿再过来看。

自旋锁的工作原理

自旋锁基于一个原子操作——Test-and-Set。硬件保证这个操作要么全部完成,要么完全不执行。我当年调试一个驱动时,就因为没理解这个原子性,差点把服务器搞挂了。

伪代码看起来是这样的:

// 自旋锁的核心逻辑(简化版)
void spin_lock(lock_t *lock) {
    while (test_and_set(&lock->flag, 1) == 1) {
        // 啥也不干,就空转
        // CPU在这里不断循环
    }
}

void spin_unlock(lock_t *lock) {
    lock->flag = 0;
}

注意看那个while循环。只要test_and_set返回1(表示锁被占用),CPU就一直在那转。这就是「自旋」二字的由来。

关键点:自旋锁的等待线程不会睡眠,它一直占用CPU。这意味着如果你在单核CPU上用了自旋锁,持有锁的线程被调度走了,那其他线程就永远等不到锁释放——死锁了。

自旋锁的API

Linux内核提供了丰富的自旋锁API。我整理了一张表,方便你查阅:

API 说明 使用场景
spin_lock_init 初始化自旋锁 创建锁时调用一次
spin_lock 获取自旋锁 普通上下文,可睡眠
spin_lock_irq 获取锁并关闭本地中断 中断上下文,防止死锁
spin_lock_irqsave 获取锁并保存中断状态 需要恢复原中断状态时
spin_unlock 释放自旋锁 对应spin_lock
spin_unlock_irq 释放锁并开启中断 对应spin_lock_irq
spin_unlock_irqrestore 释放锁并恢复中断状态 对应spin_lock_irqsave
spin_trylock 尝试获取锁,失败立即返回 不想阻塞的场景

嗯,这里有个细节要注意:spin_lock_irqsavespin_unlock_irqrestore是成对出现的。我见过有人混用spin_lock_irqspin_unlock_irqrestore,结果中断状态乱套了。

使用场景与注意事项

自旋锁不是万能的。它最适合以下场景:

  • 临界区非常短:比如就修改一个变量,几纳秒的事
  • 不能睡眠的上下文:中断处理函数、软中断等
  • 多核CPU:自旋锁在SMP系统上才能真正发挥作用

避坑指南:我曾经在中断处理函数里用了普通的spin_lock,结果持有锁的中断处理函数被另一个中断打断了——死锁。后来改用spin_lock_irq才解决。记住:中断上下文一定要用带irq的版本。

还有几个坑你得注意:

  1. 自旋锁保护的代码不能睡眠——这包括kmalloc(GFP_KERNEL)、copy_from_user等操作
  2. 自旋锁不能递归使用——同一个线程不能重复获取同一个自旋锁
  3. 自旋锁持有时间要短——否则其他CPU空转太浪费

实战案例:驱动中的共享计数器

来看一个实际例子。假设我们写一个驱动,需要统计某个中断触发的次数:

#include <linux/spinlock.h>

struct my_device {
    spinlock_t lock;
    unsigned long irq_count;
};

// 初始化
void my_device_init(struct my_device *dev) {
    spin_lock_init(&dev->lock);
    dev->irq_count = 0;
}

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

    // 保存中断状态并获取锁
    spin_lock_irqsave(&dev->lock, flags);
    
    dev->irq_count++;  // 临界区:就这一行
    
    spin_unlock_irqrestore(&dev->lock, flags);
    
    return IRQ_HANDLED;
}

// 读取计数(可能在进程上下文调用)
unsigned long my_get_count(struct my_device *dev) {
    unsigned long count;
    unsigned long flags;

    spin_lock_irqsave(&dev->lock, flags);
    count = dev->irq_count;
    spin_unlock_irqrestore(&dev->lock, flags);

    return count;
}

这个例子展示了几个要点:

  • 中断上下文和进程上下文共享数据时,都用spin_lock_irqsave
  • 临界区只有一行代码,自旋锁的开销几乎可以忽略
  • irqsave/restore版本,保证中断状态正确恢复

个人经验:我习惯在驱动初始化时就分配好所有资源,这样临界区里就不会有内存分配操作。你想想看,如果在持有自旋锁时调用kmalloc,万一内存紧张触发页面回收,那就睡眠了——内核直接报错。

自旋锁的知识体系

下面这张图帮你理清自旋锁的核心逻辑:

自旋锁核心逻辑流程图 尝试获取锁 锁是否可用? CPU空转等待 进入临界区 执行临界区代码 释放自旋锁 核心思想:锁被占用时CPU空转等待,适合短临界区

这张图把自旋锁的整个流程串起来了。你注意看那个「CPU空转等待」的循环——这就是自旋锁的精髓所在。

最后说一句,自旋锁虽然简单,但用不好就是灾难。我见过一个同事在持有自旋锁时调用了printk,结果控制台输出太慢,导致其他CPU空转了十几毫秒。嗯,从那以后,我写代码时都会问自己一句:这段代码能在5微秒内执行完吗?


公众号:蓝海资料掘金营,微信deep3321