3. 自旋锁原理:忙等待、SMP/UP 行为差异与变种

自旋锁(Spinlock)是实时内核中最基础的锁原语之一。与睡眠锁不同,自旋锁在锁被持有时,等待线程不会主动让出 CPU,而是通过“忙等待”(busy-waiting)循环检测锁状态。这种机制在短临界区中性能极高,但在实时系统中若使用不当,会引发优先级反转、死锁与不可预测的延迟。本节从自旋锁的忙等待机制出发,分析其在 SMP 与 UP 下的行为差异,并介绍内核中常见的自旋锁变种。

3.1 忙等待机制:自旋锁的核心循环

自旋锁的核心是一个原子操作循环。在 Linux 内核中,自旋锁通常基于原子变量实现。当线程尝试获取锁时,如果锁已被其他 CPU 持有,该线程会在一个紧凑的循环中反复读取锁变量,直到锁被释放。这个循环称为“自旋”(spinning)。

关键特征: 自旋锁的等待线程不睡眠、不切换上下文,因此等待开销仅包含内存访问与原子指令,延迟通常在几十纳秒级别。但这也意味着等待线程会持续占用 CPU 时间片,导致 CPU 利用率虚高。

以下是一个简化的自旋锁获取与释放的伪代码实现(基于 x86 架构的 lock cmpxchg 指令):

// 自旋锁结构体
typedef struct {
    volatile unsigned int lock;
} spinlock_t;

// 尝试获取锁(原子交换)
void spin_lock(spinlock_t *sl) {
    while (__atomic_test_and_set(&sl->lock, __ATOMIC_ACQUIRE)) {
        // 忙等待:反复读取锁状态,可插入 CPU 暂停指令(pause)
        while (sl->lock) {
            cpu_relax();  // 提示 CPU 当前为自旋等待,优化功耗与内存顺序
        }
    }
}

// 释放锁
void spin_unlock(spinlock_t *sl) {
    __atomic_clear(&sl->lock, __ATOMIC_RELEASE);
}
优化提示: 现代内核在自旋循环中会插入 cpu_relax()(x86 上对应 pause 指令),该指令能降低流水线压力、减少功耗,并在超线程 CPU 上避免资源争抢。

忙等待机制在以下场景中表现优异:

  • 临界区极短(通常 < 100 条指令):自旋开销远低于上下文切换。
  • 锁竞争概率低:大部分情况下能一次获取成功。
  • 中断上下文:在中断处理程序中不能睡眠,自旋锁是唯一选择。

下图展示了自旋锁忙等待的完整流程:

自旋锁忙等待流程 线程尝试获取锁 原子交换检查锁状态 锁空闲 → 获取成功 锁被持有 → 进入自旋 循环检测锁状态 + cpu_relax 继续自旋

3.2 SMP 与 UP 下的行为差异

自旋锁的行为在单处理器(UP)与对称多处理器(SMP)系统中有本质区别。理解这一差异对于编写可移植的内核代码至关重要。

特性 UP(单处理器) SMP(多处理器)
锁的实际作用 通常编译为空操作(NOP),因为只有一个 CPU,不存在并行访问 需要真正的原子操作与内存屏障,防止多 CPU 并发
忙等待效果 如果锁被持有且中断未关闭,自旋会导致死锁(等待线程与持锁线程在同一 CPU 上) 等待线程在另一个 CPU 上自旋,持锁线程可正常执行并释放锁
中断上下文 必须关闭本地中断,否则中断处理程序可能抢占持锁线程并尝试获取同一锁 同样需要关闭本地中断,但其他 CPU 的中断不受影响
性能开销 几乎为零(编译时优化掉) 原子指令(如 LOCK CMPXCHG)带来数十周期开销,且自旋消耗总线带宽
调试难度 死锁问题通常由中断抢占引起,较易复现 死锁可能涉及多个 CPU 的时序交错,难以稳定复现
UP 陷阱: 在 UP 系统上,自旋锁编译为空操作,但内核代码中仍需要保留锁的语义。如果开发者仅在 UP 上测试,可能无法发现 SMP 下的并发问题。因此,即使目标平台是 UP,也应使用 CONFIG_SMP 开启 SMP 编译选项进行测试。

下图对比了 SMP 与 UP 下自旋锁的运行时状态:

SMP vs UP 自旋锁行为对比 SMP 系统 CPU0: 持锁线程 CPU1: 自旋等待 执行临界区 while(lock) { pause } 释放锁 获取锁成功 UP 系统 唯一 CPU: 持锁线程 中断/异常到达 执行临界区 中断处理尝试获取锁 抢占 死锁!永远自旋

3.3 自旋锁的变种

Linux 内核针对不同场景提供了多种自旋锁变种。每种变种在语义、中断控制、递归支持等方面有所差异。下表总结了最常用的变种:

变种 API 前缀 中断状态 适用场景
普通自旋锁 spin_lock() / spin_unlock() 不改变中断状态 进程上下文,已知中断不会竞争该锁
关闭中断的自旋锁 spin_lock_irq() / spin_unlock_irq() 关闭本地中断 中断上下文可能竞争该锁时使用
保存并关闭中断的自旋锁 spin_lock_irqsave() / spin_unlock_irqrestore() 保存当前中断状态并关闭本地中断 不确定调用前中断是否已关闭,需恢复原始状态
下半部自旋锁 spin_lock_bh() / spin_unlock_bh() 关闭软中断(bottom half) 进程上下文与软中断处理程序共享数据
读写自旋锁 read_lock() / write_lock() 可配合 _irq / _irqsave 使用 读多写少的场景,允许多个读者并发
排队自旋锁 queued_spin_lock()(架构相关) 与普通自旋锁相同 大规模 SMP 系统,避免锁竞争时的“惊群”效应
选择指南:
  • 如果临界区可能被中断处理程序访问,必须使用 spin_lock_irq() 或 spin_lock_irqsave()。
  • 如果临界区可能被软中断(tasklet/timer)访问,使用 spin_lock_bh()。
  • 如果临界区以读操作为主且写操作极少,优先考虑读写自旋锁。
  • 在大型 NUMA 或高核心数系统上,排队自旋锁能显著降低缓存一致性开销。

下图展示了不同自旋锁变种在中断上下文中的保护层级:

自旋锁变种的中断保护层级 spin_lock() / spin_unlock() — 不保护中断 spin_lock_bh() / spin_unlock_bh() — 关闭软中断 spin_lock_irq() / spin_unlock_irq() — 关闭本地硬中断 spin_lock_irqsave() / spin_unlock_irqrestore() — 保存并恢复中断状态 无保护 软中断 硬中断 全状态 保护强度

3.4 自旋锁在实时内核中的挑战

在实时内核(如 PREEMPT_RT)中,自旋锁的行为发生了根本性变化:

  • 自旋锁被替换为互斥锁(rt_mutex):在 PREEMPT_RT 中,大部分自旋锁被转换为可睡眠的互斥锁,等待线程不再忙等待,而是进入睡眠状态。
  • 中断上下文中的自旋锁:由于中断处理程序不能睡眠,PREEMPT_RT 将中断处理程序线程化,使得原本在中断上下文中使用的自旋锁也能使用互斥锁。
  • raw_spinlock_t:对于极少数必须保持原始自旋锁语义的场景(如调度器内部、底层架构代码),内核提供了 raw_spinlock_t,它在 PREEMPT_RT 下仍然保持忙等待行为。
实时系统关键提示: 在非 PREEMPT_RT 内核中,自旋锁的忙等待会直接导致优先级反转。例如,低优先级任务持有锁后被高优先级任务抢占,而高优先级任务在另一个 CPU 上自旋等待该锁,导致低优先级任务无法运行释放锁。实时系统必须使用优先级继承或可睡眠锁来避免此问题。

下表总结了自旋锁在普通内核与 PREEMPT_RT 内核中的行为对比:

属性 普通内核 PREEMPT_RT 内核
等待机制 忙等待(自旋) 睡眠等待(rt_mutex)
中断上下文可用性 是(必须关闭本地中断) 否(中断线程化后使用互斥锁)
优先级反转风险 高(无优先级继承) 低(rt_mutex 支持优先级继承)
临界区长度限制 极短(< 100 条指令) 可较长(睡眠等待不浪费 CPU)
原始自旋锁 spinlock_t raw_spinlock_t(保留忙等待)