3. 自旋锁(Spinlock):原理、API与实战避坑
自旋锁,这个名字起得很形象——“自旋”就是原地打转。说白了,当一个线程尝试获取锁时,如果锁已经被别人占着,它不会去睡觉(休眠),而是在那里不停地循环检查,直到锁被释放。
我第一次接触自旋锁时,心里就一个疑问:“这不就是浪费CPU吗?” 嗯,确实有浪费,但你要知道,在某些场景下,这种“浪费”反而是最高效的做法。
3.1 自旋锁的原理与适用场景
自旋锁的核心机制很简单:
- 锁被占用时,线程进入忙等待(busy-waiting)状态
- 线程不会主动让出CPU,而是持续检查锁状态
- 一旦锁释放,立即获取并继续执行
你可能会问:“为什么不直接休眠,等锁释放了再唤醒?”
原因在于——上下文切换的开销。如果锁被持有的时间非常短(比如几十个指令周期),那么让线程休眠再唤醒的开销,可能比原地自旋等待还要大。我曾在项目中做过测试,一个简单的自旋锁等待时间如果小于10微秒,自旋比休眠快一个数量级。
- 锁持有时间极短(通常几十到几百个指令周期)
- 中断上下文或原子上下文中(不能休眠)
- 多核处理器上,避免进程调度开销
- 锁持有时间较长(毫秒级以上)
- 单核CPU上(自旋等于死循环)
- 需要支持优先级反转的场景
3.2 自旋锁的API:spin_lock / spin_unlock
Linux内核提供的自旋锁API非常简洁,核心就几个函数:
#include <linux/spinlock.h>
spinlock_t my_lock;
// 初始化
spin_lock_init(&my_lock);
// 加锁
spin_lock(&my_lock);
// 临界区代码
// ... 你的共享数据操作 ...
// 解锁
spin_unlock(&my_lock);
嗯,看起来很简单对吧?但实际用起来,坑可不少。
我记得有一次,我在一个驱动模块里用了自旋锁保护一个全局变量。代码跑起来一切正常,但一上压力测试就死机。查了半天,发现是锁的持有者被中断打断了,而中断处理程序里又去尝试获取同一个锁——死锁了。
- 这个锁会被中断处理程序访问吗?
- 持有锁的代码路径会不会调用可能导致睡眠的函数?
- 锁的持有时间是否足够短?
3.3 自旋锁的中断变体:spin_lock_irqsave
刚才提到的中断死锁问题,内核早就想到了。解决方案就是中断变体:
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区:此时本地中断被禁用
spin_unlock_irqrestore(&my_lock, flags);
spin_lock_irqsave 做了两件事:
- 禁用本地CPU的中断
- 获取自旋锁
而 spin_unlock_irqrestore 则恢复之前的中断状态。为什么要保存 flags?因为调用前中断可能是开启的,也可能是关闭的,我们需要恢复到原来的状态,而不是简单粗暴地开启中断。
我曾经在调试一个网络驱动时,看到同事用了 spin_lock_irq(不带save的版本),结果在某个代码路径上,中断本来是关闭的,解锁后却把中断打开了,导致后续代码出现竞态条件。嗯,从那以后,我一律使用 _irqsave 版本,除非我100%确定中断状态。
| 场景 | 推荐API |
|---|---|
| 只在进程上下文使用 | spin_lock / spin_unlock |
| 可能被中断上下文访问 | spin_lock_irqsave / spin_unlock_irqrestore |
| 底半部(softirq/tasklet) | spin_lock_bh / spin_unlock_bh |
3.4 自旋锁的注意事项与死锁
自旋锁的死锁问题,可以说是内核开发者的头号杀手。我总结了几种最常见的死锁场景:
场景一:递归加锁
void func_a() {
spin_lock(&lock);
func_b(); // func_b 里又去拿同一个锁
spin_unlock(&lock);
}
void func_b() {
spin_lock(&lock); // 死锁!
// ...
spin_unlock(&lock);
}
自旋锁不支持递归。同一个线程第二次尝试获取同一个自旋锁时,因为锁已经被自己占着,就会永远自旋下去。这就像你左手抓右手,永远抓不到。
场景二:中断上下文死锁
这个前面提过,持有锁时被中断打断,中断处理程序又来拿同一个锁。解决方案就是用 spin_lock_irqsave。
场景三:锁顺序不一致
// 线程A
spin_lock(&lock_a);
spin_lock(&lock_b);
// 线程B
spin_lock(&lock_b);
spin_lock(&lock_a); // 死锁!
解决这种问题,需要全局约定锁的获取顺序。我习惯在代码注释里明确标注:“所有代码路径必须先拿lock_a,再拿lock_b”。
- 持有自旋锁期间,绝对不能调用可能导致睡眠的函数(如
kmalloc(GFP_KERNEL)、copy_from_user等) - 自旋锁的临界区要尽可能短,我一般控制在几十行代码以内
- 多核调试时,可以用
lockdep工具检测潜在的死锁
3.5 知识体系总览
下面这张图,是我自己整理的自旋锁知识框架,帮你快速建立整体认知:
自旋锁虽然简单,但用好了是利器,用不好就是定时炸弹。我个人建议,新手先从 spin_lock_irqsave 开始用起,等真正理解了中断上下文和原子操作后,再考虑其他变体。毕竟,安全第一,性能第二——这是我在内核开发中摔过跟头后最深的体会。
公众号:蓝海资料掘金营,微信deep3321