第4章 自旋锁设计:自旋锁原理,多核场景下的实现
自旋锁,这个名字听起来挺唬人的。其实说白了,就是一种「忙等待」的锁机制。线程拿不到锁的时候,不会休眠,而是在原地不停地循环检测——就像陀螺一样在那转啊转。嗯,这就是「自旋」这个名字的由来。
我个人习惯把自旋锁比作「厕所门口的急脾气」——门关着就一直在那敲门,直到里面的人出来。而互斥锁更像是个「有耐心的人」,门关着就去旁边椅子上坐着等。
自旋锁的核心原理
自旋锁的实现,本质上就是一个原子操作。我刚开始学RTOS时,总觉得这东西很简单,不就是个while循环吗?后来踩了坑才明白,没那么简单。
先看一个最朴素的自旋锁实现:
// 朴素的自旋锁(单核可用,多核有问题)
typedef struct {
volatile int locked;
} spinlock_t;
void spin_lock(spinlock_t *lock) {
while (lock->locked) {
// 空转等待
}
lock->locked = 1;
}
void spin_unlock(spinlock_t *lock) {
lock->locked = 0;
}
你想想看,这段代码有什么问题?
问题大了去了!检查锁状态和加锁是两个独立的操作,不是原子的。我在项目中遇到过这样的bug:两个CPU核心同时读到locked=0,然后同时设置locked=1,结果两个核心都认为自己拿到了锁。这就像两个人同时看到厕所门开着,同时冲了进去——撞车了。
核心要点:自旋锁必须使用硬件提供的原子指令,比如ARM的LDREX/STREX,或者x86的LOCK前缀指令。
多核场景下的正确实现
多核环境下,自旋锁的实现必须依赖硬件原子操作。以ARM Cortex-A系列为例,常用的方式是LDREX和STREX指令对:
// ARM多核自旋锁实现(汇编风格)
spin_lock:
mov r1, #1
1:
ldrex r2, [r0] // 独占加载锁值
cmp r2, #0 // 检查是否已锁
strexeq r2, r1, [r0] // 如果未锁,尝试写入1
cmpeq r2, #0 // 检查写入是否成功
bne 1b // 失败则重试
dmb // 内存屏障,保证可见性
bx lr
spin_unlock:
dmb // 内存屏障
mov r1, #0
str r1, [r0] // 直接写0解锁
bx lr
这段代码里,LDREX和STREX是成对出现的。LDREX从内存读数据,同时标记这个内存地址为「独占访问」。STREX尝试写入,如果期间没有其他核心修改过这个地址,写入成功;否则写入失败,返回非0值。
我曾经在一个4核的物联网网关项目里,就因为漏掉了DMB指令,导致一个核心释放了锁,另一个核心却看不到。那问题排查了整整两天,最后用逻辑分析仪抓总线才定位到。嗯,从此以后我再也不敢省略内存屏障了。
重要提醒:自旋锁的持有时间必须极短。如果在自旋锁内做耗时操作(比如I/O操作、内存分配),整个系统的实时性会急剧恶化。我见过有人把自旋锁里放了printf调试——结果系统直接卡死。
自旋锁的变种与优化
实际工程中,我们很少直接用最基础的自旋锁。这里分享几种常见的优化变种:
| 变种名称 | 核心思想 | 适用场景 |
|---|---|---|
| Ticket Spinlock | 像排队取号,保证公平性 | 多核竞争激烈,防止饥饿 |
| MCS Lock | 每个核心在本地自旋,减少总线流量 | NUMA架构,缓存一致性开销大 |
| 读写自旋锁 | 区分读锁和写锁,读读不互斥 | 读多写少的场景 |
| 排队自旋锁 | 结合Ticket和MCS的优点 | 通用高性能场景 |
拿Ticket Spinlock举个例子,它的实现思路很有意思:
// Ticket Spinlock 核心逻辑
typedef struct {
volatile int next; // 当前服务号
volatile int owner; // 当前持有者号
} ticket_spinlock_t;
void ticket_spin_lock(ticket_spinlock_t *lock) {
int my_ticket = atomic_fetch_add(&lock->next, 1);
while (lock->owner != my_ticket) {
// 自旋等待,直到叫到自己的号
cpu_relax(); // 提示CPU当前在自旋
}
}
void ticket_spin_unlock(ticket_spinlock_t *lock) {
atomic_fetch_add(&lock->owner, 1);
}
为什么需要这种设计?你想想看,基础自旋锁在高竞争下有个严重问题:先来的人不一定先拿到锁。因为所有核心都在抢同一个变量,谁抢到算谁的。Ticket锁通过「取号-叫号」机制,保证了严格的FIFO顺序。
实战技巧:在自旋循环中加入cpu_relax()或PAUSE指令。这个指令会提示CPU当前在自旋等待,让CPU可以优化流水线,减少功耗。我在一个电池供电的IoT设备上,加了PAUSE指令后,功耗降低了约15%。
自旋锁在RTOS中的使用规范
在RTOS内核里,自旋锁的使用有几个铁律:
- 关中断 + 自旋锁:在中断处理函数中获取自旋锁时,必须先关本地中断。否则可能发生死锁——CPU在自旋锁里等着,中断来了,中断处理函数也想拿同一个锁。
- 禁止睡眠:持有自旋锁的代码绝对不能睡眠或触发调度。因为调度器可能把CPU让给另一个想拿同一把锁的任务,造成死锁。
- 嵌套限制:同一个CPU上,自旋锁不建议嵌套使用。如果必须嵌套,要确保获取顺序一致,否则容易死锁。
我记得有一次,团队里一个新同事在自旋锁保护的区域里调用了kmalloc。kmalloc内部可能触发内存回收,进而睡眠等待。结果系统在压力测试下频繁死锁。排查了三天,最后发现是这个问题。从那以后,我在代码评审时看到自旋锁区域里有任何可能阻塞的调用,都会直接打回。
性能考量与选择建议
自旋锁不是万能的。什么时候该用自旋锁,什么时候该用互斥锁?我个人的经验是:
- 临界区极短(几十条指令以内)→ 自旋锁
- 临界区较长(涉及I/O、内存分配)→ 互斥锁
- 中断上下文中 → 只能自旋锁(配合关中断)
- 多核实时性要求高 → 自旋锁(避免调度延迟)
最后说一句,自旋锁的设计看似简单,但真正用好它,需要对硬件架构、缓存一致性协议、内存模型都有深入理解。我建议你在实际项目中,先从Ticket Spinlock开始用起,等对性能瓶颈有了清晰认识后,再考虑更复杂的MCS锁或自定义优化。