3. 自旋锁原理

自旋锁(spinlock)是 Linux 内核中最基础的同步原语之一,专为 短临界区 和 不可睡眠上下文 设计。理解其数据结构和忙等待机制,是掌握内核并发调试的第一步。

3.1 自旋锁的核心数据结构

在 Linux 内核中,自旋锁的核心数据结构定义在 <linux/spinlock_types.h> 中。不同架构下的底层实现略有差异,但上层抽象统一为 spinlock_t。

类型 / 结构体 定义位置 关键字段 说明
spinlock_t spinlock_types.h raw_lock (arch_spinlock_t) 上层通用自旋锁类型,内嵌架构相关锁
arch_spinlock_t asm/spinlock_types.h slock / next / owner 架构相关实现,x86 使用 ticket spinlock
raw_spinlock_t spinlock_types.h raw_lock (arch_spinlock_t) 原始自旋锁,禁用内核抢占,用于 RT 内核
关键点: spinlock_t 在非 RT 内核中本质上是 raw_spinlock_t 的封装。在 RT 内核中,spinlock_t 可能被替换为可睡眠的互斥体,而 raw_spinlock_t 始终保持自旋语义。

以 x86 架构的 ticket spinlock 为例,其结构体本质是一个 原子计数器,分为两个部分:

// x86 架构下的 arch_spinlock_t (简化)
typedef struct {
    union {
        u32 slock;               // 整个 32 位锁变量
        struct {
            u16 owner;           // 当前持有锁的 ticket 号
            u16 next;            // 下一个可获取锁的 ticket 号
        };
    };
} arch_spinlock_t;
调试提示: 在 crash 或 kgdb 中查看 arch_spinlock_t 的 owner 和 next 字段,可以快速判断当前有多少个 CPU 在等待该自旋锁(next - owner 即为等待队列长度)。

3.2 忙等待机制:Ticket Spinlock 原理

自旋锁的 忙等待(busy-waiting) 是指当锁被持有时,等待的 CPU 不会睡眠,而是在一个紧凑的循环中不断检测锁状态,直到锁被释放。x86 架构使用 Ticket Spinlock 算法来保证公平性。

Ticket Spinlock 忙等待流程 锁空闲 (owner == next) 原子获取 ticket (next++) ticket == owner? 进入临界区 是 忙等待 (while 循环) 否 重新检查 owner 释放锁 (owner++) 公平性保证:每个等待者按 ticket 顺序获得锁,不会发生“锁饥饿”。 忙等待期间 CPU 100% 占用,因此自旋锁只适用于纳秒级临界区。

Ticket Spinlock 的核心操作流程:

  1. 获取 ticket: 每个等待的 CPU 通过原子指令(如 atomic_add_return)获取一个唯一的 ticket 号,同时 next 计数器递增。
  2. 比较 owner: 检查自己的 ticket 是否等于 owner 字段。如果相等,说明锁已传递到当前 CPU,进入临界区。
  3. 忙等待: 如果不相等,CPU 在一个紧凑的 while 循环中反复读取 owner 值,直到匹配。循环体内通常包含 cpu_relax() 指令(x86 的 PAUSE 指令),以降低功耗并提高超线程性能。
  4. 释放锁: 临界区结束后,原子递增 owner 字段,将锁传递给下一个等待者。
调试陷阱: 在单核 CPU 上,如果自旋锁被持有时发生中断,且中断处理程序尝试获取同一把锁,将导致 死锁。这是为什么在中断上下文中必须使用 spin_lock_irqsave() 变体的根本原因。

3.3 自旋锁的变体:raw_spinlock 与 spinlock

Linux 内核提供了两种自旋锁变体,以满足不同内核配置(特别是 RT 内核)下的需求。

特性 spinlock_t raw_spinlock_t
内核抢占 禁用抢占(非 RT) 始终禁用抢占
RT 内核行为 可能被替换为 rt_mutex(可睡眠) 保持自旋语义(不可睡眠)
使用场景 绝大多数临界区 中断处理、调度器、底层架构代码
API 前缀 spin_lock() / spin_unlock() raw_spin_lock() / raw_spin_unlock()
调试开销 支持 lockdep 死锁检测 支持 lockdep 死锁检测
spinlock_t 与 raw_spinlock_t 的层次关系 驱动 / 文件系统 / 网络协议栈 (使用 spinlock_t) spinlock_t 封装层 (抢占控制 + lockdep) raw_spinlock_t (架构相关实现) RT 内核中:spinlock_t → rt_mutex(可睡眠),raw_spinlock_t → 自旋(不可睡眠)

3.3.1 何时使用 raw_spinlock_t?

  • 中断处理程序: 在 handle_irq() 或 threaded_irq 中,如果临界区必须绝对避免睡眠,使用 raw_spin_lock()。
  • 调度器代码: kernel/sched/ 下的核心路径,如 context_switch(),必须使用 raw_spinlock_t 以防止递归。
  • 底层架构代码: 如 arch/x86/ 中的页表操作、TLB 刷新等。
  • 时间子系统: 时钟事件设备、定时器列表操作等。

3.3.2 何时使用 spinlock_t?

  • 绝大多数设备驱动: 保护设备寄存器访问、共享数据结构。
  • 文件系统操作: 保护 inode、dentry 缓存。
  • 网络协议栈: 保护 socket 缓冲区列表、路由表。
经验法则: 除非你明确知道临界区必须在 RT 内核中保持自旋(即不可被优先级反转),否则始终使用 spinlock_t。内核开发者社区的建议是:"If you don't know whether to use raw_spinlock_t, use spinlock_t."

3.4 自旋锁的 API 家族

自旋锁提供了一系列变体 API,用于处理不同的中断上下文:

API 中断状态 使用场景
spin_lock() 不操作中断 进程上下文,已知中断不会竞争
spin_lock_irq() 禁用本地中断 进程上下文,中断处理程序可能竞争
spin_lock_irqsave() 保存并禁用本地中断 不确定当前中断状态时使用(推荐)
spin_lock_bh() 禁用软中断 进程上下文,软中断(tasklet/timer)可能竞争
调试陷阱: 使用 spin_lock_irq() 时,如果调用前中断已经处于禁用状态,释放锁时 spin_unlock_irq() 会错误地启用中断,导致潜在的竞态条件。始终优先使用 spin_lock_irqsave() / spin_unlock_irqrestore() 对。

3.5 自旋锁的调试与观测

内核提供了多种机制来观测自旋锁的行为:

  • lockdep: 通过 CONFIG_PROVE_LOCKING 启用,在运行时检测潜在的死锁、递归锁、中断上下文反转等问题。
  • spinlock 统计: CONFIG_LOCK_STAT 提供每个锁的等待时间、争用次数等统计信息,通过 /proc/lock_stat 查看。
  • ftrace: 使用 trace_lock_acquire / trace_lock_release 事件跟踪锁的获取和释放。
# 查看自旋锁争用统计
cat /proc/lock_stat | head -20

# 使用 ftrace 跟踪 spin_lock 事件
echo 0 > /sys/kernel/tracing/tracing_on
echo function_graph > /sys/kernel/tracing/current_tracer
echo spin_lock > /sys/kernel/tracing/set_ftrace_filter
echo 1 > /sys/kernel/tracing/tracing_on
# ... 复现竞态条件 ...
cat /sys/kernel/tracing/trace
实战建议: 在开发阶段始终开启 CONFIG_PROVE_LOCKING 和 CONFIG_LOCK_STAT。虽然会带来约 10-20% 的性能开销,但能捕获绝大多数自旋锁使用错误。在性能测试阶段再关闭这些选项。