4、自旋锁原理:自旋锁的忙等待机制、原子操作与内存屏障、自旋锁的变体
自旋锁(spinlock)是嵌入式 Linux 驱动中最轻量级的互斥原语之一,专为短临界区设计。其核心思想是:当锁被持有时,等待线程不会睡眠,而是在一个紧凑的循环中“原地打转”,持续检查锁是否已被释放。这种忙等待(busy-waiting)机制避免了线程切换的开销,但代价是消耗 CPU 时间片。
4.1 忙等待机制与自旋锁的工作流程
自旋锁的“自旋”行为本质上是一个原子测试并设置(test-and-set)循环。当一个 CPU 核心尝试获取已被其他核心持有的锁时,它会在本地缓存中反复读取锁变量的值,直到该值变为可用。这种机制在多核系统上非常高效,因为自旋期间 CPU 缓存一致性协议(如 MESI)会保证锁变量的可见性。
下图展示了两个 CPU 核心竞争同一自旋锁时的典型时序:
从图中可以看出,自旋锁的忙等待机制存在一个明显的权衡:以 CPU 时间片换取低延迟。如果临界区很短,自旋的开销远小于线程切换(上下文切换通常需要几微秒);但如果临界区较长,自旋锁会浪费大量 CPU 周期。
| 特性 | 自旋锁(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);
}
spin_trylock,硬件会确保只有一个 CPU 成功将锁变量从 0 改为 1,另一个 CPU 读到的是旧值 1,从而自旋等待。
4.2.2 内存屏障:保证可见性与顺序
即使有了原子操作,现代 CPU 的乱序执行(out-of-order execution)和写缓冲(store buffer)机制仍然可能导致锁语义被破坏。例如,CPU 在释放锁之前,临界区内的写操作可能还没有刷新到内存,导致另一个 CPU 获取锁后读到过时的数据。
内存屏障(memory barrier)强制约束 CPU 和编译器对内存访问指令的重排。自旋锁的获取和释放操作隐含了特定的屏障语义:
- 获取锁(ACQUIRE 语义): 保证锁获取之后的所有内存访问(读/写)不会重排到锁获取之前。相当于一个“读屏障 + 写屏障”的组合。
- 释放锁(RELEASE 语义): 保证锁释放之前的所有内存访问(读/写)不会重排到锁释放之后。相当于一个“写屏障”。
下图展示了内存屏障如何保护临界区的数据一致性:
4.3 自旋锁的变体
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);
}
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 决策图帮助开发者在不同上下文中快速选择正确的自旋锁变体:
4.5 自旋锁使用避坑清单
- 禁止在持有自旋锁时调用可能睡眠的函数: 如
kmalloc(GFP_KERNEL)、copy_from_user()、mutex_lock()。这会导致内核调度,而锁未被释放,可能引发死锁。 - 注意锁的嵌套层级: 如果必须嵌套获取多个自旋锁,始终按相同的全局顺序获取(锁排序),避免 ABBA 死锁。
- 关中断变体不能用于中断上下文: 在中断处理函数中调用
spin_lock_irq()会错误地重新开启中断,应使用spin_lock()(中断上下文本身已关中断)。 - 读写锁不是万能药: 在写者频繁时,读写锁的读者可能被写者长时间阻塞,性能反而不如普通自旋锁。使用前应通过性能测试验证。
- 单核系统上的自旋锁: 在单核 UP(Uni-Processor)系统中,自旋锁的忙等待退化为空操作,但关中断变体仍然有效(用于防止中断竞争)。