2、内存锁基础:自旋锁、互斥锁、读写锁在PREEMPT_RT下的行为变化与替换原则
在标准 Linux 内核中,自旋锁、互斥锁和读写锁是三种最基础的同步原语。PREEMPT_RT 补丁通过将自旋锁和读写锁替换为可睡眠的 rt-mutex 实现,从根本上改变了锁的行为语义。理解这些变化是排查实时系统内存锁冲突的前提。
核心观点: PREEMPT_RT 下,所有“持有者不可抢占”的锁都被替换为“持有者可被抢占”的 rt-mutex 变体。这意味着锁的等待队列、优先级继承和中断上下文使用规则都发生了质变。
2.1 标准内核 vs PREEMPT_RT 锁实现对比
下表从实现机制、等待行为、中断上下文兼容性和优先级处理四个维度,对比三种锁在标准内核与 PREEMPT_RT 下的差异。
| 锁类型 | 维度 | 标准内核 (PREEMPT_NONE/VOLUNTARY) | PREEMPT_RT 内核 |
|---|---|---|---|
| 自旋锁 (spinlock) | 实现机制 | 原子操作 + 忙等待 (spin) | rt-mutex + 可睡眠等待 |
| 等待行为 | 持有者禁止抢占,等待者原地循环 | 持有者可被抢占,等待者进入睡眠队列 | |
| 中断上下文 | 允许在中断处理程序中使用 (spin_lock_irqsave) | 禁止在硬中断上下文使用;必须使用 raw_spinlock |
|
| 优先级处理 | 无优先级继承,优先级反转风险高 | 内置优先级继承 (PI),防止无限反转 | |
| 互斥锁 (mutex) | 实现机制 | 基于 struct mutex,快速路径原子操作 |
基于 rt-mutex,与自旋锁统一底层 |
| 等待行为 | 睡眠等待,持有者禁止中断上下文 | 睡眠等待,支持优先级继承 | |
| 中断上下文 | 禁止使用 | 禁止使用 | |
| 优先级处理 | 无优先级继承 (标准 mutex) | 完整优先级继承链 | |
| 读写锁 (rwlock) | 实现机制 | 原子计数器 + 忙等待 | rt-mutex + 读写语义封装 |
| 等待行为 | 读者与写者均忙等待,读者可并发 | 读者与写者均睡眠等待,写者优先 | |
| 中断上下文 | 允许在中断中使用 (read_lock_irqsave) | 禁止;必须使用 raw_rwlock |
|
| 优先级处理 | 无优先级继承,写者饥饿常见 | 写者优先级提升,减少饥饿 |
排查提示: 在 PREEMPT_RT 内核中,如果看到
BUG: sleeping function called from invalid context 且调用栈指向 spin_lock,通常是因为代码在硬中断上下文使用了已被替换为可睡眠锁的自旋锁。此时应改用 raw_spin_lock。
2.2 自旋锁的 rt-mutex 化与 raw_spinlock 保留
PREEMPT_RT 将 spinlock_t 的实现替换为 rt-mutex,但保留了 raw_spinlock_t 作为真正的、不可睡眠的自旋锁。这一替换是 PREEMPT_RT 锁语义变化的核心。
/* 标准内核:spinlock_t 是真正的自旋锁 */
/* PREEMPT_RT 内核:spinlock_t 被重定义为 rt-mutex */
struct spinlock_t {
struct rt_mutex lock; /* 可睡眠的 rt-mutex */
};
/* raw_spinlock_t 始终保持原始自旋语义 */
struct raw_spinlock_t {
arch_spinlock_t raw_lock; /* 架构相关的原子自旋锁 */
};
替换原则:
- 中断上下文必须使用 raw_spinlock:硬中断处理程序、NMI、以及任何不允许睡眠的上下文,必须使用
raw_spin_lock()/raw_spin_unlock()。 - 进程上下文优先使用 spinlock:在进程上下文或软中断中,使用
spin_lock()即可享受优先级继承带来的实时性保障。 - 临界区极短时考虑 raw_spinlock:如果临界区只有几条原子指令,且持有时间远小于一次上下文切换开销,使用 raw_spinlock 可能获得更低延迟。
常见错误: 在 PREEMPT_RT 下,
spin_lock_irqsave() 会关闭本地中断并获取一个可睡眠锁。如果该锁被高优先级任务持有,当前任务可能睡眠,但本地中断已被关闭——这会导致中断丢失。正确的做法是使用 raw_spin_lock_irqsave() 处理真正的硬件临界区。
2.3 互斥锁的优先级继承增强
标准内核的 struct mutex 在 PREEMPT_RT 下被统一为 rt-mutex 实现。最大的变化是 优先级继承 (Priority Inheritance, PI) 成为默认行为,而非可选特性。
优先级继承的作用:
- 当低优先级任务持有互斥锁,而高优先级任务正在等待该锁时,低优先级任务临时继承高优先级任务的优先级。
- 这防止了中间优先级任务抢占低优先级任务,从而避免无限优先级反转。
- 在 PREEMPT_RT 中,所有
mutex_lock()调用都自动启用 PI,无需显式使用mutex_lock_pi()。
/* 标准内核:普通 mutex,无 PI */
struct mutex m;
mutex_lock(&m);
/* 临界区 */
mutex_unlock(&m);
/* PREEMPT_RT 内核:所有 mutex 自动获得 PI 能力 */
/* 代码写法不变,但底层行为已变 */
struct mutex m;
mutex_lock(&m); /* 内部使用 rt_mutex,支持 PI */
/* 临界区 */
mutex_unlock(&m);
调试技巧: 使用
trace-cmd 或 ftrace 的 rt-mutex 事件可以观察优先级继承链的传递过程。例如:echo 1 > /sys/kernel/tracing/events/rtmutex/enable。
2.4 读写锁的写者优先与睡眠化
读写锁在 PREEMPT_RT 下的变化最为剧烈。标准内核中读者可以并发访问且不会阻塞彼此,但在 PREEMPT_RT 中,读者和写者都通过 rt-mutex 进行睡眠等待,且写者具有优先权。
行为变化要点:
- 读者不再并发:所有读者在获取锁时都会进入 rt-mutex 等待队列,读者之间不再能真正并行执行临界区。
- 写者优先策略:当写者等待时,新到达的读者会被阻塞,直到写者完成操作。这减少了写者饥饿,但降低了读并发性。
- 中断上下文限制:与自旋锁类似,
rwlock_t在中断上下文中必须替换为raw_rwlock_t。
/* PREEMPT_RT 下读写锁的替换原则 */
/* 场景 1:进程上下文,读多写少,但可接受读并发降低 */
rwlock_t my_rwlock;
read_lock(&my_rwlock); /* 实际变为 rt-mutex 睡眠等待 */
/* 读临界区 */
read_unlock(&my_rwlock);
/* 场景 2:中断上下文,必须使用 raw 变体 */
raw_rwlock_t my_raw_rwlock;
read_lock_irqsave(&my_raw_rwlock, flags);
/* 读临界区 */
read_unlock_irqrestore(&my_raw_rwlock, flags);
性能陷阱: 在 PREEMPT_RT 下,如果读临界区非常短且读操作极其频繁,使用
rwlock_t 可能导致性能严重下降,因为每次读操作都会引起一次 rt-mutex 的获取/释放(包含优先级继承开销)。此时应考虑使用 atomic_t 或 RCU 替代。
2.5 锁替换原则速查表
以下速查表总结了在 PREEMPT_RT 内核中,针对不同上下文和临界区特性,应选择的锁类型。
| 使用场景 | 临界区特性 | 推荐锁类型 | 说明 |
|---|---|---|---|
| 硬中断 / NMI | 极短,不可睡眠 | raw_spinlock_t |
唯一选择,保持原子自旋语义 |
| 软中断 / tasklet | 较短,可睡眠 | spinlock_t 或 raw_spinlock_t |
如果临界区极短且频繁,考虑 raw 变体 |
| 进程上下文 (实时线程) | 中等长度,可睡眠 | mutex_t 或 spinlock_t |
mutex 提供 PI,spinlock 在 RT 下也是可睡眠的 |
| 进程上下文 (读多写少) | 读操作频繁 | RCU 或 atomic_t |
避免使用 rwlock,因其读并发已丧失 |
| 进程上下文 (必须读写锁语义) | 写者优先需求 | rwlock_t (RT 变体) |
接受读串行化,利用写者优先特性 |
| 跨 CPU 数据交换 | 极短,无睡眠 | atomic_t / smp_mb() |
无锁方案,避免任何锁开销 |
最佳实践: 在 PREEMPT_RT 下,优先使用
mutex 进行进程上下文同步,因为它自动提供优先级继承且语义清晰。仅在中断上下文或对延迟有极端要求的场景使用 raw_spinlock。尽量避免使用 rwlock,除非你明确需要写者优先语义且能接受读性能下降。