3、自旋锁原理:自旋锁的工作机制、忙等待与上下文切换的权衡、自旋锁的原子操作实现
3.1 自旋锁的核心工作机制
自旋锁(Spinlock)是 Linux 内核中最基础的同步原语之一,专为短临界区设计。其核心思想是:当锁被其他 CPU 持有时,当前 CPU 不会主动让出处理器(不进入睡眠状态),而是通过忙等待(Busy-Waiting)循环检测锁状态,直到锁被释放。
关键特性:自旋锁的等待线程不会发生上下文切换,因此避免了切换带来的开销,但代价是持续占用 CPU 执行无效循环。
自旋锁的典型工作流程如下:
- 尝试获取锁:调用
spin_lock(),通过原子操作尝试将锁变量从“未锁定”状态置为“锁定”状态。 - 成功:若原子操作成功,当前 CPU 进入临界区执行。
- 失败:若锁已被其他 CPU 持有,当前 CPU 进入自旋循环,反复读取锁变量,直到锁变为可用。
- 释放锁:调用
spin_unlock(),通过原子操作将锁变量置回“未锁定”状态。
3.2 忙等待与上下文切换的权衡
自旋锁的“忙等待”特性是一把双刃剑。理解其权衡对于性能优化至关重要。
| 对比维度 | 自旋锁(忙等待) | 互斥锁(睡眠等待) |
|---|---|---|
| 等待行为 | CPU 循环检测,不调度 | 线程进入睡眠,触发调度 |
| 上下文切换 | 无切换,零调度延迟 | 两次切换(入眠+唤醒) |
| CPU 利用率 | 等待时 100% 占用 CPU | 等待时 CPU 可运行其他任务 |
| 适用临界区 | 极短(通常 < 1000 条指令) | 较长或可能阻塞 |
| 中断上下文 | 可用(中断处理中) | 不可用(可能引起死锁) |
性能权衡原则:如果临界区的执行时间 小于 两次上下文切换的开销(通常约 1-5 μs),则自旋锁更优;反之,互斥锁更优。现代内核通过
spin_lock_irqsave() 等变体在中断上下文中安全使用自旋锁。
上下文切换的成本包括:保存/恢复寄存器、TLB 刷新、缓存污染、调度器运行。对于高频短临界区(如网络栈的 sk_buff 操作),自旋锁的忙等待避免了这些开销,是正确选择。
3.3 自旋锁的原子操作实现
自旋锁的核心依赖于原子操作,确保在多 CPU 并发访问时锁状态的一致性。Linux 内核中,自旋锁的底层实现经历了从纯原子变量到排队自旋锁的演进。
3.3.1 经典实现:基于原子交换
早期的自旋锁实现使用 atomic_xchg(原子交换)指令:
// 自旋锁结构体
typedef struct {
volatile unsigned int lock; // 0: 未锁定, 1: 锁定
} spinlock_t;
// 获取锁(简化版)
void spin_lock(spinlock_t *lock) {
// 原子交换:将 lock 置为 1,并返回旧值
while (__atomic_exchange_n(&lock->lock, 1, __ATOMIC_ACQUIRE) != 0) {
// 自旋:CPU 忙等待
cpu_relax(); // 提示 CPU 当前处于自旋循环
}
}
// 释放锁
void spin_unlock(spinlock_t *lock) {
__atomic_store_n(&lock->lock, 0, __ATOMIC_RELEASE);
}
问题:经典实现存在“先到先得”不公平问题。当锁释放时,所有等待的 CPU 同时尝试原子交换,导致大量缓存一致性流量(缓存行 bouncing),影响性能。
3.3.2 现代实现:排队自旋锁(Ticket Spinlock)
Linux 内核引入了 Ticket Spinlock 机制,保证公平性并减少缓存竞争。其核心思想类似于“排队叫号”:
- 锁结构:包含两个字段——
owner(当前持有者的票号)和next(下一个可用的票号)。 - 获取锁:原子地获取
next值作为自己的票号,然后自旋等待直到owner等于自己的票号。 - 释放锁:将
owner加 1,通知下一个等待者。
// 排队自旋锁结构(x86 架构)
typedef struct {
union {
u32 slock; // 整体 32 位
struct {
u16 owner; // 当前持有者票号
u16 next; // 下一个可用票号
};
};
} arch_spinlock_t;
// 获取锁(x86 汇编级实现)
static __always_inline void arch_spin_lock(arch_spinlock_t *lock) {
u32 val;
u16 ticket, head;
// 步骤1: 原子地获取 next 值,并将 next 加 1
asm volatile (
"1: lock; xaddw %w0, %1\n"
: "+r" (ticket), "+m" (lock->slock)
: "0" (1)
: "memory"
);
// 步骤2: 获取当前 owner 值
head = lock->owner;
// 步骤3: 自旋等待,直到 owner 等于自己的票号
while (head != ticket) {
cpu_relax(); // 自旋提示
head = READ_ONCE(lock->owner);
}
}
// 释放锁
static __always_inline void arch_spin_unlock(arch_spinlock_t *lock) {
// 将 owner 加 1,通知下一个等待者
__add(&lock->owner, 1);
}
3.4 自旋锁的变体与使用场景
Linux 内核提供了多种自旋锁变体,以适应不同的上下文需求:
| API | 描述 | 典型场景 |
|---|---|---|
spin_lock() |
标准自旋锁,不关中断 | 进程上下文,已知不会被中断打断 |
spin_lock_irq() |
获取锁并关闭本地 CPU 中断 | 中断处理程序与进程上下文共享数据 |
spin_lock_irqsave() |
获取锁并保存中断状态后关中断 | 嵌套中断场景,需恢复原始中断状态 |
spin_lock_bh() |
获取锁并禁止下半部(softirq) | 进程上下文与 softirq/tasklet 共享数据 |
最佳实践:在中断上下文中必须使用
spin_lock_irqsave() 或 spin_lock_irq(),否则当前 CPU 获取锁后可能被中断打断,而中断处理程序又尝试获取同一把锁,导致死锁。
3.5 自旋锁的性能陷阱与优化
即使自旋锁设计精巧,使用不当仍会导致严重性能问题:
- 临界区过长:自旋锁的忙等待会浪费大量 CPU 周期。如果临界区执行时间超过 1000 条指令,应考虑使用互斥锁。
- 锁争用过高:多个 CPU 频繁竞争同一把锁,导致大量缓存一致性流量。可通过锁拆分(如 per-CPU 锁)或 RCU 优化。
- 优先级反转:低优先级任务持有锁,高优先级任务自旋等待。实时系统需使用
rt_mutex或优先级继承协议。 - NUMA 感知缺失:在 NUMA 系统中,跨节点访问锁变量会增加延迟。可使用
ticket_spinlock的 NUMA 感知变体。