4、自旋锁原理:自旋锁的忙等待机制、原子操作与内存屏障、自旋锁的变体

自旋锁(spinlock)是嵌入式 Linux 驱动中最轻量级的互斥原语之一,专为短临界区设计。其核心思想是:当锁被持有时,等待线程不会睡眠,而是在一个紧凑的循环中“原地打转”,持续检查锁是否已被释放。这种忙等待(busy-waiting)机制避免了线程切换的开销,但代价是消耗 CPU 时间片。

适用场景判断: 自旋锁适用于临界区执行时间极短(通常少于几十微秒)、且持有锁期间不允许睡眠的上下文(如中断处理函数、软中断、tasklet)。如果临界区可能阻塞或耗时较长,应使用 mutex 或 completion。

4.1 忙等待机制与自旋锁的工作流程

自旋锁的“自旋”行为本质上是一个原子测试并设置(test-and-set)循环。当一个 CPU 核心尝试获取已被其他核心持有的锁时,它会在本地缓存中反复读取锁变量的值,直到该值变为可用。这种机制在多核系统上非常高效,因为自旋期间 CPU 缓存一致性协议(如 MESI)会保证锁变量的可见性。

下图展示了两个 CPU 核心竞争同一自旋锁时的典型时序:

自旋锁忙等待机制:双核竞争时序 CPU 0:尝试获取锁 CPU 1:持有锁(临界区执行中) 锁变量 = LOCKED CPU 1:释放锁(写回 LOCKED → UNLOCKED) 锁变量 = UNLOCKED 自旋期间 CPU 0 持续执行 while 循环,不睡眠、不调度,直到锁变量变为 UNLOCKED

从图中可以看出,自旋锁的忙等待机制存在一个明显的权衡:以 CPU 时间片换取低延迟。如果临界区很短,自旋的开销远小于线程切换(上下文切换通常需要几微秒);但如果临界区较长,自旋锁会浪费大量 CPU 周期。

自旋锁忙等待 vs 睡眠等待(mutex)对比
特性自旋锁(spinlock)互斥锁(mutex)
等待行为忙等待(CPU 循环检查)睡眠等待(线程切换)
临界区耗时极短(< 几十微秒)可长可短(支持阻塞)
中断上下文允许(需关中断变体)不允许(可能睡眠)
CPU 消耗自旋期间 100% 占用等待时 CPU 可做其他工作
适用场景硬件寄存器操作、链表遍历文件 I/O、内存分配、用户态交互

4.2 原子操作与内存屏障

自旋锁的实现离不开两个底层硬件原语:原子操作和内存屏障。没有它们,自旋锁在多核乱序执行的环境下将完全失效。

4.2.1 原子操作:不可分割的测试与设置

自旋锁的核心是一个原子“测试并设置”(test-and-set)指令,例如 ARM 架构上的 LDREX/STREX 或 x86 上的 LOCK CMPXCHG。这些指令保证:在锁变量上执行读取、修改、写入三个步骤时,不会被其他核心的操作打断。

Linux 内核封装了通用的原子 API,自旋锁底层依赖 atomic_t 类型和 atomic_* 系列函数:

/* 自旋锁的核心原子操作(简化伪代码) */
typedef struct {
    volatile unsigned int lock;  /* 0 = unlocked, 1 = locked */
} spinlock_t;

/* 尝试获取锁:原子地将 lock 从 0 交换为 1 */
static inline int spin_trylock(spinlock_t *sl) {
    unsigned int old;
    /* 使用架构相关的原子交换指令 */
    old = __atomic_exchange_n(&sl->lock, 1, __ATOMIC_ACQUIRE);
    return old == 0;  /* 返回 1 表示获取成功 */
}

/* 释放锁:原子地将 lock 写回 0 */
static inline void spin_unlock(spinlock_t *sl) {
    __atomic_store_n(&sl->lock, 0, __ATOMIC_RELEASE);
}
关键点: 原子操作保证了“读-改-写”的不可分割性。如果两个 CPU 同时执行 spin_trylock,硬件会确保只有一个 CPU 成功将锁变量从 0 改为 1,另一个 CPU 读到的是旧值 1,从而自旋等待。

4.2.2 内存屏障:保证可见性与顺序

即使有了原子操作,现代 CPU 的乱序执行(out-of-order execution)和写缓冲(store buffer)机制仍然可能导致锁语义被破坏。例如,CPU 在释放锁之前,临界区内的写操作可能还没有刷新到内存,导致另一个 CPU 获取锁后读到过时的数据。

内存屏障(memory barrier)强制约束 CPU 和编译器对内存访问指令的重排。自旋锁的获取和释放操作隐含了特定的屏障语义:

  • 获取锁(ACQUIRE 语义): 保证锁获取之后的所有内存访问(读/写)不会重排到锁获取之前。相当于一个“读屏障 + 写屏障”的组合。
  • 释放锁(RELEASE 语义): 保证锁释放之前的所有内存访问(读/写)不会重排到锁释放之后。相当于一个“写屏障”。

下图展示了内存屏障如何保护临界区的数据一致性:

内存屏障在自旋锁中的作用 CPU A(写者) spin_lock(&lock) // ACQUIRE 屏障 临界区:data = 42; flag = 1; spin_unlock(&lock) // RELEASE 屏障 CPU B(读者) spin_lock(&lock) // ACQUIRE 屏障 临界区:读 data 和 flag spin_unlock(&lock) // RELEASE 屏障 数据可见性 RELEASE 屏障保证 CPU A 的 data/flag 写入在解锁前全局可见;ACQUIRE 屏障保证 CPU B 在加锁后才能看到这些写入
常见陷阱: 在自旋锁保护的临界区内,不要依赖编译器屏障(barrier())代替硬件内存屏障。编译器屏障只阻止编译器重排,无法约束 CPU 的乱序执行。自旋锁的 spin_lock/spin_unlock 已经包含了完整的硬件屏障,开发者通常不需要手动添加额外的屏障。

4.3 自旋锁的变体

Linux 内核针对不同的上下文和性能需求,提供了多种自旋锁变体。选择合适的变体是驱动开发者的基本功。

Linux 内核自旋锁变体一览
变体名称类型/宏中断上下文性能开销典型使用场景
普通自旋锁spinlock_t不允许(可能死锁)最低进程上下文,无中断竞争
关中断自旋锁spin_lock_irq()允许(本地中断关闭)中等中断处理函数与进程上下文共享数据
保存状态关中断spin_lock_irqsave()允许(保存中断状态)中等+嵌套中断或不确定中断状态时
底半部自旋锁spin_lock_bh()允许(关闭软中断)中等tasklet/软中断与进程上下文共享
读写自旋锁rwlock_t取决于变体略高于普通读多写少的共享数据
顺序锁seqlock_t取决于变体写者优先写者多、读者可容忍重读

4.3.1 关中断变体:spin_lock_irq() 与 spin_lock_irqsave()

当自旋锁可能被中断处理函数和进程上下文同时访问时,必须使用关中断变体。否则,进程上下文持有锁时被中断打断,中断处理函数尝试获取同一把锁,将导致死锁(中断处理函数自旋等待,而锁持有者被中断挂起,永远不会释放锁)。

/* 错误示例:普通自旋锁在中断上下文中可能导致死锁 */
spinlock_t my_lock;
void irq_handler(void) {
    spin_lock(&my_lock);  /* 如果进程上下文已持有锁,这里死锁! */
    /* 临界区 */
    spin_unlock(&my_lock);
}

void process_context(void) {
    spin_lock(&my_lock);  /* 持有锁 */
    /* 临界区执行中,此时中断到来... */
    spin_unlock(&my_lock);
}

/* 正确做法:使用关中断变体 */
void irq_handler(void) {
    spin_lock(&my_lock);  /* 中断中不需要再关中断(已经隐式关闭) */
    /* 临界区 */
    spin_unlock(&my_lock);
}

void process_context(void) {
    unsigned long flags;
    spin_lock_irqsave(&my_lock, flags);  /* 关闭本地中断并保存状态 */
    /* 临界区,此时中断被屏蔽 */
    spin_unlock_irqrestore(&my_lock, flags);  /* 恢复之前的中断状态 */
}
最佳实践: 在驱动中,如果不确定当前中断是否已开启,始终使用 spin_lock_irqsave() 和 spin_unlock_irqrestore() 配对。它们比 spin_lock_irq() 更安全,因为后者假设中断总是开启的,在嵌套中断场景下可能错误地重新开启中断。

4.3.2 底半部变体:spin_lock_bh()

底半部(softirq/tasklet)与进程上下文共享数据时,使用 spin_lock_bh()。它关闭本地 CPU 的软中断,防止底半部在进程上下文持有锁时被调度执行。

/* 底半部与进程上下文共享数据 */
spinlock_t dev_lock;
struct net_device *dev_data;

void process_context_read(void) {
    spin_lock_bh(&dev_lock);  /* 关闭软中断 */
    /* 读取 dev_data */
    spin_unlock_bh(&dev_lock);
}

void tasklet_handler(unsigned long data) {
    spin_lock_bh(&dev_lock);  /* tasklet 中也可以使用 bh 变体 */
    /* 修改 dev_data */
    spin_unlock_bh(&dev_lock);
}

4.3.3 读写自旋锁(rwlock_t)

当临界区被频繁读取、偶尔写入时,读写自旋锁允许多个读者同时进入临界区,但写者必须独占访问。这能显著提升读多写少场景的并发性能。

rwlock_t my_rwlock = __RW_LOCK_UNLOCKED(my_rwlock);

/* 读者路径:多个读者可以同时进入 */
void read_data(void) {
    read_lock(&my_rwlock);
    /* 只读操作 */
    read_unlock(&my_rwlock);
}

/* 写者路径:必须等待所有读者释放,且独占 */
void write_data(void) {
    write_lock(&my_rwlock);
    /* 修改操作 */
    write_unlock(&my_rwlock);
}
使用限制: 读写自旋锁在写者频繁的场景下性能可能劣于普通自旋锁,因为写者会“饿死”(需要等待所有读者释放)。此外,读写锁不支持递归读(同一个读者不能嵌套获取读锁)。在大多数现代内核中,推荐优先使用 RCU(Read-Copy-Update)替代读写锁。

4.3.4 顺序锁(seqlock_t)

顺序锁是一种写者优先的锁机制,适用于写者多、读者可以容忍偶尔读到不一致数据并重试的场景。读者不需要阻塞写者,只需在读取前后检查一个序列计数器。

seqlock_t my_seq = __SEQLOCK_UNLOCKED(my_seq);

/* 写者:获取写锁,阻塞所有其他写者和读者 */
void writer(void) {
    write_seqlock(&my_seq);
    /* 修改数据 */
    write_sequnlock(&my_seq);
}

/* 读者:不阻塞写者,但可能重试 */
void reader(void) {
    unsigned int seq;
    do {
        seq = read_seqbegin(&my_seq);  /* 读取开始序列号 */
        /* 读取数据(可能不一致) */
    } while (read_seqretry(&my_seq, seq));  /* 如果序列号变化,重试 */
}

顺序锁在内核中常用于时间戳、系统统计信息等场景,例如 ktime_get() 和 jiffies 的读取就使用了 seqlock。

4.4 自旋锁变体选择决策树

下面的 SVG 决策图帮助开发者在不同上下文中快速选择正确的自旋锁变体:

自旋锁变体选择决策树 选择自旋锁变体 临界区是否可能被中断/底半部访问? 否:普通 spinlock_t 是:需要关中断/底半部 访问来源是中断还是底半部? 中断:spin_lock_irqsave() 底半部:spin_lock_bh() 读多写少? 是:rwlock_t 否:spinlock_t 提示:如果写者较多且读者可容忍重试,考虑 seqlock_t;如果读者不需要阻塞写者,考虑 RCU。 在中断嵌套不确定时,始终优先使用 spin_lock_irqsave() 而非 spin_lock_irq()。

4.5 自旋锁使用避坑清单

  • 禁止在持有自旋锁时调用可能睡眠的函数: 如 kmalloc(GFP_KERNEL)、copy_from_user()、mutex_lock()。这会导致内核调度,而锁未被释放,可能引发死锁。
  • 注意锁的嵌套层级: 如果必须嵌套获取多个自旋锁,始终按相同的全局顺序获取(锁排序),避免 ABBA 死锁。
  • 关中断变体不能用于中断上下文: 在中断处理函数中调用 spin_lock_irq() 会错误地重新开启中断,应使用 spin_lock()(中断上下文本身已关中断)。
  • 读写锁不是万能药: 在写者频繁时,读写锁的读者可能被写者长时间阻塞,性能反而不如普通自旋锁。使用前应通过性能测试验证。
  • 单核系统上的自旋锁: 在单核 UP(Uni-Processor)系统中,自旋锁的忙等待退化为空操作,但关中断变体仍然有效(用于防止中断竞争)。
核心原则总结: 自旋锁是“短平快”的锁——临界区要短、持有时间要平(不阻塞)、使用要快(选对变体)。在中断上下文或底半部中,务必使用对应的关中断/关底半部变体,否则死锁只是时间问题。