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 的核心操作流程:
- 获取 ticket: 每个等待的 CPU 通过原子指令(如
atomic_add_return)获取一个唯一的 ticket 号,同时next计数器递增。 - 比较 owner: 检查自己的 ticket 是否等于
owner字段。如果相等,说明锁已传递到当前 CPU,进入临界区。 - 忙等待: 如果不相等,CPU 在一个紧凑的
while循环中反复读取owner值,直到匹配。循环体内通常包含cpu_relax()指令(x86 的PAUSE指令),以降低功耗并提高超线程性能。 - 释放锁: 临界区结束后,原子递增
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 死锁检测 |
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% 的性能开销,但能捕获绝大多数自旋锁使用错误。在性能测试阶段再关闭这些选项。