自旋锁(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 内核中,抢占状态依赖于一个每 CPU(per-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 的定时器中断和调度器可能已经被关闭)。