第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内核里,自旋锁的使用有几个铁律:

  1. 关中断 + 自旋锁:在中断处理函数中获取自旋锁时,必须先关本地中断。否则可能发生死锁——CPU在自旋锁里等着,中断来了,中断处理函数也想拿同一个锁。
  2. 禁止睡眠:持有自旋锁的代码绝对不能睡眠或触发调度。因为调度器可能把CPU让给另一个想拿同一把锁的任务,造成死锁。
  3. 嵌套限制:同一个CPU上,自旋锁不建议嵌套使用。如果必须嵌套,要确保获取顺序一致,否则容易死锁。

我记得有一次,团队里一个新同事在自旋锁保护的区域里调用了kmalloc。kmalloc内部可能触发内存回收,进而睡眠等待。结果系统在压力测试下频繁死锁。排查了三天,最后发现是这个问题。从那以后,我在代码评审时看到自旋锁区域里有任何可能阻塞的调用,都会直接打回。

性能考量与选择建议

自旋锁不是万能的。什么时候该用自旋锁,什么时候该用互斥锁?我个人的经验是:

  • 临界区极短(几十条指令以内)→ 自旋锁
  • 临界区较长(涉及I/O、内存分配)→ 互斥锁
  • 中断上下文中 → 只能自旋锁(配合关中断)
  • 多核实时性要求高 → 自旋锁(避免调度延迟)

最后说一句,自旋锁的设计看似简单,但真正用好它,需要对硬件架构、缓存一致性协议、内存模型都有深入理解。我建议你在实际项目中,先从Ticket Spinlock开始用起,等对性能瓶颈有了清晰认识后,再考虑更复杂的MCS锁或自定义优化。