linux内核 - spinlock

张开发
2026/4/16 21:24:18 15 分钟阅读

分享文章

linux内核 - spinlock
自旋锁spinlock是一种基于硬件实现的锁机制它依赖硬件提供的原子操作能力例如 test_and_set。在非原子实现中这个操作通常会被分解为“读-修改-写”三个步骤。它是最简单、最基础的一种锁机制其工作方式如下。当 CPU B 正在运行而任务 B 想要获取自旋锁即调用自旋锁的加锁函数但此时该锁已经被另一个 CPU例如 CPU A 上运行的任务 A 已经调用了加锁函数并持有该锁占用那么 CPU B 就会在一个 while 循环中不断“自旋等待”从而阻塞任务 B直到另一个 CPU 释放该锁即任务 A 调用解锁函数。这种“自旋等待”只会发生在多核系统上因为只有多个 CPU 同时运行时才可能出现一个 CPU 等待另一个 CPU 释放锁的情况。在单核系统中不会发生这种情况因为同一时刻只能运行一个任务要么任务持有锁并继续执行要么任务不运行直到锁被释放。自旋锁本质上是“由 CPU 持有的锁”这一点与互斥锁mutex不同后者是“由任务持有的锁”。自旋锁的工作方式是在本地 CPU 上禁用调度器即执行获取自旋锁的任务所在的 CPU。这意味着该 CPU 上正在运行的任务不会被抢占除非发生中断请求IRQ前提是本地 CPU 上未禁用中断后面会详细说明。换句话说自旋锁用于保护在任意时刻只能被一个 CPU 访问的资源这使其适用于对称多处理SMP系统的并发安全以及执行原子操作。自旋锁不仅仅依赖硬件提供的原子操作函数。例如在 Linux 内核中抢占状态依赖于一个每 CPUper-CPU变量如果该变量等于 0表示允许抢占如果大于 0则表示抢占被禁用此时 schedule() 调度函数将无法执行。因此禁用抢占preempt_disable()的实现方式就是将当前 per-CPU 变量实际上是 preempt_count加 1而 preempt_enable() 则会将该变量减 1并检查新的值是否为 0如果为 0则调用 schedule()。这些加法/减法操作必须是原子的因此依赖 CPU 提供原子加法/减法指令能力。自旋锁可以通过两种方式创建一种是使用 DEFINE_SPINLOCK 宏进行静态定义如下所示另一种是在运行时对一个未初始化的自旋锁调用 spin_lock_init() 进行初始化。static DEFINE_SPINLOCK(my_spinlock);为了理解其工作原理只需要查看该宏在 include/linux/spinlock_types.h 中的定义如下#define DEFINE_SPINLOCK(x) spinlock_t x \ __SPIN_LOCK_UNLOCKED(x)它可以这样使用static DEFINE_SPINLOCK(foo_lock);在这之后这个自旋锁可以通过名字 foo_lock 访问它的地址是 foo_lock。然而对于动态运行时分配的情况更好的做法是将自旋锁嵌入到一个更大的结构体中为该结构体分配内存然后对其中的自旋锁成员调用 spin_lock_init()如下代码所示struct bigger_struct { spinlock_t lock; unsigned int foo; [...] }; static struct bigger_struct *fake_init_function() { struct bigger_struct *bs; bs kmalloc(sizeof(struct bigger_struct), GFP_KERNEL); if (!bs) return -ENOMEM; spin_lock_init(bs-lock); return bs; }通常情况下尽可能使用 DEFINE_SPINLOCK 是更好的选择。它提供了编译期初始化并且代码更简洁几乎没有实际缺点。在使用自旋锁时可以通过内联函数 spin_lock() 和 spin_unlock() 来加锁和解锁这两个函数定义在 include/linux/spinlock.h 中如下所示static __always_inline void spin_unlock(spinlock_t *lock) static __always_inline void spin_lock(spinlock_t *lock)不过使用这种方式的自旋锁也存在一些已知限制。虽然自旋锁可以防止本地 CPU 上的抢占但它无法防止该 CPU 被中断“占用”即进入中断处理程序执行。假设一种情况CPU 为了保护某个资源以任务 A 的名义持有一个自旋锁。此时发生了一个中断CPU 会暂停当前任务并跳转到该中断处理函数。到目前为止一切正常。但如果这个中断处理函数也需要获取同一个自旋锁也就是说该资源同时被中断处理程序共享那么它会进入无限自旋状态因为它试图获取一个已经被当前任务持有的锁而当前任务又被它自己所在的中断抢占了。这种情况会导致死锁deadlock。为了解决这个问题Linux 内核提供了 _irq 版本的自旋锁函数。这类函数在禁用/启用抢占的同时也会禁用/启用本地 CPU 的中断。这些函数是 spin_lock_irq() 和 spin_unlock_irq()定义如下static void spin_unlock_irq(spinlock_t *lock) static void spin_lock_irq(spinlock_t *lock)但是我们可能会认为这种方法已经足够安全了实际上并不是。_irq 版本只能部分解决问题。假设在执行你的代码之前CPU 上已经有一些中断处于关闭状态。当你调用 spin_unlock_irq() 时它不仅会释放锁还会重新开启中断但这种行为可能是不正确的因为它并不知道在加锁之前哪些中断是开启的哪些是关闭的。这使得 spin_lock_irq() 在“已经处于关中断上下文IRQs off-context”时变得不安全因为它对应的 spin_unlock_irq() 会简单粗暴地重新开启中断从而可能错误地开启那些在调用 spin_lock_irq() 之前本来就是关闭的中断。因此只有在你明确知道当前本地 CPU 的中断是开启状态时才适合使用 spin_lock_irq()也就是说你必须确保在调用它之前没有其他代码已经关闭了本地 CPU 的中断。现在设想一种更好的方式在获取锁之前保存当前中断状态并在释放锁时完全恢复它那么就不会再出现上述问题。为此Linux 内核提供了 _irqsave 版本的函数它的行为与 _irq 版本类似但额外增加了“保存并恢复中断状态”的功能。这些函数是 spin_lock_irqsave() 和 spin_unlock_irqrestore()定义如下spin_lock_irqsave(spinlock_t *lock, unsigned long flags) spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)spin_lock() 以及它的所有变体都会自动调用 preempt_disable()从而在本地 CPU 上禁用抢占而 spin_unlock() 及其变体则会调用 preempt_enable()尝试重新启用抢占。注意——这里说的是“尝试”启用因为是否真正启用取决于当前是否还有其他自旋锁仍然被持有这会影响抢占计数器preemption counter的值。如果条件满足preempt_enable() 内部还可能会调用 schedule() 进行调度取决于计数器当前的值而该值应该为 0。因此spin_unlock() 本身也是一个抢占点并且可能会重新开启抢占。虽然禁用中断可以防止内核抢占例如调度器的时钟中断被关闭但并不能阻止被保护的代码段主动调用调度器schedule() 函数。许多内核函数都会间接调用调度器例如那些涉及自旋锁的函数。因此即使是一个简单的 printk() 调用也可能会触发调度器因为它会使用保护内核消息缓冲区的自旋锁。内核通过增加或减少一个全局以及 per-CPU 变量默认值为 0表示允许调度来控制调度器的启用或禁用这个变量叫做 preempt_count。当该变量大于 0 时schedule() 函数会检查这一点调度器会直接返回而不执行任何操作。这个变量会在每次调用 spin_lock* 系列函数时递增而在释放自旋锁spin_unlock* 系列函数时递减。当该计数从 1 减到 0 时调度器可能会被重新触发这意味着你的临界区实际上并不是完全原子的。因此仅仅通过禁用中断只能在“被保护代码本身不会触发调度”的情况下防止内核抢占。另外需要注意的是持有自旋锁的代码不能进入睡眠状态因为如果睡眠就无法被唤醒要记住此时本地 CPU 的定时器中断和调度器可能已经被关闭。

更多文章