4. 自旋锁实现原理:spinlock_t结构、raw_spinlock、自旋等待与中断上下文使用
自旋锁(Spinlock)是Linux内核中最基础的同步原语之一,专为短临界区设计。与睡眠锁不同,当锁被持有时,等待线程不会主动调度出去,而是在原地“自旋”(忙等待),直到锁被释放。这种设计避免了上下文切换的开销,但要求临界区执行时间极短。
核心原则:自旋锁的持有者不允许睡眠或主动调度,否则会导致死锁。自旋锁适用于CPU核数较少、临界区执行时间在几十到几百纳秒的场景。
4.1 spinlock_t 与 raw_spinlock 数据结构
在Linux内核中,spinlock_t 是提供给绝大多数驱动和子系统使用的“高级”自旋锁类型,而 raw_spinlock_t 是底层实现。两者的关系如下表所示:
| 类型 | 定义位置 | 核心字段 | 使用场景 |
|---|---|---|---|
spinlock_t |
<linux/spinlock_types.h> |
raw_lock (raw_spinlock_t) + owner (调试用) |
普通进程上下文、软中断、tasklet |
raw_spinlock_t |
<linux/spinlock_types.h> |
arch_spinlock_t (体系结构相关) |
实时内核(PREEMPT_RT)、中断处理、调度器内部 |
spinlock_t 在非实时内核中直接映射为 raw_spinlock_t,但在实时内核中,spinlock_t 会被替换为可睡眠的 rt_mutex 实现,而 raw_spinlock_t 始终保持真正的自旋语义。因此,中断处理程序必须使用 raw_spinlock_t。
// spinlock_t 的核心结构(简化版,非调试模式)
typedef struct {
raw_spinlock_t raw_lock; // 底层自旋锁
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int owner; // 当前持有者 CPU 编号
#endif
} spinlock_t;
// raw_spinlock_t 的核心结构
typedef struct {
arch_spinlock_t raw_lock; // 体系结构相关的锁实现
} raw_spinlock_t;
关键区别:在标准内核中,
spin_lock() 最终调用 raw_spin_lock();在 PREEMPT_RT 内核中,spin_lock() 可能调用 rt_mutex_lock(),而 raw_spin_lock() 始终是真正的自旋。
4.2 自旋等待的底层机制
自旋等待的核心是 arch_spin_lock() 函数,它由体系结构代码实现。以 ARM64 为例,其使用 独占加载/存储指令(LDXR/STXR)实现原子操作:
- 加载并检查:使用
LDXR读取锁变量,检查是否为 0(未锁定)。 - 尝试获取:如果为 0,使用
STXR尝试写入 1(锁定)。 - 条件判断:如果
STXR返回 0(成功),则获取锁;否则跳回步骤 1。 - 自旋等待:如果锁已被持有,CPU 持续执行
WFE(Wait For Event)指令降低功耗,直到其他 CPU 释放锁时发出事件信号。
WFE 优化:现代 CPU 在自旋等待时会使用
WFE 或 PAUSE 指令,减少总线竞争和功耗,而不是空转执行 NOP。
4.3 中断上下文使用自旋锁的规则
在中断上下文(硬中断、软中断、tasklet)中使用自旋锁时,必须考虑死锁风险。核心规则是:如果一个自旋锁可能在中断处理程序中被获取,那么在任何持有该锁的代码路径中,都必须先关闭本地中断。
Linux 内核提供了以下 API 来安全地在中断上下文使用自旋锁:
| API 函数 | 作用 | 中断状态 |
|---|---|---|
spin_lock_irq() |
获取锁并关闭本地中断 | 中断被禁用 |
spin_unlock_irq() |
释放锁并恢复中断状态 | 中断恢复 |
spin_lock_irqsave() |
保存当前中断状态,关闭中断,获取锁 | 中断被禁用,状态已保存 |
spin_unlock_irqrestore() |
释放锁,恢复之前保存的中断状态 | 中断恢复到保存前的状态 |
spin_lock_bh() |
获取锁并禁止软中断 | 软中断被禁用 |
spin_unlock_bh() |
释放锁并恢复软中断 | 软中断恢复 |
常见错误:在进程上下文中持有自旋锁时,如果发生中断,中断处理程序尝试获取同一把锁,将导致递归自旋死锁。必须使用
spin_lock_irq() 或 spin_lock_irqsave() 来避免。
4.4 自旋锁在中断上下文中的典型使用模式
以下代码展示了在设备驱动中,如何在进程上下文和中断上下文之间安全地共享数据:
// 定义一个自旋锁和保护的数据
static spinlock_t my_lock;
static struct my_data shared_data;
// 初始化
void init_module(void) {
spin_lock_init(&my_lock);
}
// 中断处理程序(硬中断上下文)
irqreturn_t my_interrupt_handler(int irq, void *dev_id) {
unsigned long flags;
// 必须使用 irqsave 变体,因为中断可能嵌套
spin_lock_irqsave(&my_lock, flags);
// 操作共享数据
shared_data.count++;
spin_unlock_irqrestore(&my_lock, flags);
return IRQ_HANDLED;
}
// 进程上下文(系统调用或文件操作)
ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) {
unsigned long flags;
int val;
// 进程上下文也需要关闭中断,防止与中断处理程序死锁
spin_lock_irqsave(&my_lock, flags);
val = shared_data.count;
spin_unlock_irqrestore(&my_lock, flags);
// 将数据拷贝到用户空间
if (copy_to_user(buf, &val, sizeof(val)))
return -EFAULT;
return sizeof(val);
}
最佳实践:始终使用
spin_lock_irqsave() / spin_unlock_irqrestore() 对,而不是 spin_lock_irq() / spin_unlock_irq()。前者可以安全地处理中断嵌套场景,而后者假设中断总是开启的,在中断中调用会导致问题。
4.5 自旋锁与读写锁的对比
自旋锁是读写锁(rwlock)的基础。下表对比了二者的关键差异:
| 特性 | 自旋锁 (spinlock) | 读写自旋锁 (rwlock) |
|---|---|---|
| 并发读 | 不允许,读操作互斥 | 允许多个读者同时持有 |
| 写者优先级 | N/A(只有一种锁) | 写者可能饥饿(取决于实现) |
| 临界区大小 | 极短(纳秒级) | 较短(微秒级) |
| 适用场景 | 保护简单标志、链表操作 | 读多写少的统计、路由表 |
| 中断安全 | 需使用 _irqsave 变体 | 需使用 _irqsave 变体 |
4.6 自旋锁使用避坑指南
- 禁止睡眠:持有自旋锁期间,不能调用
kmalloc(GFP_KERNEL)、copy_to_user()、mutex_lock()等可能睡眠的函数。 - 临界区要短:自旋等待浪费 CPU 周期,临界区应控制在几十条指令以内。
- 注意嵌套顺序:如果必须嵌套使用多个自旋锁,应始终按照相同的顺序获取,避免死锁。
- 使用 raw_spinlock 的场景:在实时内核(PREEMPT_RT)中,中断处理程序、调度器代码必须使用
raw_spinlock_t。 - 调试支持:开启
CONFIG_DEBUG_SPINLOCK和CONFIG_PROVE_LOCKING可以在开发阶段检测死锁和误用。
性能数据:在 3GHz x86 CPU 上,一次自旋锁获取/释放大约需要 10-30 纳秒(无竞争时),而一次上下文切换需要 1-5 微秒。自旋锁在短临界区场景下比睡眠锁快 100-500 倍。