Linux 内核锁

最后更新: 2024-11-26 05:37:18 +0800 +0800
tags: 内核 | Linux | 锁
原文以英文撰写。您正在阅读AI翻译版本。

内核锁定从执行上下文开始。对进程上下文调用方正确的锁,当同一数据从软中断、硬中断或不可屏蔽中断(NMI)访问时可能死锁。原语及其后缀必须匹配访问受保护状态的所有上下文。

自旋锁语义 #

使用以下方式声明并初始化普通自旋锁:

c
static DEFINE_SPINLOCK(queue_lock);

struct queue {
    spinlock_t lock;
};

spin_lock_init(&queue->lock);

在非 PREEMPT_RT 的 SMP 内核上,spin_lock() 禁止本地 CPU 上的抢占,并旋转等待获取独占所有权。它不会禁用本地中断或软中断。持有者不得睡眠、显式调度、执行阻塞分配,或调用可能获取睡眠锁的代码。

在单处理器非 RT 构建中,由于没有其他 CPU 竞争,部分实现可能编译为抢占和编译器控制。正确性不能依赖该优化;同一源代码必须在 SMP 上仍然有效。

PREEMPT_RT 会改变 spinlock_t 和 rwlock_t 的语义,使其使用基于 RT-mutex 的实现并保持可抢占。需要在 RT 和非 RT 内核之间真正自旋、不可抢占锁的代码使用 raw_spinlock_t,但原始临界区必须保持很小,且不能调用在 RT 上会变为睡眠的代码。替换在锁头文件和核心代码内部实现;应用程序代码不应使用配置条件重复实现。

匹配进程、软中断和硬中断上下文 #

选择能够阻止同一 CPU 重新进入的最窄变体:

数据访问来源进程上下文获取中断侧获取
仅进程上下文spin_lock()不适用
进程上下文和软中断/taskletspin_lock_bh()软中断中使用 spin_lock()
进程上下文和硬中断spin_lock_irqsave()处理程序中使用 spin_lock()
已知 IRQ 状态的硬中断上下文spin_lock_irq() 或 spin_lock_irqsave()根据调用方状态

spin_lock_bh() 在获取锁前禁用本地下半部。spin_lock_irq() 禁用本地可屏蔽中断。spin_lock_irqsave(lock, flags) 还会保存之前的中断状态,而 spin_unlock_irqrestore(lock, flags) 恢复该确切状态:

c
unsigned long flags;

spin_lock_irqsave(&device->lock, flags);
update_device_state(device);
spin_unlock_irqrestore(&device->lock, flags);

在一个词法上的获取/释放对中使用同一个 flags 变量,不要假设进入时中断已启用。禁用本地中断可防止持有者 CPU 上的中断等待自己的锁;自旋锁本身会排除其他 CPU 上运行的处理程序。

如果数据只在硬中断实例之间共享,并且中断线无法在同一 CPU 上重新进入,处理程序可以使用 spin_lock()。进程上下文访问者仍必须使用禁用 IRQ 的变体。

NMI 上下文需要独立设计 #

local_irq_disable() 和 spin_lock_irqsave() 不会屏蔽 NMI。如果 NMI 处理程序获取了被中断代码可能在同一 CPU 上持有的锁,NMI 会永久自旋,因为被中断的持有者无法恢复。

NMI 共享数据因此需要为 NMI 设计协议:

  • 使用 NMI 安全原子操作进行无锁或每 CPU 记录;
  • 使用序列计数器模式,使写入者不会以导致死锁的方式被 NMI 读取者抢占;
  • 当可以接受丢弃 NMI 侧观察时使用 raw_spin_trylock();
  • 仅当每条获取路径都证明不会发生 NMI 重入时才使用原始锁。

“原始”表示锁在 PREEMPT_RT 上保留自旋语义;它不会使任意临界区都具备 NMI 安全性。

获取和释放顺序 #

锁获取具有获取语义,解锁具有释放语义。因此,对于使用同一锁保护相同数据的另一个 CPU,临界区内的操作会有序:

c
spin_lock(&state->lock);
state->payload = payload;
state->ready = true;
spin_unlock(&state->lock);

之后成功获取锁的调用方会观察到解锁前受保护的写入。当某些读取者有意不加锁访问数据时,锁定不是协议的替代品。这些读取者可能需要 READ_ONCE()、释放/获取发布、RCU 或显式屏障。Linux 内存屏障文档还区分锁获取/释放排序与一般完整内存屏障;需要临界区外排序的代码必须使用文档规定的屏障原语。

锁顺序仍是独立要求。如果一条路径先获取 A 再获取 B,另一条路径不能先获取 B 再获取 A。IRQ 状态变体可以防止本地中断反转,但不能防止跨锁循环。

架构实现 #

公共 spinlock_t API 在通用锁头文件中定义,并通过原始锁代码映射到架构操作。在 SMP 上,raw_spinlock_t 包含一个 arch_spinlock_t 以及可选的调试和 lockdep 状态。架构选择适合其原子指令和虚拟化环境的实现。

x86 使用排队自旋锁基础设施。无竞争路径在紧凑的锁字上操作;有竞争的调用方形成队列,而不是所有调用方反复使同一缓存行失效。半虚拟化钩子可以替换部分等待和唤醒行为。架构代码提供排序和原子原语,通用代码提供 API 一致性、检测以及依赖配置的转换。

源代码布局不是 ABI。应从同一修订版本的 include/linux/spinlock*.h、kernel/locking/ 和 arch/<arch>/include/asm/ 验证特定内核。

竞争和调试 #

竞争会浪费 CPU 时间,并在处理器之间移动锁的缓存行。保持自旋锁临界区有界,将分配和慢速 I/O 移到临界区外,分区不相关状态,避免为每 CPU 数据获取一个全局锁。将一个锁替换为多个锁可以减少竞争,但会增加顺序和生命周期复杂性,因此更改设计前应先测量。

内核设施有助于建立事实:

  • CONFIG_LOCKDEP 和 lockdep 注解检测已观察到的和潜在的依赖循环、错误 IRQ 使用和上下文不匹配。
  • CONFIG_DEBUG_SPINLOCK 增加所有权和状态检查。
  • CONFIG_PROVE_LOCKING 启用更广泛的锁定验证。
  • lockstat 和 perf lock 报告竞争和获取行为。

调试构建会改变时序和开销。干净的 lockdep 运行可以提高信心,但不能证明未执行路径具有正确的生命周期或内存排序协议。

读写自旋锁 #

rwlock_t 允许并发读取者或一个写入者:

c
static DEFINE_RWLOCK(routes_lock);
unsigned long flags;

read_lock_irqsave(&routes_lock, flags);
route = lookup_route(key);
read_unlock_irqrestore(&routes_lock, flags);

write_lock_irqsave(&routes_lock, flags);
replace_route(key, replacement);
write_unlock_irqrestore(&routes_lock, flags);

读写自旋锁需要比普通自旋锁更多的原子操作。它们不提供可移植的公平性保证;工作负载和实现可能让写入者在读取者之后延迟,或让读取者在写入者之后延迟。不能安全地将读锁升级为写锁。如果操作可能修改结构,应释放并在写锁下重试,或从一开始就获取写锁。

新的使用场景应仔细审查:

  • 当所有访问都在可睡眠的进程上下文中,尤其是持有者可能阻塞时,使用互斥锁。
  • 当读取占主导、读取者可以接受其访问模型,并且更新可以延迟回收时,使用 RCU。
  • 当读取者可以重试且写入者可以串行化时,对小型快照使用序列锁/计数器。
  • 当临界区很短时,使用普通自旋锁;较低的读取侧复杂性可能胜过 rwlock_t。

选择取决于上下文、持有时间、竞争、读写比例和对象生命周期,而不是并行读取者这一抽象概念的吸引力。

参考资料 #

评论

在 GitHub 上阅读或评论(发表评论需要登录)