3. 自旋锁原理

3.1 自旋锁的核心工作机制

自旋锁(Spinlock)是 Linux 内核中最基础的同步原语之一,专为短临界区设计。其核心思想是:当锁被其他 CPU 持有时,当前 CPU 不会主动让出处理器(不进入睡眠状态),而是通过忙等待(Busy-Waiting)循环检测锁状态,直到锁被释放。

关键特性:自旋锁的等待线程不会发生上下文切换,因此避免了切换带来的开销,但代价是持续占用 CPU 执行无效循环。

自旋锁的典型工作流程如下:

  1. 尝试获取锁:调用 spin_lock(),通过原子操作尝试将锁变量从“未锁定”状态置为“锁定”状态。
  2. 成功:若原子操作成功,当前 CPU 进入临界区执行。
  3. 失败:若锁已被其他 CPU 持有,当前 CPU 进入自旋循环,反复读取锁变量,直到锁变为可用。
  4. 释放锁:调用 spin_unlock(),通过原子操作将锁变量置回“未锁定”状态。
自旋锁工作机制流程图 尝试获取锁 锁可用? 是 进入临界区执行 否 自旋等待(忙等) 循环检测锁状态 释放锁

3.2 忙等待与上下文切换的权衡

自旋锁的“忙等待”特性是一把双刃剑。理解其权衡对于性能优化至关重要。

对比维度 自旋锁(忙等待) 互斥锁(睡眠等待)
等待行为 CPU 循环检测,不调度 线程进入睡眠,触发调度
上下文切换 无切换,零调度延迟 两次切换(入眠+唤醒)
CPU 利用率 等待时 100% 占用 CPU 等待时 CPU 可运行其他任务
适用临界区 极短(通常 < 1000 条指令) 较长或可能阻塞
中断上下文 可用(中断处理中) 不可用(可能引起死锁)
性能权衡原则:如果临界区的执行时间 小于 两次上下文切换的开销(通常约 1-5 μs),则自旋锁更优;反之,互斥锁更优。现代内核通过 spin_lock_irqsave() 等变体在中断上下文中安全使用自旋锁。

上下文切换的成本包括:保存/恢复寄存器、TLB 刷新、缓存污染、调度器运行。对于高频短临界区(如网络栈的 sk_buff 操作),自旋锁的忙等待避免了这些开销,是正确选择。

忙等待 vs 上下文切换:性能权衡 自旋锁(忙等待) CPU 0:执行临界区 CPU 1:自旋等待(忙等) 总耗时 = 临界区执行时间 无调度开销,无缓存污染 ✅ 短临界区时最优 互斥锁(睡眠等待) 临界区 切换 其他任务执行 切换 临界区 ⚠️ 长临界区时更优

3.3 自旋锁的原子操作实现

自旋锁的核心依赖于原子操作,确保在多 CPU 并发访问时锁状态的一致性。Linux 内核中,自旋锁的底层实现经历了从纯原子变量到排队自旋锁的演进。

3.3.1 经典实现:基于原子交换

早期的自旋锁实现使用 atomic_xchg(原子交换)指令:

// 自旋锁结构体
typedef struct {
    volatile unsigned int lock;  // 0: 未锁定, 1: 锁定
} spinlock_t;

// 获取锁(简化版)
void spin_lock(spinlock_t *lock) {
    // 原子交换:将 lock 置为 1,并返回旧值
    while (__atomic_exchange_n(&lock->lock, 1, __ATOMIC_ACQUIRE) != 0) {
        // 自旋:CPU 忙等待
        cpu_relax();  // 提示 CPU 当前处于自旋循环
    }
}

// 释放锁
void spin_unlock(spinlock_t *lock) {
    __atomic_store_n(&lock->lock, 0, __ATOMIC_RELEASE);
}
问题:经典实现存在“先到先得”不公平问题。当锁释放时,所有等待的 CPU 同时尝试原子交换,导致大量缓存一致性流量(缓存行 bouncing),影响性能。

3.3.2 现代实现:排队自旋锁(Ticket Spinlock)

Linux 内核引入了 Ticket Spinlock 机制,保证公平性并减少缓存竞争。其核心思想类似于“排队叫号”:

  • 锁结构:包含两个字段——owner(当前持有者的票号)和 next(下一个可用的票号)。
  • 获取锁:原子地获取 next 值作为自己的票号,然后自旋等待直到 owner 等于自己的票号。
  • 释放锁:将 owner 加 1,通知下一个等待者。
// 排队自旋锁结构(x86 架构)
typedef struct {
    union {
        u32 slock;  // 整体 32 位
        struct {
            u16 owner;  // 当前持有者票号
            u16 next;   // 下一个可用票号
        };
    };
} arch_spinlock_t;

// 获取锁(x86 汇编级实现)
static __always_inline void arch_spin_lock(arch_spinlock_t *lock) {
    u32 val;
    u16 ticket, head;

    // 步骤1: 原子地获取 next 值,并将 next 加 1
    asm volatile (
        "1: lock; xaddw %w0, %1\n"
        : "+r" (ticket), "+m" (lock->slock)
        : "0" (1)
        : "memory"
    );

    // 步骤2: 获取当前 owner 值
    head = lock->owner;

    // 步骤3: 自旋等待,直到 owner 等于自己的票号
    while (head != ticket) {
        cpu_relax();  // 自旋提示
        head = READ_ONCE(lock->owner);
    }
}

// 释放锁
static __always_inline void arch_spin_unlock(arch_spinlock_t *lock) {
    // 将 owner 加 1,通知下一个等待者
    __add(&lock->owner, 1);
}
排队自旋锁(Ticket Spinlock)架构 锁结构 (arch_spinlock_t) owner = 3 next = 7 等待队列(按票号顺序) CPU 3 票号=3 (持有) CPU 4 票号=4 (等待) CPU 5 票号=5 (等待) CPU 6 票号=6 (等待) 释放锁:owner++ → 通知下一个等待者 公平性保证:每个 CPU 按获取票号的顺序获得锁,避免“惊群”效应和缓存行 bouncing 每个等待者只在自己的票号等于 owner 时退出自旋,其他等待者继续等待

3.4 自旋锁的变体与使用场景

Linux 内核提供了多种自旋锁变体,以适应不同的上下文需求:

API 描述 典型场景
spin_lock() 标准自旋锁,不关中断 进程上下文,已知不会被中断打断
spin_lock_irq() 获取锁并关闭本地 CPU 中断 中断处理程序与进程上下文共享数据
spin_lock_irqsave() 获取锁并保存中断状态后关中断 嵌套中断场景,需恢复原始中断状态
spin_lock_bh() 获取锁并禁止下半部(softirq) 进程上下文与 softirq/tasklet 共享数据
最佳实践:在中断上下文中必须使用 spin_lock_irqsave() 或 spin_lock_irq(),否则当前 CPU 获取锁后可能被中断打断,而中断处理程序又尝试获取同一把锁,导致死锁。

3.5 自旋锁的性能陷阱与优化

即使自旋锁设计精巧,使用不当仍会导致严重性能问题:

  • 临界区过长:自旋锁的忙等待会浪费大量 CPU 周期。如果临界区执行时间超过 1000 条指令,应考虑使用互斥锁。
  • 锁争用过高:多个 CPU 频繁竞争同一把锁,导致大量缓存一致性流量。可通过锁拆分(如 per-CPU 锁)或 RCU 优化。
  • 优先级反转:低优先级任务持有锁,高优先级任务自旋等待。实时系统需使用 rt_mutex 或优先级继承协议。
  • NUMA 感知缺失:在 NUMA 系统中,跨节点访问锁变量会增加延迟。可使用 ticket_spinlock 的 NUMA 感知变体。
自旋锁性能陷阱与优化方向 临界区过长 CPU 浪费在忙等 → 改用互斥锁 锁争用过高 缓存行 bouncing → 锁拆分 / RCU 优先级反转 高优先级被阻塞 → 优先级继承 NUMA 感知缺失 跨节点访问延迟 → NUMA 感知锁 优化建议 1. 保持临界区极短(< 1000 条指令) 2. 使用 Perf / Ftrace 定位锁争用热点