锁的重新划分
PREEMPT_RT 最根本的一刀:把 spinlock_t 从"自旋 + 关抢占"改成"可睡眠 + 优先级继承"。本章回答:为什么非这样不可、具体怎么实现、哪些锁必须保持原样、以及这给内核编码带来了哪些新规矩。
3.1问题:一把锁,两种语义
在非 RT 内核里,spin_lock() 做两件事:
- 互斥:同一时刻只有一个 CPU 进入临界区——这是锁"本来的工作";
- 关抢占(
_irqsave版本还关中断)——持锁者不会被调度走,因此别的 CPU 自旋等待的时间是有界的。
第二件事是实现手段,却在几十年里被全内核的代码当成了"语义"来依赖:持着 spinlock 就能安全访问 per-CPU 变量、就隐含处在 RCU 读侧、就不会被同 CPU 的其它任务打断。结果是,内核里每一个 spinlock_t 临界区都是一个不可抢占区。一个 SCHED_FIFO 99 的任务被唤醒时,如果当前 CPU 正处在某个 spinlock 里,它只能等这个临界区结束。
CPU0 ─ task L ─[spin_lock ███████ spin_unlock]─ ...
↑ 此刻 task H 被唤醒
│ 必须等到 spin_unlock 后
└─ preempt_enable() 才是抢占点
最坏延迟 = 全内核最长的那个 spinlock 临界区,而内核有上万个。
CPU0 ─ task L ─[spin_lock ███ ┊被抢占┊ ██ spin_unlock]
↑ task H 被唤醒 → 立即运行
若 H 也要这把锁 → 睡眠 + 把优先级
借给 L(PI),L 尽快跑完临界区
最坏延迟只取决于真正必须原子的那一小部分代码(raw 区)。
要缩短最坏延迟,只有两条路:把每个临界区都改短(不可能,上万处),或者让临界区本身可以被抢占。PREEMPT_RT 选择了后者——但"可抢占的自旋锁"是个矛盾:如果持锁者被抢占了,等待者自旋就可能永远转下去。所以等待者必须改为睡眠,而一旦睡眠,就会出现优先级反转,于是又必须配上优先级继承(第 4 章)。这三件事环环相扣:
flowchart LR
A["目标:缩短最坏延迟"] --> B["spinlock 临界区必须可抢占"]
B --> C["持锁者可能被调度走"]
C --> D["等待者不能自旋,必须睡眠"]
D --> E["睡眠锁 = 优先级反转可能无界"]
E --> F["必须有优先级继承 PI"]
F --> G["spinlock_t 基于 rtmutex 实现"]
C --> H["per-CPU 数据失去保护"]
H --> I["migrate_disable 代替 preempt_disable"]
B --> J["少数代码真的不能睡"]
J --> K["raw_spinlock_t 保持原语义"]
classDef rt fill:#f6e3dc,stroke:#b5371c,color:#1d1d1f
class G,I,K rt
3.2锁的三分法
权威描述在 Documentation/locking/locktypes.rst。它把内核锁分成三类,RT 的改造正是沿着这三类的边界划下去的:
- Sleeping locks(睡眠锁):
mutex、rt_mutex、semaphore、rw_semaphore、ww_mutex、percpu_rw_semaphore;在 RT 上还包括local_lock、spinlock_t、rwlock_t。 - CPU local locks:
local_lock。 - Spinning locks(真自旋):
raw_spinlock_t、bit spinlock;在非 RT 上还包括spinlock_t、rwlock_t。
raw_spinlock_t 保持真自旋| 类型 | 非 RT | PREEMPT_RT | 持有期间能否被抢占(RT) | 能否在硬中断上下文使用(RT) |
|---|---|---|---|---|
raw_spinlock_t | 自旋 + 关抢占 | 不变 | 否 | 能 |
spinlock_t | 自旋 + 关抢占 | rtmutex(state-preserving) | 能 | 不能 |
rwlock_t | 自旋读写锁 | rwbase_rt + rtmutex | 能 | 不能 |
local_lock_t | preempt_disable() / 关中断 | per-CPU spinlock_t + migrate_disable() | 能 | 不能 |
mutex | 原生 mutex(无 PI) | rtmutex(有 PI) | 能 | 不能 |
rw_semaphore | 原生 rwsem(写者公平) | rwbase_rt(写者不公平) | 能 | 不能 |
semaphore | 计数信号量 | 不变(无 owner,无法 PI) | 能 | 不能 |
seqcount_spinlock_t 等 | 写侧关抢占 | 写侧可抢占,读者遇写者就去"锁一下再放" | 能 | — |
注意 API 一行都没改。spinlock_t 的定义在编译期切换:
#else /* !CONFIG_PREEMPT_RT */
/* PREEMPT_RT kernels map spinlock to rt_mutex */
#include <linux/rtmutex.h>
typedef struct spinlock {
struct rt_mutex_base lock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} spinlock_t;
mutex 也一样(include/linux/mutex_types.h:56 起),RT 下 struct mutex 里只有一个 rt_mutex_base。这就是为什么 PREEMPT_RT 的模块 ABI 与非 RT 不兼容——锁的大小和布局都变了。
3.3地基:rt_mutex_base
struct rt_mutex_base {
raw_spinlock_t wait_lock; /* 保护 waiters 树,自身是真自旋锁 */
struct rb_root_cached waiters; /* 按优先级排序的等待者红黑树,缓存最左节点 */
struct task_struct *owner; /* 持有者;bit0 = RT_MUTEX_HAS_WAITERS */
};
三个字段奠定了一切:
owner指针本身就是锁。task_struct至少按字对齐,最低位空出来当 "有等待者" 标志(RT_MUTEX_HAS_WAITERS = 1UL,kernel/locking/rtmutex_common.h:156)。无竞争时加锁就是一条cmpxchg(owner, NULL, current),解锁是cmpxchg(owner, current, NULL)——和自旋锁一样便宜。waiters是按优先级排序的红黑树,而不是 FIFO 链表——唤醒时永远给最高优先级的等待者(top waiter),这是确定性的前提。wait_lock是 raw 锁——"睡眠锁"的内部仍需要一把真自旋锁来保护自己的数据结构,只是它的临界区极短且有界。
owner 低位的四种状态(kernel/locking/rtmutex.c:66 注释):
| owner | bit0 | 含义 |
|---|---|---|
| NULL | 0 | 空闲,可走快路径获取 |
| NULL | 1 | 空闲但有等待者,top waiter 即将拿锁(或慢路径中的过渡态) |
| task | 0 | 被持有,可走快路径释放 |
| task | 1 | 被持有且有等待者——快路径 cmpxchg 必然失败,强制进慢路径 |
bit0 的作用就是"把所有人赶进慢路径":只要有人在排队,任何加锁/解锁都必须拿 wait_lock 串行化,这样 PI 的记账才不会被快路径绕过。
rt_mutex_cmpxchg_acquire() 最终是 try_cmpxchg_acquire()。在支持 LSE(ARMv8.1+)的 CPU 上编译成一条 CASA 指令;老核心上走 LDAXR/STXR 循环。也就是说 RT 下无竞争的 spin_lock() 在 ARM64 上与非 RT 的 qspinlock 快路径同一量级,开销主要多在 migrate_disable() 和 rcu_read_lock() 两次 per-task 计数(见下)。
3.4spinlock_t → rt_spin_lock()
快路径
spin_lock() 在 RT 下直接重定向(include/linux/spinlock_rt.h:42):
static __always_inline void rtlock_lock(struct rt_mutex_base *rtm)
{
lockdep_assert(!current->pi_blocked_on);
if (unlikely(!rt_mutex_cmpxchg_acquire(rtm, NULL, current)))
rtlock_slowlock(rtm);
}
static __always_inline void __rt_spin_lock(spinlock_t *lock)
{
rtlock_might_resched();
rtlock_lock(&lock->lock);
rcu_read_lock(); /* ← 补回"隐含 RCU 读侧"语义 */
migrate_disable(); /* ← 补回"不会换 CPU"语义 */
}
最后两行是整个设计的精髓。文件顶部注释(kernel/locking/spinlock_rt.c:14)直说:
Non RT spin/rwlocks disable preemption and eventually interrupts. Disabling preemption has the side effect of disabling migration and preventing RCU grace periods. The RT substitutions explicitly disable migration and take rcu_read_lock() across the lock held section.
非 RT 下 preempt_disable() 白送了两个副作用,海量代码在不知不觉中依赖了它们:
| 被依赖的副作用 | 非 RT 如何"白送" | RT 如何显式补回 |
|---|---|---|
| 持锁期间不会换 CPU(per-CPU 指针有效) | 关抢占 ⇒ 不迁移 | migrate_disable():可被抢占,但只能在本 CPU 上恢复运行 |
| 持锁期间处于 RCU 读侧 | 非抢占 RCU 下关抢占 = 读侧 | rcu_read_lock()(PREEMPT_RCU 的计数式读侧,见第 7 章) |
| 持锁期间同 CPU 其他任务不会进来 | 关抢占 | 由锁本身的互斥保证(别人进来会阻塞在锁上) |
持锁期间硬中断 handler 不会进来(_irqsave) | 关中断 | 不再保证——但中断 handler 已线程化,它要拿锁也会阻塞 |
解锁的顺序也有讲究(kernel/locking/spinlock_rt.c:78):migrate_enable() → cmpxchg 释放(失败则 rt_mutex_slowunlock())→ 最后 rcu_read_unlock()。注释里给了一个 use-after-free 例子:如果先结束 RCU 读侧,锁所在的对象可能在 rt_mutex_cmpxchg_release() 写它之前就被 kfree_rcu 回收掉。
慢路径:先自旋,再睡眠
flowchart TD
A["spin_lock(lock)"] --> B{"cmpxchg owner: NULL → current"}
B -- 成功 --> Z["rcu_read_lock + migrate_disable<br/>进入临界区"]
B -- 失败 --> C["rtlock_slowlock:拿 wait_lock(raw,关中断)"]
C --> D{"try_to_take_rt_mutex"}
D -- 拿到 --> Y["释放 wait_lock"] --> Z
D -- 没拿到 --> E["保存任务状态到 saved_state<br/>__state = TASK_RTLOCK_WAIT"]
E --> F["task_blocks_on_rt_mutex<br/>入 waiters 树;若成为 top waiter<br/>则提升 owner 优先级 PI"]
F --> G{"try_to_take_rt_mutex"}
G -- 拿到 --> R["恢复 saved_state"] --> Y
G -- 没拿到 --> H["释放 wait_lock"]
H --> I{"我是 top waiter 且<br/>owner 正在别的 CPU 上运行?"}
I -- 是 --> J["rtmutex_spin_on_owner<br/>自适应自旋"]
J -- "owner 换了 / 自旋失败" --> K
I -- 否 --> K["schedule_rtlock 睡眠"]
J -- "owner 释放了" --> L["重新拿 wait_lock"]
K --> L
L --> G
classDef rt fill:#f6e3dc,stroke:#b5371c,color:#1d1d1f
classDef lock fill:#f5e1eb,stroke:#8b3a62,color:#1d1d1f
class F,K rt
class C,L lock
rtlock_slowlock_locked() 的控制流(kernel/locking/rtmutex.c:1838)static void __sched rtlock_slowlock_locked(struct rt_mutex_base *lock,
struct wake_q_head *wake_q)
{
struct rt_mutex_waiter waiter; /* 在等待者自己的内核栈上 */
struct task_struct *owner;
if (try_to_take_rt_mutex(lock, current, NULL))
return;
rt_mutex_init_rtlock_waiter(&waiter);
/* Save current state and set state to TASK_RTLOCK_WAIT */
current_save_and_set_rtlock_wait_state();
task_blocks_on_rt_mutex(lock, &waiter, current, NULL, RT_MUTEX_MIN_CHAINWALK, wake_q);
for (;;) {
if (try_to_take_rt_mutex(lock, current, &waiter))
break;
if (&waiter == rt_mutex_top_waiter(lock))
owner = rt_mutex_owner(lock);
else
owner = NULL;
preempt_disable();
raw_spin_unlock_irq(&lock->wait_lock);
wake_up_q(wake_q);
wake_q_init(wake_q);
preempt_enable();
if (!owner || !rtmutex_spin_on_owner(lock, &waiter, owner))
schedule_rtlock();
raw_spin_lock_irq(&lock->wait_lock);
set_current_state(TASK_RTLOCK_WAIT);
}
/* Restore the task state */
current_restore_rtlock_saved_state();
...
}
自适应自旋(adaptive spinning)
大多数 spinlock 临界区只有几十到几百纳秒。如果每次争用都睡眠-唤醒,两次上下文切换(微秒级)的代价远大于等待本身。所以 rtmutex_spin_on_owner()(kernel/locking/rtmutex.c:1486)让top waiter 在 owner 正在另一个 CPU 上运行时先原地转圈。停止自旋的条件:
- owner 被调度出去了(
!owner_on_cpu(owner))——它不在跑,转圈没意义; - 自己不再是 top waiter——来了更高优先级的等待者,让它去等;
need_resched()——本 CPU 上有更高优先级的任务要跑,自旋绝不能挡住它;- owner 所在的 vCPU 被宿主抢占(
owner_on_cpu()内部检查vcpu_is_preempted())。
这样就同时得到了两个世界的优点:短临界区像自旋锁一样不切换上下文,长临界区(或持锁者被抢占)时像 mutex 一样让出 CPU,且自旋期间仍可被抢占。
Lock stealing:为什么 RT 任务不允许"横向偷锁"
解锁时锁并不直接"交给" top waiter,而是唤醒它、让它自己来抢。中间如果有别的任务来拿锁,是否允许它插队?rt_mutex_steal()(kernel/locking/rtmutex.c:429)的规则:
- 优先级严格高于 top waiter:允许偷;
- 同优先级(lateral steal):仅
spinlock_t/rwlock_t的 rtlock 允许,且只对普通任务开放——注释:"RT tasks are excluded from same priority (lateral) steals to prevent the introduction of an unbounded latency."
对 SCHED_OTHER 而言,横向偷锁提高吞吐(被唤醒的任务往往还没来得及跑);对 RT 任务而言,同优先级反复被插队会造成无界等待,所以禁止。另外,__waiter_prio()(kernel/locking/rtmutex.c:348)把所有非 RT/DL 任务的排队优先级统一视作 DEFAULT_PRIO——nice 值不参与锁排队,普通任务之间按 FIFO。
TASK_RTLOCK_WAIT:不丢失任何唤醒
这是"把 spinlock 变成睡眠锁"时最容易被忽略的陷阱。考虑经典的等待循环:
set_current_state(TASK_INTERRUPTIBLE); /* ① 准备睡眠 */
spin_lock(&q->lock); /* ② 非 RT:不改变任务状态 */
if (!condition)
...
spin_unlock(&q->lock);
schedule(); /* ③ 真正睡眠 */
非 RT 下第 ② 步不会睡,任务状态不受影响。RT 下第 ② 步可能阻塞——如果它简单地把状态改成 TASK_UNINTERRUPTIBLE 睡眠、拿到锁后改回 TASK_RUNNING,那么:(a) ① 设置的状态丢了,③ 不会睡;(b) 更糟的是,如果在 ② 阻塞期间有人发来了"条件满足"的普通唤醒,这个唤醒会把任务从锁等待中错误地唤醒。
RT 的解决方法是给任务两个状态槽(include/linux/sched.h:279):
#define current_save_and_set_rtlock_wait_state() \
do { \
lockdep_assert_irqs_disabled(); \
raw_spin_lock(¤t->pi_lock); \
current->saved_state = current->__state; \
debug_rtlock_wait_set_state(); \
WRITE_ONCE(current->__state, TASK_RTLOCK_WAIT); \
raw_spin_unlock(¤t->pi_lock); \
} while (0);
唤醒端 ttwu_state_match()(kernel/sched/core.c:3991)据此区分两种唤醒:
stateDiagram-v2
direction LR
S0: __state=INTERRUPTIBLE
S1: __state=RTLOCK_WAIT, saved=INTERRUPTIBLE
S2: __state=RTLOCK_WAIT, saved=RUNNING
S3: 拿到锁, __state=INTERRUPTIBLE
S4: 拿到锁, __state=RUNNING
[*] --> S0: set_current_state
S0 --> S1: spin_lock 阻塞,保存状态
S1 --> S2: 普通唤醒 wake_up,只改 saved_state,不唤醒任务
S1 --> S3: 锁唤醒 TASK_RTLOCK_WAIT,恢复 saved_state
S2 --> S4: 锁唤醒,恢复 saved_state
S3 --> [*]: 后续 schedule 正常睡眠
S4 --> [*]: 后续 schedule 立即返回,唤醒没丢
锁的唤醒以 TASK_RTLOCK_WAIT 为匹配状态(rt_mutex_waiter::wake_state),普通 wake_up() 以 TASK_NORMAL 匹配——两者位不相交,所以永远不会互相误伤。
schedule_rtlock() 与 SM_RTLOCK_WAIT
#ifdef CONFIG_PREEMPT_RT
void __sched notrace schedule_rtlock(void)
{
__schedule_loop(SM_RTLOCK_WAIT);
}
#endif
为什么不直接调 schedule()?__schedule() 开头(kernel/sched/core.c:6617)有一句注释:"On PREEMPT_RT kernel, SM_RTLOCK_WAIT is noted as a preemption by schedule_debug() and RCU."——因为 rt_spin_lock() 调用方可能已经持有别的 spinlock,从而处在 rcu_read_lock() 里;用普通 schedule 会触发"在 RCU 读侧里自愿睡眠"的告警。SM_RTLOCK_WAIT 让 RCU 把它当成"被抢占的读者"处理(第 7 章 blkd_tasks),但任务状态检查仍按正常阻塞走(preempt = sched_mode == SM_PREEMPT 只对真抢占成立)。此外 schedule() 会调 sched_submit_work() 刷 plug、通知 workqueue,这些操作本身可能拿 spinlock——在 spinlock 慢路径里递归就乱套了,所以 rtlock 绕开它。
调用栈:一次 spin_lock 争用
- irq_thread() → irq_forced_thread_fn() → mydrv_handler()kernel/irq/manage.c中断线程上下文,SCHED_FIFO 50
- spin_lock(&dev->lock)include/linux/spinlock_rt.h:42
- rt_spin_lock()kernel/locking/spinlock_rt.c:54
- __rt_spin_lock() → rtlock_lock()spinlock_rt.c:46 / :38cmpxchg(owner, NULL, current) 失败
- rtlock_slowlock()kernel/locking/rtmutex.c:1893raw_spin_lock_irqsave(&lock->wait_lock)
- rtlock_slowlock_locked()rtmutex.c:1838current_save_and_set_rtlock_wait_state()
- task_blocks_on_rt_mutex(…, RT_MUTEX_MIN_CHAINWALK)rtmutex.c:1202入 waiters 树;若成为 top waiter → rt_mutex_adjust_prio(owner) → rt_mutex_setprio() 提升持锁者
- …… 回到 rtlock_slowlock_locked 的 for 循环 ……
- rtmutex_spin_on_owner()rtmutex.c:1486owner 在别的 CPU 运行则先自旋;owner 被调度出去则放弃
- schedule_rtlock()kernel/sched/core.c:6884
- __schedule_loop(SM_RTLOCK_WAIT) → __schedule()core.c:6803 / :6613rcu_note_context_switch(preempt=true);任务以 TASK_RTLOCK_WAIT 出队
- context_switch() → __switch_to() → cpu_switch_toarch/arm64/kernel/entry.S
- spin_unlock() → rt_spin_unlock()kernel/locking/spinlock_rt.c:78先 migrate_enable();cmpxchg(owner, current, NULL) 因 bit0=1 失败
- rt_mutex_slowunlock()kernel/locking/rtmutex.c:1416拿 wait_lock
- mark_wakeup_next_waiter()rtmutex.c:1317从自己的 pi_waiters 摘掉 top waiter 并去 boost;owner 置为 RT_MUTEX_HAS_WAITERS;preempt_disable()
- rt_mutex_wake_q_add()rtlock 等待者记到 rtlock_task,wake_state = TASK_RTLOCK_WAIT
- rt_mutex_wake_up_q()rtmutex.c:562wake_up_state(task, TASK_RTLOCK_WAIT);随后 preempt_enable() —— 被唤醒的高优先级任务在此立即抢占
- rcu_read_unlock()最后一步,防 UAF
为什么"先去 boost、再唤醒,并用 preempt_disable() 包住"?mark_wakeup_next_waiter() 的注释解释:如果先唤醒再去 boost,会短暂出现两个"同一优先级"的任务;如果去 boost 后、唤醒前被抢占,那个等待者就会被晾着——又一次优先级反转。所以去 boost 和唤醒之间不允许抢占。
3.5spin_lock_irqsave() 不再关中断
#define spin_lock_irqsave(lock, flags) \
do { \
typecheck(unsigned long, flags); \
flags = 0; \
spin_lock(lock); \
} while (0)
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
rt_spin_lock(lock);
}
flags 只是个占位符。这看起来很危险——非 RT 内核里用 _irqsave 就是为了防止"本 CPU 持锁时被中断打断、中断 handler 又来拿同一把锁"的自死锁。为什么 RT 可以不关?
因为在 RT 下,会拿 spinlock_t 的中断 handler 已经不在硬中断上下文里运行了(第 5 章:force_irqthreads() 恒真)。handler 跑在 irq/N-xxx 线程里,它来拿锁时只是一个普通的阻塞+PI,不存在"在持锁者的栈上自死锁"的问题。反过来,仍然在真硬中断上下文运行的代码(标了 IRQF_NO_THREAD 的 handler、per-CPU 中断、hrtimer HARD 回调、irq_work HARD)绝对不能拿 spinlock_t——它们只能用 raw_spinlock_t。
推论:任何"用 spin_lock_irqsave 来挡住硬中断"的代码在 RT 下都是错的。真正需要挡硬中断(比如和一个 IRQF_NO_THREAD handler 共享数据)就必须用 raw_spinlock_t,或者显式 local_irq_save() + raw 锁。
3.6什么必须是 raw_spinlock_t
RT 下 raw_spinlock_t 是唯一真正的自旋锁,它的临界区就是内核里剩下的"不可抢占区"。因此它的使用者被严格限制在无法睡眠的上下文,或实现睡眠本身的代码:
| 使用者 | 例子 | 为什么不能睡 |
|---|---|---|
| 调度器 | rq->__lock(kernel/sched/sched.h:1134)、task->pi_lock | 睡眠的实现者:拿 rq 锁时调用 schedule 会无限递归 |
| rtmutex 自身 | rt_mutex_base::wait_lock | 睡眠锁的内部状态需要一把真锁 |
| 中断子系统 | irq_desc->lock(include/linux/irqdesc.h:83)、GICv3 ITS 的 its_lock 等(drivers/irqchip/irq-gic-v3-its.c:189) | 在真硬中断上下文里被访问 |
| 时间子系统 | hrtimer_cpu_base::lock(include/linux/hrtimer_defs.h:104) | hrtimer_interrupt 在硬中断里运行 |
| RCU | rcu_node::lock | 在 tick/调度器路径里报告静止态 |
| printk 原子路径 | nbcon 的 console 所有权 | panic/NMI 也要能输出 |
raw 区内的约束(违反即 bug,详见第 10 章红线):
- ❌ 不能拿
spinlock_t/rwlock_t/mutex等任何 RT 下可睡眠的锁; - ❌ 不能分配或释放内存(SLUB 的 per-CPU 快路径用的是
local_lock,第 7 章); - ❌ 不能
wake_up()一个等待队列(waitqueue 头的锁是spinlock_t)——要用swait或rcuwait,或者推迟到 raw 区外; - ⚠️ 临界区必须短且有界——它直接构成系统最坏延迟的一部分。把一个
spinlock_t改成raw_spinlock_t来"修"RT 警告之前,要先证明临界区有界。
3.7读写锁:放弃写者公平
rwlock_t 和 rw_semaphore 在 RT 下共用 kernel/locking/rwbase_rt.c。核心难题是:优先级继承要求有一个确定的"持有者"可以被提升,而读锁有多个持有者。
文件头注释(kernel/locking/rwbase_rt.c:4)给出了算法和取舍:
- 写者:先拿内部 rtmutex → 去掉
READER_BIAS迫使新读者进慢路径 → 等现有读者全部离开 → 标记写锁定。 - 读者:
readers计数为负(BIAS 置位)时 cmpxchg +1 就是快路径;否则拿wait_lock检查是否已被写锁定,是则阻塞在那个 rtmutex 上——于是读者可以把优先级借给写者。 - 反过来,写者等待读者时无法提升多个读者("We can't use multi-reader inheritance because there is no way to support that with SCHED_DEADLINE")。
写者公平:写者排队后,新来的读者也排在它后面,防止写者饥饿。
写者不公平:只要还有读者持锁,新读者可以继续进来(只要没写锁定)。一个被抢占的低优先级读者会让高优先级写者一直等——locktypes.rst 明说 RT 任务应避免做写者("think mmap_sem")。
3.8local_lock_t:给隐式语义起个名字
非 RT 内核里大量出现这种模式:
preempt_disable(); /* 实际意图:保护 this_cpu 的 foo_pcp */
p = this_cpu_ptr(&foo_pcp);
p->count++;
list_add(&x, &p->list);
preempt_enable();
问题有两个:(1) 看代码的人(和 lockdep)不知道 preempt_disable() 保护的到底是什么;(2) RT 下这段一律不可抢占。local_lock 把它变成一把有名字、有作用域的锁:
struct foo_pcp { local_lock_t lock; int count; struct list_head list; };
static DEFINE_PER_CPU(struct foo_pcp, foo_pcp) = { .lock = INIT_LOCAL_LOCK(lock) };
local_lock(&foo_pcp.lock);
p = this_cpu_ptr(&foo_pcp);
...
local_unlock(&foo_pcp.lock);
/* PREEMPT_RT */
typedef spinlock_t local_lock_t;
#define __local_lock(__lock) \
do { \
migrate_disable(); \
spin_lock(this_cpu_ptr((__lock))); \
} while (0)
#define __local_lock_irqsave(lock, flags) \
do { \
typecheck(unsigned long, flags); \
flags = 0; \
__local_lock(lock); \
} while (0)
RT 下它是"本 CPU 专属的一把 spinlock_t":先 migrate_disable() 保证 this_cpu_ptr() 选中的锁和后面访问的数据属于同一个 CPU,然后拿锁保证同 CPU 上被抢占后进来的其他任务会阻塞(并 PI)。非 RT 下它编译成原来的 preempt_disable(),零开销,但 lockdep 能追踪它。SLUB、page allocator 的 pcp、网络栈、softirq_ctrl(第 6 章)都是它的用户。
3.9migrate_disable():钉住 CPU 但允许抢占
kernel/sched/core.c:2323。它只是在 current->migration_disabled 上计数;调度器在选择迁移目标、负载均衡、set_cpus_allowed 时都会尊重这个计数。被抢占的 migrate-disabled 任务只能在原 CPU 上恢复。代价:
- 负载均衡变差——一个被抢占的低优先级任务可能被钉在一个忙 CPU 上,而另一个 CPU 空闲;
- 它不是 PI 的一部分:如果高优先级任务需要的东西在被钉住的任务手里,仍要靠锁的 PI 来解决;
- CPU 热插拔需要等待 migrate-disabled 任务离开该区间。
详细的调度器侧实现在 第 2 章。
3.10seqcount:写者被抢占时读者会活锁
seqcount 的读者不加锁,只是"读序号 → 读数据 → 再读序号,若期间有写者(序号为奇数或变了)就重试"。非 RT 下写侧在关抢占的 spinlock 里,写者不可能被同 CPU 的读者抢占,所以读者最多空转一小段时间。
RT 下写侧可抢占了:如果一个高优先级读者抢占了同 CPU 上正在写的低优先级写者,读者会看到奇数序号永远重试——写者没机会跑完,活锁。解决办法(include/linux/seqlock_types.h:41)是让 seqcount 记住"保护写侧的那把锁"(seqcount_spinlock_t、seqcount_mutex_t 等),读者发现写者在进行中就去锁一下再放掉:
static __always_inline unsigned
__seqprop_##lockname##_sequence(const seqcount_##lockname##_t *s)
{
unsigned seq = smp_load_acquire(&s->seqcount.sequence);
if (!IS_ENABLED(CONFIG_PREEMPT_RT))
return seq;
if (preemptible && unlikely(seq & 1)) {
__SEQ_LOCK(lockbase##_lock(s->lock)); /* 阻塞在写者持有的锁上 → PI boost 写者 */
__SEQ_LOCK(lockbase##_unlock(s->lock));
seq = smp_load_acquire(&s->seqcount.sequence);
}
return seq;
}
"lock-unlock" 是 RT 里反复出现的模式:把"自旋等待某人完成"改写成"在那人持有的锁上阻塞",于是等待自动带上 PI,持有者被提升、尽快完成。第 6 章 tasklet_unlock_spin_wait() 用 local_bh_disable/enable 也是同一个思路。纯 seqcount_t(不关联锁)和 seqcount_raw_spinlock_t 没法这么做,所以它们的写侧必须仍然是不可抢占的。
3.11lockdep wait-type:在非 RT 内核上抓 RT bug
RT 的改造涉及全内核上万把锁,靠人工审查不可能保证正确。真正让 RT 能合入主线的工程手段是 lockdep 的 wait type(include/linux/lockdep_types.h:16):
enum lockdep_wait_type {
LD_WAIT_INV = 0, /* not checked, catch all */
LD_WAIT_FREE, /* wait free, rcu etc.. */
LD_WAIT_SPIN, /* spin loops, raw_spinlock_t etc.. */
#ifdef CONFIG_PROVE_RAW_LOCK_NESTING
LD_WAIT_CONFIG, /* preemptible in PREEMPT_RT, spinlock_t etc.. */
#else
LD_WAIT_CONFIG = LD_WAIT_SPIN,
#endif
LD_WAIT_SLEEP, /* sleeping locks, mutex_t etc.. */
LD_WAIT_MAX, /* must be last */
};
每把锁、每种上下文都带一个等级,规则是:在持有等级为 X 的锁(或处于等级 X 的上下文)时,只能获取等级 ≤ X 的锁。
关键是 LD_WAIT_CONFIG:它表示"在当前配置下自旋,但在 RT 下会睡眠"。打开 CONFIG_PROVE_RAW_LOCK_NESTING(lib/Kconfig.debug:1398)后,lockdep 在普通内核上就会报告这种嵌套(下面是格式示意,锁名和地址是虚构的):
=============================
[ BUG: Invalid wait context ]
-----------------------------
swapper/0/1 is trying to lock:
ffff0000c0a1e018 (&dev->lock){....}-{3:3}, at: mydrv_update+0x2c/0x90
other info that might help us debug this:
context-{5:5}
1 lock held by swapper/0/1:
#0: ffff0000c0a1e000 (&desc->lock){-.-.}-{2:2}, at: ...
{3:3} 是 spinlock_t(CONFIG=3),{2:2} 是 raw 锁(SPIN=2)——在 SPIN 上下文里拿 CONFIG 级的锁,报错。报错位置在 kernel/locking/lockdep.c:4787。RT 内核自己则通过 might_sleep()/__might_resched() 报经典的 "BUG: sleeping function called from invalid context"。
3.12mutex 在 RT 下也换了实现
即使原本就是睡眠锁的 mutex 也被换掉了(kernel/locking/rtmutex_api.c:498 起):
static __always_inline int __mutex_lock_common(struct mutex *lock,
unsigned int state, unsigned int subclass,
struct lockdep_map *nest_lock, unsigned long ip)
{
int ret;
might_sleep();
mutex_acquire_nest(&lock->dep_map, subclass, 0, nest_lock, ip);
ret = __rt_mutex_lock(&lock->rtmutex, state);
...
}
原因很简单:原生 mutex 没有优先级继承,按 FIFO 排队唤醒。在一个 spinlock 都会 PI 的系统里,mutex 若不 PI,就成了整条依赖链上的薄弱环节。semaphore 则保持不变——它没有 owner 概念(可以由别的任务 up()),根本无从继承;因此 RT 下应尽量避免在关键路径上用 semaphore 和 completion 当锁使用。
3.13变化与代价
临界区可抢占
最坏调度延迟不再由最长 spinlock 临界区决定,只剩 raw 区 + 硬中断入口。
锁等待按优先级
waiters 红黑树 + PI:高优先级任务总是先拿到锁,持锁者被提升。
上下文切换
争用从"转几百个周期"变成可能的睡眠-唤醒;自适应自旋能挽回大部分,但不是全部。
Lock holder preemption
持锁者被抢占后,所有等待者都要等它重新被调度——PI 只保证"会被提升",不保证"马上跑完"。
每次加解锁更重
额外的 migrate_disable/rcu_read_lock 计数,raw wait_lock,PI 记账;吞吐敏感路径会看到差距。
新的编码纪律
raw 区不能分配内存、不能 wake_up、不能拿 spinlock_t;不能依赖隐式副作用。
3.14本章小结
PREEMPT_RT 把 spinlock_t 的两种语义拆开:互斥交给 rtmutex(可睡眠 + PI),"不迁移、在 RCU 读侧"由 migrate_disable() + rcu_read_lock() 显式补回,"不被中断打断"由中断线程化(第 5 章)间接保证;真正不能睡的那一小撮代码改用 raw_spinlock_t,并由 lockdep wait-type 在编译期之外的运行期强制检查。
- RT 的 spinlock 是 state-preserving 的:
TASK_RTLOCK_WAIT+saved_state保证外层睡眠语义和唤醒都不丢。 - "自旋等待"在 RT 下应改写成"在对方持有的锁上阻塞"(seqcount 的 lock-unlock)。
- 读写锁放弃了写者公平,信号量不能 PI——这两类锁在 RT 关键路径上要慎用。
- 下一章深入 PI 本身:rtmutex 如何沿着阻塞链传播优先级、如何检测死锁,以及用户态如何通过 PI futex 用上同一套机制。