CHAPTER 03

锁的重新划分

PREEMPT_RT 最根本的一刀:把 spinlock_t 从"自旋 + 关抢占"改成"可睡眠 + 优先级继承"。本章回答:为什么非这样不可、具体怎么实现、哪些锁必须保持原样、以及这给内核编码带来了哪些新规矩。

3.1问题:一把锁,两种语义

在非 RT 内核里,spin_lock() 做两件事:

  1. 互斥:同一时刻只有一个 CPU 进入临界区——这是锁"本来的工作";
  2. 关抢占(_irqsave 版本还关中断)——持锁者不会被调度走,因此别的 CPU 自旋等待的时间是有界的。

第二件事是实现手段,却在几十年里被全内核的代码当成了"语义"来依赖:持着 spinlock 就能安全访问 per-CPU 变量、就隐含处在 RCU 读侧、就不会被同 CPU 的其它任务打断。结果是,内核里每一个 spinlock_t 临界区都是一个不可抢占区。一个 SCHED_FIFO 99 的任务被唤醒时,如果当前 CPU 正处在某个 spinlock 里,它只能等这个临界区结束。

非 RT:spinlock 临界区 = 不可抢占区
CPU0  ─ task L ─[spin_lock ███████ spin_unlock]─ ...
                         ↑ 此刻 task H 被唤醒
                         │ 必须等到 spin_unlock 后
                         └─ preempt_enable() 才是抢占点

最坏延迟 = 全内核最长的那个 spinlock 临界区,而内核有上万个。

PREEMPT_RT: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
      
从"让 spinlock 可抢占"一路推出的连锁改造

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。
API(驱动/子系统看到的) 非 RT 实现 PREEMPT_RT 实现 raw_spinlock_t spinlock_t rwlock_t local_lock_t mutex / ww_mutex rw_semaphore 自旋 + preempt_disable qspinlock / qrwlock _irqsave 版本再关中断 等待者原地转圈 临界区 = 不可抢占区 preempt_disable / local_irq_* 原生 mutex / rwsem osq 乐观自旋 + 等待队列 无优先级继承 不变:仍自旋 + 关抢占 rt_mutex_base kernel/locking/rtmutex.c cmpxchg 快路径 自适应自旋 按优先级排队睡眠 优先级继承 PI spinlock: spinlock_rt.c rwlock / rwsem: rwbase_rt.c mutex: rtmutex_api.c 临界区可抢占 API 不变,实现在编译期按 CONFIG_PREEMPT_RT 切换 local_lock → per-CPU spinlock_t
同一套 API,RT 下几乎全部收敛到 rtmutex;只有 raw_spinlock_t 保持真自旋
类型非 RTPREEMPT_RT持有期间能否被抢占(RT)能否在硬中断上下文使用(RT)
raw_spinlock_t自旋 + 关抢占不变否能
spinlock_t自旋 + 关抢占rtmutex(state-preserving)能不能
rwlock_t自旋读写锁rwbase_rt + rtmutex能不能
local_lock_tpreempt_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 注释):

ownerbit0含义
NULL0空闲,可走快路径获取
NULL1空闲但有等待者,top waiter 即将拿锁(或慢路径中的过渡态)
task0被持有,可走快路径释放
task1被持有且有等待者——快路径 cmpxchg 必然失败,强制进慢路径

bit0 的作用就是"把所有人赶进慢路径":只要有人在排队,任何加锁/解锁都必须拿 wait_lock 串行化,这样 PI 的记账才不会被快路径绕过。

ARM64

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(&current->pi_lock);			\
		current->saved_state = current->__state;		\
		debug_rtlock_wait_set_state();				\
		WRITE_ONCE(current->__state, TASK_RTLOCK_WAIT);		\
		raw_spin_unlock(&current->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 立即返回,唤醒没丢
      
saved_state 机制:普通唤醒被"重定向"到 saved_state,锁唤醒才真正让任务运行

锁的唤醒以 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 线程里 spin_lock() 争用失败直到睡眠 PREEMPT_RT · ARM64
  1. irq_thread() → irq_forced_thread_fn() → mydrv_handler()kernel/irq/manage.c中断线程上下文,SCHED_FIFO 50
  2. spin_lock(&dev->lock)include/linux/spinlock_rt.h:42
  3. rt_spin_lock()kernel/locking/spinlock_rt.c:54
  4. __rt_spin_lock() → rtlock_lock()spinlock_rt.c:46 / :38cmpxchg(owner, NULL, current) 失败
  5. rtlock_slowlock()kernel/locking/rtmutex.c:1893raw_spin_lock_irqsave(&lock->wait_lock)
  6. rtlock_slowlock_locked()rtmutex.c:1838current_save_and_set_rtlock_wait_state()
  7. task_blocks_on_rt_mutex(…, RT_MUTEX_MIN_CHAINWALK)rtmutex.c:1202入 waiters 树;若成为 top waiter → rt_mutex_adjust_prio(owner) → rt_mutex_setprio() 提升持锁者
  8. …… 回到 rtlock_slowlock_locked 的 for 循环 ……
  9. rtmutex_spin_on_owner()rtmutex.c:1486owner 在别的 CPU 运行则先自旋;owner 被调度出去则放弃
  10. schedule_rtlock()kernel/sched/core.c:6884
  11. __schedule_loop(SM_RTLOCK_WAIT) → __schedule()core.c:6803 / :6613rcu_note_context_switch(preempt=true);任务以 TASK_RTLOCK_WAIT 出队
  12. context_switch() → __switch_to() → cpu_switch_toarch/arm64/kernel/entry.S
硬件硬中断软中断内核线程/任务用户态锁
对应的解锁与唤醒路径 PREEMPT_RT
  1. spin_unlock() → rt_spin_unlock()kernel/locking/spinlock_rt.c:78先 migrate_enable();cmpxchg(owner, current, NULL) 因 bit0=1 失败
  2. rt_mutex_slowunlock()kernel/locking/rtmutex.c:1416拿 wait_lock
  3. mark_wakeup_next_waiter()rtmutex.c:1317从自己的 pi_waiters 摘掉 top waiter 并去 boost;owner 置为 RT_MUTEX_HAS_WAITERS;preempt_disable()
  4. rt_mutex_wake_q_add()rtlock 等待者记到 rtlock_task,wake_state = TASK_RTLOCK_WAIT
  5. rt_mutex_wake_up_q()rtmutex.c:562wake_up_state(task, TASK_RTLOCK_WAIT);随后 preempt_enable() —— 被唤醒的高优先级任务在此立即抢占
  6. 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 在硬中断里运行
RCUrcu_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")。
非 RT rw_semaphore

写者公平:写者排队后,新来的读者也排在它后面,防止写者饥饿。

PREEMPT_RT rwbase_rt

写者不公平:只要还有读者持锁,新读者可以继续进来(只要没写锁定)。一个被抢占的低优先级读者会让高优先级写者一直等——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_FREE RCU 读侧、NMI LD_WAIT_SPIN raw_spinlock_t、硬中断 LD_WAIT_CONFIG spinlock_t、local_lock LD_WAIT_SLEEP mutex、rwsem 等级递增 → 越来越"可能睡眠" 规则:持有右边的锁时可以拿左边的锁;持有左边时拿右边 ⇒ "Invalid wait context" CONFIG 等级只在 PROVE_RAW_LOCK_NESTING=y 时与 SPIN 分开——于是在非 RT 内核上也能查出"raw 锁里拿 spinlock_t"
wait-type 等级:raw 锁内拿 spinlock_t 在非 RT 上"没问题",在 RT 上就是在不可睡眠的上下文里睡眠

关键是 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 用上同一套机制。