4. 自旋锁API:spin_lock/spin_unlock、spin_lock_irq/spin_unlock_irq、spin_lock_bh/spin_unlock_bh
自旋锁(Spinlock)是Linux内核中最基础的短临界区保护机制。其核心语义是:如果锁已被持有,当前CPU会原地自旋(忙等)直到锁被释放。由于自旋锁不允许调用者睡眠,它必须用于执行时间极短的临界区。
Linux内核提供了多组自旋锁API,分别对应不同的中断上下文和抢占控制需求。选择错误的API会导致死锁或竞态条件。本章将详细剖析三组最核心的API:spin_lock/spin_unlock、spin_lock_irq/spin_unlock_irq 和 spin_lock_bh/spin_unlock_bh。
4.1 自旋锁API全景对比
下表从中断控制、下半部控制、抢占控制和典型使用场景四个维度,对比三组API的核心差异:
| API 组 | 本地中断 | 下半部(BH) | 内核抢占 | 典型场景 |
|---|---|---|---|---|
spin_lock/unlock |
不操作 | 不操作 | 关闭 | 进程上下文之间、或进程与软中断(确定软中断不会访问该锁) |
spin_lock_irq/unlock_irq |
关闭 | 不操作 | 关闭 | 进程上下文与硬中断处理程序共享数据 |
spin_lock_bh/unlock_bh |
不操作 | 关闭 | 关闭 | 进程上下文与tasklet/软中断(非硬中断)共享数据 |
关键原则: 自旋锁API在获取锁时一定会关闭内核抢占(通过
preempt_disable()),这是防止持有锁的线程被抢占导致死锁的基本保证。三组API的区别在于是否额外关闭本地中断或下半部。
4.2 spin_lock / spin_unlock:基础自旋锁
这是最轻量级的自旋锁变体。它只关闭内核抢占,不修改本地中断状态。适用于临界区不会被硬中断或软中断处理程序访问的场景。
// 定义并初始化自旋锁
DEFINE_SPINLOCK(my_lock);
// 进程上下文A
spin_lock(&my_lock);
/* 临界区:访问共享数据结构 */
shared_counter++;
spin_unlock(&my_lock);
// 进程上下文B(可能运行在不同CPU上)
spin_lock(&my_lock);
shared_counter++;
spin_unlock(&my_lock);
陷阱: 如果硬中断处理程序也使用
my_lock,而进程上下文持有锁时发生了该硬中断,中断处理程序尝试获取同一把锁——死锁。因为进程上下文不会因为中断而释放锁,中断处理程序将永远自旋。
4.3 spin_lock_irq / spin_unlock_irq:关闭中断的自旋锁
在获取锁的同时关闭本地CPU的所有硬中断,释放锁时恢复中断状态。这是最安全的变体,用于保护被进程上下文和硬中断处理程序同时访问的数据。
// 硬中断处理程序与进程上下文共享的锁
DEFINE_SPINLOCK(dev_lock);
// 硬中断处理程序(例如网卡中断)
irqreturn_t net_irq_handler(int irq, void *dev_id)
{
spin_lock(&dev_lock); // 中断上下文:不需要关中断(中断已关)
/* 更新网卡环形缓冲区 */
spin_unlock(&dev_lock);
return IRQ_HANDLED;
}
// 进程上下文(例如ioctl调用)
void dev_ioctl_set_speed(int speed)
{
spin_lock_irq(&dev_lock); // 关本地中断 + 关抢占
/* 配置网卡速度寄存器 */
dev->speed = speed;
spin_unlock_irq(&dev_lock); // 恢复中断 + 开抢占
}
性能提示:
spin_lock_irq 会无条件关闭本地中断,即使中断处理程序当前并未运行。如果确定只有软中断(而非硬中断)会访问共享数据,应优先使用 spin_lock_bh 以减少中断延迟。
4.4 spin_lock_bh / spin_unlock_bh:关闭下半部的自旋锁
在获取锁时关闭本地CPU的软中断(下半部)处理,释放锁时重新使能。它不影响硬中断,因此硬中断仍然可以响应。适用于进程上下文与tasklet、软中断(如timer、NET_RX_SOFTIRQ)共享数据的场景。
// 进程上下文与tasklet共享的统计锁
DEFINE_SPINLOCK(stat_lock);
// tasklet处理函数
void my_tasklet_func(unsigned long data)
{
spin_lock(&stat_lock); // tasklet中:软中断上下文,不需要关BH
stats->rx_packets++;
spin_unlock(&stat_lock);
}
// 进程上下文读取统计信息
void show_stats(void)
{
spin_lock_bh(&stat_lock); // 关软中断 + 关抢占
printk("RX packets: %lu\n", stats->rx_packets);
spin_unlock_bh(&stat_lock);
}
为什么tasklet中不需要
_bh 变体? 因为tasklet本身运行在软中断上下文,此时软中断处理已经被禁止嵌套。进程上下文使用 spin_lock_bh 是为了防止在持有锁期间被软中断处理程序抢占,从而避免死锁。
4.5 三组API的调用路径与中断状态变化
下面的SVG图展示了三组API在获取锁前后,本地CPU中断状态和抢占状态的变化流程:
4.6 选择指南:何时使用哪组API?
以下决策树可以帮助你在实际开发中快速选择正确的自旋锁变体:
- 共享数据是否被硬中断处理程序访问?
- 是 → 使用
spin_lock_irq / spin_unlock_irq - 否 → 进入第2步
- 是 → 使用
- 共享数据是否被软中断/tasklet访问?
- 是 → 使用
spin_lock_bh / spin_unlock_bh - 否 → 使用
spin_lock / spin_unlock
- 是 → 使用
常见错误: 在硬中断处理程序中调用
spin_lock_irq。硬中断处理程序本身已经关闭了本地中断,再次调用 spin_lock_irq 虽然不会死锁,但 spin_unlock_irq 会无条件开启中断,这可能导致中断嵌套或竞态。正确的做法是:在硬中断处理程序中使用 spin_lock,在进程上下文中使用 spin_lock_irq。
4.7 调试要点:自旋锁使用不当的典型症状
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 系统完全挂死(hard lockup) | 中断上下文与进程上下文使用同一把锁,但进程上下文未关中断 | 检查进程上下文是否使用了 spin_lock 而非 spin_lock_irq |
| 软中断不执行或延迟极高 | 进程上下文使用 spin_lock_irq 保护本应只用 spin_lock_bh 的数据 |
检查是否过度关闭中断,导致网络/定时器软中断被阻塞 |
| 中断丢失或设备超时 | 临界区执行时间过长,中断被长时间关闭 | 评估临界区长度,考虑使用互斥体或读写锁替代 |
| 内核打印“BUG: spinlock lockup” | 自旋锁被持有时发生了调度或睡眠 | 检查临界区中是否有 kmalloc(GFP_KERNEL)、copy_from_user 等可能睡眠的调用 |
4.8 实战建议:从spin_lock到spin_lock_irqsave
在实际内核代码中,spin_lock_irq 有一个更安全的变体——spin_lock_irqsave。它会保存当前中断状态到局部变量,释放锁时用 spin_unlock_irqrestore 恢复原始状态。这在中断状态不确定的路径中(如函数可能被不同上下文调用)可以避免错误地开启中断。
// 推荐写法:保存并恢复中断状态
unsigned long flags;
spin_lock_irqsave(&lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&lock, flags);
// 不推荐:如果此函数被中断上下文调用,会错误开启中断
spin_lock_irq(&lock);
/* 临界区 */
spin_unlock_irq(&lock);
内核编码规范: 除非你100%确定调用路径的中断状态,否则始终使用
spin_lock_irqsave 和 spin_unlock_irqrestore。这是Linux内核社区广泛采用的安全实践。