CHAPTER 02

抢占模型与调度器

"抢占"到底在什么时候、由谁、检查哪个变量来决定?本章从 Kconfig 的四个抢占模型讲起,落到 ARM64 的 preempt_count 打包技巧、四类抢占点、一次跨 CPU 唤醒经 GICv3 SGI 到达目标核并完成切换的完整路径,最后看 PREEMPT_RT 对调度器自身做了哪些改动。

2.1四种抢占模型

6.12 的 kernel/Kconfig.preempt 里有一个 choice "Preemption Model",四个选项(kernel/Kconfig.preempt:18 / 32 / 51 / 70):

PREEMPT_NONEPREEMPT_VOLUNTARYPREEMPTPREEMPT_RT
Kconfig 描述ServerDesktopLow-Latency DesktopReal-Time
内核态被动抢占无无(只在 might_sleep 处自愿让出)有:中断返回、preempt_enable有,且不可抢占区极少
cond_resched()真正的让出点真正的让出点空操作空操作
spinlock_t自旋(关抢占为空操作)自旋自旋 + 关抢占rtmutex,可睡眠
硬中断 handler硬中断上下文硬中断上下文硬中断上下文(可 threadirqs)强制线程化
softirqirq_exit 就地执行同左同左线程上下文
RCU非抢占非抢占可抢占可抢占 + boost
可运行时切换可以:PREEMPT_DYNAMIC + 启动参数 preempt=不可以,编译期定死
最坏调度延迟量级(经验)数十毫秒甚至更长毫秒级毫秒级(受关抢占区和 softirq 限制)数十微秒级(视平台)

PREEMPT_RT 只 select PREEMPTION,与 PREEMPT_DYNAMIC 互斥(kernel/Kconfig.preempt:98 depends on HAVE_PREEMPT_DYNAMIC && !PREEMPT_RT)。原因很直接:RT 改变的是锁的类型和中断的执行上下文,这些是编译出来的数据结构和代码路径,不是一个 static key 能切换的。

一个内核执行片段中,哪些部分不可被高优先级任务抢占(红色 = 不可抢占) 系统调用主体 spin_lock 区 local_bh 区 硬中断 softirq raw_spinlock 区 PREEMPT_NONE VOLUNTARY PREEMPT PREEMPT_RT primary irq 线程 保留为原子区 不可抢占 可被抢占 VOLUNTARY 的显式让出点(might_resched)
四种模型的"不可抢占覆盖范围"示意。RT 之后,只剩下硬中断的极短 primary 部分和显式 raw_spinlock / preempt_disable 区不可抢占。
VOLUNTARY 是怎么做的

PREEMPT_VOLUNTARY_BUILD 下,might_resched() 被定义成 __cond_resched()(include/linux/kernel.h:63)。而 might_sleep() 展开时会调用 might_resched(),于是内核里成千上万个 might_sleep() 注解点顺带变成了让出点。在 CONFIG_PREEMPTION(含 RT)下,_cond_resched() 只剩 klp_sched_try_switch(); return 0;(include/linux/sched.h:2085)——因为抢占已经是被动的,不需要代码主动让。

2.2preempt_count:抢占决策的唯一依据

一句话

preempt_count 是每个任务自带的一个整数,记录"当前这段代码外面套了几层不可抢占的外壳",并且按外壳的种类分开计数:关抢占几层、关 BH 几层、硬中断几层、NMI 几层。它不是硬件寄存器,也不是 per-CPU 变量,而是存在每个任务的 thread_info 里,随任务切换一起换掉。

内核判断"此刻能否抢占"只看一个 per-task 计数器 preempt_count。它被切成几个位域(include/linux/preempt.h:33):

#define PREEMPT_BITS	8	/* preempt_disable() 嵌套深度 */
#define SOFTIRQ_BITS	8	/* bit0 = 正在执行 softirq;其余 = local_bh_disable 计数(步长 2) */
#define HARDIRQ_BITS	4	/* 硬中断嵌套 */
#define NMI_BITS	4	/* NMI 嵌套 */

进入一层外壳就加上对应字段的偏移,退出时减掉。用计数而不用标志位,是为了支持嵌套:拿着两把 spinlock 时 PREEMPT 字段是 2,释放一把还剩 1,仍不可抢占。

         bit: 23..20   19..16     15..8              7..0
             ┌───────┬────────┬──────────────────┬────────────┐
             │  NMI  │HARDIRQ │     SOFTIRQ      │  PREEMPT   │
             └───────┴────────┴──────────────────┴────────────┘
谁来加:     nmi_enter irq_enter bit8:正在执行软中断(+1)  preempt_disable()
                               bit9+:local_bh_disable(+2) spin_lock()(非 RT)

它用来做四件事

preempt_count per-task · 位域计数 ① 能不能抢占 整个 count == 0 才可以 arm64_preempt_schedule_irq() preempt_enable() → preempt_schedule() ② 现在在什么上下文 in_nmi() / in_hardirq() in_serving_softirq() / in_task() 上下文是软件记账,不是硬件状态 ③ 抓"原子区里睡眠" might_sleep() / schedule() sleeping function called from invalid context / scheduling while atomic ④ RCU 免费的静止态 tick 时 == HARDIRQ_OFFSET ⇒ 被打断的代码 count 为 0 ⇒ 不在读侧,可报告静止态
同一个计数器同时服务调度器、上下文判断、调试检查和 RCU 四方
  1. 判断此刻能不能抢占(最核心)。规则只有一条:整个 count 为 0 才能抢占,任何一个字段非零都说明处在不可打断的上下文里。两个内核态抢占点问的都是这个:中断返回内核态时 arm64_preempt_schedule_irq() 先检查 preempt_count != 0 就返回(arch/arm64/kernel/entry-common.c:270);preempt_enable() 减到 0 并且有人在等,就立刻 preempt_schedule()。
  2. 回答"我现在在什么上下文"。内核没有别处记录"是不是在中断里",in_nmi()、in_hardirq()、in_serving_softirq()、in_task() 全是查这些字段(include/linux/preempt.h:126)。硬件只知道 PSTATE.DAIF.I 有没有置位,并不知道"上下文"。
  3. 抓"在原子上下文里睡眠"的 bug。might_sleep() 走到 __might_resched()(kernel/sched/core.c:8648),发现 count 不为预期值就打印 "BUG: sleeping function called from invalid context";schedule() 入口的 schedule_debug() 发现异常就报 "BUG: scheduling while atomic"(kernel/sched/core.c:5888)。这是驱动开发里最常见的两条报错,依据都是 preempt_count。
  4. 给 RCU 提供免费的静止态。tick 中断到来时,如果 preempt_count() == HARDIRQ_OFFSET,说明被打断前 count 为 0,被打断的代码不在 RCU 读侧临界区,可以直接报告静止态(kernel/rcu/tree_plugin.h:1073,见第 7 章)。

ARM64 的打包技巧:一次 load 判断两件事

ARM64 把 preempt_count 放在 thread_info 里,和 need_resched 标志共享一个 64 位字(arch/arm64/include/asm/thread_info.h:29):

union {
	u64		preempt_count;	/* 0 => preemptible, <0 => bug */
	struct {
#ifdef CONFIG_CPU_BIG_ENDIAN
		u32	need_resched;
		u32	count;
#else
		u32	count;
		u32	need_resched;
#endif
	} preempt;
};

关键在于 need_resched 是取反存储的:set_preempt_need_resched() 把它写成 0,clear_preempt_need_resched() 写成 1(arch/arm64/include/asm/preempt.h:30)。于是"计数为 0 且需要重调度"恰好等价于"整个 u64 等于 0":

thread_info::preempt_count(u64,小端) need_resched(取反) 0 = 需要调度 · 1 = 不需要 保留 31..24 NMI 23..20 HARDIRQ 19..16 SOFTIRQ 15..8 PREEMPT 7..0 bit 63 32 | 31 bit 0 preempt.need_resched (u32) preempt.count (u32) —— 即 preempt_count() should_resched(0) ⇔ READ_ONCE(ti→preempt_count) == 0 ⇔ count == 0(不在任何关抢占 / 中断 / BH 区) 且 need_resched 存的是 0(确实需要调度) 一条 LDR + 一次与 0 比较。RT 下抢占检查频率大增,这个设计直接受益。 arch/arm64/include/asm/preempt.h:77 · entry-common.c:280 (arm64_preempt_schedule_irq 里同样整字比较)
ARM64 把 need_resched 取反后拼在 preempt_count 的高 32 位,使"可以抢占且需要抢占"成为单次比较。
static inline bool __preempt_count_dec_and_test(void)
{
	struct thread_info *ti = current_thread_info();
	u64 pc = READ_ONCE(ti->preempt_count);

	/* Update only the count field, leaving need_resched unchanged */
	WRITE_ONCE(ti->preempt.count, --pc);

	/* 整个 u64 变成 0 = 计数归零且需要调度 */
	return !pc || !READ_ONCE(ti->preempt_count);
}

这就是 preempt_enable() 的核心:每次递减后顺手判断要不要立即调用 preempt_schedule()。注意这里有两份 need_resched:TIF_NEED_RESCHED(thread flags,远端 CPU 可以设置)和 preempt.need_resched(本地折叠后的副本)。远端设置 TIF 后发 IPI,目标 CPU 在 scheduler_ipi() 里调用 preempt_fold_need_resched() 把它折叠进 preempt_count(include/linux/sched.h:1976)。

RT 下的变化:softirq 计数离开了 preempt_count

非 RT
#define softirq_count() \
  (preempt_count() & SOFTIRQ_MASK)
/* local_bh_disable() = preempt_count += 2
 * ⇒ BH 禁用区必然不可抢占 */
PREEMPT_RT
#define softirq_count() \
  (current->softirq_disable_cnt & SOFTIRQ_MASK)
/* local_bh_disable() 拿 per-CPU local_lock
 * 计数记在 task 上 ⇒ BH 禁用区可抢占 */

这两个定义就在 include/linux/preempt.h:110-115。in_task()、irq_count() 也随之有 RT 版本。另一个同类变化是 PREEMPT_LOCK_OFFSET:非 RT 下 spin_lock() 之后 preempt_count 增加 PREEMPT_DISABLE_OFFSET,RT 下定义为 0(include/linux/preempt.h:155-162,注释原文 "Locks on RT do not disable preemption")。might_sleep() 的检查、cond_resched_lock() 都依赖这个偏移。softirq 的完整 RT 实现见 第 6 章。

把 RT 下"谁还会改 preempt_count"汇总起来:

操作非 RTPREEMPT_RT
preempt_disable() / raw_spin_lock()PREEMPT +1PREEMPT +1(不变)
spin_lock() / read_lock()PREEMPT +1不变:变成睡眠锁,持锁期间仍可抢占
local_bh_disable()SOFTIRQ +2不变:改为拿 per-CPU local_lock,计数记在 current->softirq_disable_cnt
执行软中断SOFTIRQ +1,在中断返回路径上计数在 task 上,在 irq 线程或 ksoftirqd 里执行
local_lock()PREEMPT +1不变:migrate_disable() + 睡眠锁
硬中断 / NMI 进入HARDIRQ / NMI +1HARDIRQ / NMI +1(不变)
落到实现上

"RT 让内核处处可抢占",落到实现上就是让 preempt_count 尽量保持为 0。只有真正的关抢占、raw 锁、硬中断和 NMI 还会让它非零,所以上面第 ① 条里的抢占点几乎总能通过。反过来,评审 RT 代码时,凡是让 preempt_count 非零的地方(preempt_disable、raw_spin_lock、local_irq_save),都要问一句"这段有多长"。

2.3抢占点:need_resched 在哪里被检查

设置 need_resched 只是"申请",真正发生切换要等代码执行到某个抢占点。在 ARM64 + CONFIG_PREEMPTION 下一共四类:

flowchart TB
  W["try_to_wake_up / scheduler_tick
resched_curr():置 TIF_NEED_RESCHED"] --> Q{"目标 CPU 正在执行什么"} Q -->|"用户态 EL0"| P1["A. 返回用户态
exit_to_user_mode_prepare
do_notify_resume → schedule()"] Q -->|"内核态,preempt_count == 0"| P2["B. 中断返回内核态
__el1_irq → arm64_preempt_schedule_irq
→ preempt_schedule_irq()"] Q -->|"内核态,preempt_count 非 0"| P3["C. 离开关抢占区
preempt_enable() → preempt_schedule()"] Q -->|"内核态,主动检查"| P4["D. 主动让出
cond_resched() 仅非抢占模型有效
schedule() 阻塞"] P1 --> S["__schedule()"] P2 --> S P3 --> S P4 --> S S --> X["context_switch → __switch_to → cpu_switch_to"] classDef rt fill:#f6e3dc,stroke:#b5371c,color:#1d1d1f classDef user fill:#e0f1e6,stroke:#2f7d4f,color:#1d1d1f classDef hardirq fill:#fde8da,stroke:#c2410c,color:#1d1d1f classDef lock fill:#f5e1eb,stroke:#8b3a62,color:#1d1d1f class W rt class P1 user class P2 hardirq class P3 lock
ARM64 上四类抢占点。PREEMPT_RT 的贡献不在于增加抢占点的种类,而在于让 C 类"等待离开关抢占区"的时间趋近于零。

A. 返回用户态

这是所有模型(包括 PREEMPT_NONE)都有的抢占点。ARM64 不使用通用的 kernel/entry/common.c,而是在 arch/arm64/kernel/entry-common.c 里自己实现:

static void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags)
{
	do {
		local_irq_enable();

		if (thread_flags & _TIF_NEED_RESCHED)
			schedule();
		...
		if (thread_flags & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
			do_signal(regs);
		...
		local_irq_disable();
		thread_flags = read_thread_flags();
	} while (thread_flags & _TIF_WORK_MASK);
}

它由 exit_to_user_mode_prepare()(entry-common.c:161)在每次系统调用、异常、中断返回 EL0 前调用。

B. 中断返回内核态

static __always_inline void __el1_irq(struct pt_regs *regs,
				      void (*handler)(struct pt_regs *))
{
	enter_from_kernel_mode(regs);

	irq_enter_rcu();
	do_interrupt_handler(regs, handler);
	irq_exit_rcu();            /* 非 RT:softirq 在这里就地执行 */

	arm64_preempt_schedule_irq();   /* ← 抢占点 */

	exit_to_kernel_mode(regs);
}

arm64_preempt_schedule_irq()(entry-common.c:270)有三道门槛:整字 preempt_count 必须为 0;启用 GIC 优先级屏蔽(pseudo-NMI)时 DAIF 必须全清(否则说明刚处理的是 NMI);cpufeature 必须已经 finalize。通过后调用 preempt_schedule_irq()(kernel/sched/core.c:7040),后者在关中断状态下进入、循环调用 __schedule(SM_PREEMPT) 直到 need_resched 清除。

C. preempt_enable()

#define preempt_enable() \
do { \
	barrier(); \
	if (unlikely(preempt_count_dec_and_test())) \
		__preempt_schedule(); \
} while (0)

非 RT 内核里,spin_unlock()、local_bh_enable()、rcu_read_unlock()(非抢占 RCU 时)、put_cpu() 末尾都隐含一次 preempt_enable()。所以一个已被唤醒的高优先级任务,最长要等系统中最长的那个关抢占区结束——这正是第 1 章里第 ② 类延迟的本质。

D. 主动让出

代码阻塞(schedule())或调用 cond_resched()。在 PREEMPT / RT 下后者基本是空操作(见上)。RT 还新增了一种特殊的"主动让出":争用 sleeping spinlock 时调用 schedule_rtlock()(kernel/sched/core.c:6884),它以 SM_RTLOCK_WAIT 模式进入 __schedule(),下一节详述。

2.4从唤醒到运行:一次跨 CPU 唤醒的完整路径

场景:CPU1 上的 irq 线程处理完网卡数据,唤醒绑定在 CPU2 上的 SCHED_FIFO 80 应用线程 app;CPU2 此刻正在内核态执行一个普通进程的系统调用。

sequenceDiagram
  autonumber
  participant W as CPU1 唤醒者
  participant RQ as CPU2 runqueue
  participant GIC as GICv3 (SGI)
  participant C2 as CPU2
  participant A as app (FIFO 80)
  W->>RQ: try_to_wake_up(app)
  Note over W,RQ: RT 下 TTWU_QUEUE=false:
唤醒者直接拿 CPU2 的 rq 锁 W->>RQ: ttwu_do_activate:activate_task 入队 W->>RQ: wakeup_preempt → wakeup_preempt_rt Note over RQ: app 优先级高于 curr → resched_curr W->>C2: set_nr_and_not_polling:置 TIF_NEED_RESCHED W->>GIC: smp_send_reschedule(2)
写 ICC_SGI1R_EL1 GIC->>C2: IPI_RESCHEDULE (SGI) C2->>C2: el1h_64_irq → gic_handle_irq
→ ipi_handler → scheduler_ipi Note over C2: preempt_fold_need_resched C2->>C2: irq_exit_rcu C2->>C2: arm64_preempt_schedule_irq
preempt_count == 0 C2->>A: __schedule(SM_PREEMPT)
pick_next_task → context_switch A->>A: cpu_switch_to:恢复 x19-x29/sp/lr
跨 CPU 唤醒:唤醒者负责把任务放进目标 runqueue 并发一个 SGI;目标 CPU 在中断返回的抢占点上完成切换。
唤醒者一侧(CPU1)ARM64 · GICv3 · v6.12
  1. wake_up_process(app) → try_to_wake_up()kernel/sched/core.c:4142先 guard(preempt);持 p->pi_lock;ttwu_state_match 检查状态
  2. select_task_rq()core.c:3536app 亲和性只有 CPU2
  3. ttwu_queue()core.c:3958ttwu_queue_wakelist 在 RT 下因 TTWU_QUEUE=false 不走 wakelist
  4. rq_lock(CPU2 rq)rq->__lock 是 raw_spinlock_t,RT 下仍然自旋
  5. ttwu_do_activate()core.c:3668
  6. activate_task() → enqueue_task_rt()
  7. wakeup_preempt()core.c:2140同调度类调 class 回调;更高调度类直接 resched_curr
  8. wakeup_preempt_rt()kernel/sched/rt.c:1655p->prio < rq->curr->prio
  9. resched_curr()core.c:1083远端 CPU:set_nr_and_not_polling 后发 IPI(目标在 idle 轮询时免 IPI)
  10. smp_send_reschedule() → arch_smp_send_reschedule()arch/arm64/kernel/smp.c:1099
  11. smp_cross_call(IPI_RESCHEDULE) → __ipi_send_mask()smp.c:1010
  12. gic_ipi_send_mask() → gic_send_sgi() → gic_write_sgi1r()drivers/irqchip/irq-gic-v3.c:1428按 affinity 组装目标列表写 ICC_SGI1R_EL1,随后 isb
目标一侧(CPU2,原先在内核态运行普通进程)PREEMPT / PREEMPT_RT · ARM64
  1. el1h_64_irqarch/arm64/kernel/entry.S:526
  2. el1h_64_irq_handler() → el1_interrupt() → __el1_irq()entry-common.c:643 / 632 / 619
  3. gic_handle_irq() → __gic_handle_irq_from_irqson()irq-gic-v3.c:964 / 904读 ICC_IAR1_EL1 得到 SGI 号
  4. generic_handle_domain_irq() → handle_percpu_devid_irq()kernel/irq/chip.c:924IPI 是 per-CPU 中断,永不线程化
  5. ipi_handler() → do_handle_IPI(IPI_RESCHEDULE)smp.c:1004 / 945
  6. scheduler_ipi() → preempt_fold_need_resched()include/linux/sched.h:1976把 TIF_NEED_RESCHED 折叠进 preempt.need_resched
  7. …… EOI,返回 __el1_irq ……
  8. irq_exit_rcu()非 RT 可能在这里先跑 softirq;RT 下只可能唤醒 ksoftirqd
  9. arm64_preempt_schedule_irq()entry-common.c:270整字 preempt_count == 0 → 可以抢占
  10. preempt_schedule_irq()kernel/sched/core.c:7040
  11. __schedule(SM_PREEMPT)core.c:6613rcu_note_context_switch(preempt=true);被抢占者保持 TASK_RUNNING,不出队
  12. pick_next_task()stop → dl → rt → fair → idle,rt 类挑出 app
  13. context_switch()core.c:5296切换 mm(ASID)、prepare_task_switch
  14. switch_to() → __switch_to()arch/arm64/kernel/process.c:577fpsimd_thread_switch、TLS、MTE、ptrauth 等
  15. cpu_switch_to(prev, next)arch/arm64/kernel/entry.S:825保存 x19–x29、sp、lr 到 prev->thread.cpu_context,载入 next 的;sp_el0 = next
  16. … app 从它上次 schedule() 的位置继续,最终 exit_to_user_mode → ERET 到 EL0
硬件 / 入口 / IPI硬中断软中断调度用户态锁
SYM_FUNC_START(cpu_switch_to)
	save_and_disable_daif x11
	mov	x10, #THREAD_CPU_CONTEXT
	add	x8, x0, x10
	mov	x9, sp
	stp	x19, x20, [x8], #16		// store callee-saved registers
	stp	x21, x22, [x8], #16
	stp	x23, x24, [x8], #16
	stp	x25, x26, [x8], #16
	stp	x27, x28, [x8], #16
	stp	x29, x9, [x8], #16
	str	lr, [x8]
	add	x8, x1, x10
	ldp	x19, x20, [x8], #16		// restore callee-saved registers
	...
	mov	sp, x9
	msr	sp_el0, x1			// arm64 用 sp_el0 存放 current
	...
	ret
SYM_FUNC_END(cpu_switch_to)

上下文切换本身的成本在 ARM64 上相当稳定:只保存 callee-saved 寄存器(其余寄存器已由调用约定或异常入口保存),FPSIMD/SVE 状态采用惰性保存(TIF_FOREIGN_FPSTATE,返回用户态时才恢复)。这是延迟构成中 RT 不需要改动的那一段。

2.5调度类、优先级与 RT 节流

pick_next_task() 从上到下依次询问各调度类 stop_sched_class dl_sched_class(SCHED_DEADLINE) rt_sched_class(FIFO / RR) fair_sched_class(OTHER / BATCH) idle_sched_class migration/N:迁移、stop_machine 按截止期(EDF);6.12 的 fair_server 也是一个 dl 实体 用户 rt_priority 1..99 → 内核 prio = 99 − rt_priority(0..98) nice −20..19 → prio 100..139(MAX_RT_PRIO=100) 每 CPU 的 swapper/N 数值越小优先级越高。irq 线程默认 SCHED_FIFO 50;用户 RT 任务应当围绕它规划(见第 9 章)。
调度类的固定次序。只要更高的类里有可运行任务,低类的任务永远得不到 CPU——这既是实时保证,也是饿死风险的来源。

优先级常量定义在 include/linux/sched/prio.h:16:MAX_RT_PRIO = 100,MAX_PRIO = MAX_RT_PRIO + NICE_WIDTH。RT 类在同一优先级内 FIFO 不轮转、RR 按时间片轮转;不同优先级之间严格抢占,wakeup_preempt_rt() 里只有一句 if (p->prio < rq->curr->prio) resched_curr(rq)。

RT 节流:给系统留 5% 的命

int sysctl_sched_rt_period = 1000000;   /* /proc/sys/kernel/sched_rt_period_us  */
...
int sysctl_sched_rt_runtime = 950000;   /* /proc/sys/kernel/sched_rt_runtime_us */

默认每 1 秒周期内,RT 类最多运行 0.95 秒;超出时 sched_rt_runtime_exceeded()(kernel/sched/rt.c:876)把 RT 队列节流,并打印一次 sched: RT throttling activated。这是一个安全阀:写错的 SCHED_FIFO 死循环不至于让 shell 和 sshd 彻底饿死。生产 RT 系统里它有两种处理方式——保留默认(推荐,配合监控这条日志),或设为 -1 关闭(需要确信 RT 任务不会跑飞,并且有看门狗)。

6.12 新增:fair_server

6.12 在 deadline 类里引入了 fair_server(kernel/sched/fair.c:9074),它是一个 deadline 服务器实体,默认参数 50ms / 1000ms(kernel/sched/deadline.c:1839),在 RT 任务长时间占满 CPU 时为 CFS 任务预留运行时间,并可通过 debugfs 的 sched/fair_server/cpuN/ 调整。它与传统 RT 节流并存。做严格 RT 隔离的核上需要了解它会以 deadline 优先级插入 CFS 任务。

2.6PREEMPT_RT 对调度器自身的改动

调度器是 RT 中"必须保持原子"的核心:rq->__lock、p->pi_lock 都是 raw_spinlock_t,__schedule() 本身在关中断下运行。RT 对它的改动不是把它变得可抢占,而是让它能正确地支撑上层的可睡眠锁,并降低自身的关中断时长。

migrate_disable:只钉 CPU,不关抢占

内核里大量 preempt_disable() 的真实意图只是"别把我迁移到别的 CPU,我在用 per-CPU 数据"。RT 把 spinlock 变成可睡眠锁后,持锁区不能再关抢占,但 per-CPU 语义仍需保证——于是需要一个更弱的原语:

void migrate_disable(void)
{
	struct task_struct *p = current;

	if (p->migration_disabled) {
		p->migration_disabled++;
		return;
	}

	guard(preempt)();
	this_rq()->nr_pinned++;
	p->migration_disabled = 1;
}

它只设置 task 上的计数,调度器在负载均衡、select_fallback_rq()、is_cpu_allowed() 时检查 is_migration_disabled()。任务仍可被抢占,只是被抢占后还会回到这颗 CPU。6.12 中 migrate_disable() 对所有 SMP 内核都存在,但它的主要用户是 RT:rt_spin_lock()、local_lock 的 RT 实现都在内部调用它(第 3 章)。

代价

被钉住的任务可能挡在一个更高优先级、但也只能跑在这颗 CPU 上的任务前面——它被抢占后只能等这颗 CPU 空出来。这是 RT 下一种新的、调度器难以分析的延迟来源,所以 migrate_disable 区也应当短。

SM_RTLOCK_WAIT 与 saved_state

非 RT 内核里,一段代码可以这样写:

set_current_state(TASK_INTERRUPTIBLE);
spin_lock(&q->lock);          /* RT 下这里可能阻塞! */
if (!condition)
	...
spin_unlock(&q->lock);
schedule();

在 RT 下 spin_lock() 可能睡眠。如果它直接用 TASK_UNINTERRUPTIBLE 阻塞,外层已经设置好的 TASK_INTERRUPTIBLE 就会被覆盖;更糟的是,阻塞期间如果有人对外层条件调用了 wake_up(),这次唤醒会被"锁唤醒"吞掉。RT 的解法分三处:

  1. 阻塞前 current_save_and_set_rtlock_wait_state() 把原状态存进 task_struct::saved_state,当前状态设为专用的 TASK_RTLOCK_WAIT(include/linux/sched.h:279);
  2. 普通唤醒者在 ttwu_state_match() 中发现状态匹配的是 saved_state,就只把 saved_state 改成 TASK_RUNNING,不真正唤醒,但对调用者报告成功(kernel/sched/core.c:3990 注释);
  3. 拿到锁后 current_restore_rtlock_saved_state() 恢复原状态——如果中途被普通唤醒过,恢复出来的就是 TASK_RUNNING,唤醒不会丢失。
stateDiagram-v2
  direction LR
  [*] --> INTERRUPTIBLE: set_current_state
  INTERRUPTIBLE --> RTLOCK_WAIT: spin_lock 争用
saved_state = INTERRUPTIBLE RTLOCK_WAIT --> RTLOCK_WAIT: 普通 wake_up 到达
只改 saved_state = RUNNING RTLOCK_WAIT --> RESTORED: 锁释放,rtlock 唤醒
拿到锁 RESTORED --> INTERRUPTIBLE: saved_state 仍为 INTERRUPTIBLE RESTORED --> RUNNING: 期间被普通唤醒过
RT 下阻塞在 sleeping spinlock 上时任务状态的保存与恢复,保证外层等待语义和唤醒都不丢失。

调度器为此增加了一个模式常量(kernel/sched/core.c:6531):

/*
 * The mode argument allows RT enabled kernels to differentiate a
 * preemption from blocking on an 'sleeping' spin/rwlock.
 */
#define SM_IDLE			(-1)
#define SM_NONE			0
#define SM_PREEMPT		1
#define SM_RTLOCK_WAIT		2

在 __schedule() 里,preempt = sched_mode > SM_NONE:SM_RTLOCK_WAIT 对 schedule_debug() 和 rcu_note_context_switch() 而言算作抢占(持有 sleeping spinlock 的任务可能正处于 RCU 读侧,RCU 需要按"被抢占的读者"处理),但对任务状态而言不算抢占(preempt = sched_mode == SM_PREEMPT),所以 TASK_RTLOCK_WAIT 的任务会被正常出队阻塞。

RT 下 spin_lock() 争用时进入调度器 PREEMPT_RT · 详见第 3 章
  1. spin_lock(&q->lock) → rt_spin_lock()kernel/locking/spinlock_rt.cfastpath cmpxchg 失败
  2. rtlock_slowlock() → rtlock_slowlock_locked()kernel/locking/rtmutex.ccurrent_save_and_set_rtlock_wait_state;入等待树;必要时 PI boost 持锁者
  3. schedule_rtlock()kernel/sched/core.c:6884
  4. __schedule_loop(SM_RTLOCK_WAIT) → __schedule()core.c:6613rcu_note_context_switch(preempt=true);任务出队

RT 专属的调度器开关

位置非 RTPREEMPT_RT为什么
TTWU_QUEUE
kernel/sched/features.h:75
truefalse远端唤醒默认把任务挂到目标 CPU 的 wakelist、由目标 CPU 在 IPI 里入队,这段工作在硬中断上下文执行;RT 改为唤醒者直接拿远端 rq 锁入队,避免目标 CPU 上不可控的硬中断工作量
RT_PUSH_IPI
features.h:112
falsetrue多个 CPU 同时降低优先级、都想去"拉"同一个 RT 任务时,会争抢同一 rq 锁(惊群);改为发 IPI 让持有过载 RT 任务的 CPU 自己"推"出去
SCHED_NR_MIGRATE_BREAK
kernel/sched/sched.h:2833
328负载均衡一次持 rq 锁最多迁移多少任务后就要中断让出,缩短关中断的 raw 锁区
schedule_rtlock() / SM_RTLOCK_WAIT不存在存在支撑 sleeping spinlock,见上节
rt_mutex_setprio()
kernel/sched/core.c:7123
用于 PI futex 等所有 spinlock_t 都可能触发PI boost 的落地点,第 4 章

2.7解决后带来的变化

收益
  • 抢占点从"稀疏的几个位置"变成"几乎处处":只要不在 raw 锁、显式 preempt_disable、硬中断 primary 里,高优先级任务被唤醒后下一个中断返回或锁释放就能上 CPU。
  • 调度延迟 ④ 的上界从"系统中最长的关抢占区"缩短为"最长的 raw 锁区 + 调度器自身路径"。
  • 优先级真正有意义:中断、软中断、RCU 回调、打印都成为可以排队的线程。
代价
  • 上下文切换显著增多:锁争用从"自旋几百纳秒"变成"睡眠 + 唤醒 + 两次切换"。
  • 持锁者可能被抢占(lock holder preemption),争用方必须等它重新被调度——自适应自旋(第 3 章)部分缓解。
  • 对 SCHED_OTHER 工作负载,频繁抢占损害缓存局部性,吞吐下降。
  • 迁移禁止区带来新的不可迁移约束。
后续演进(6.12 之后)

6.12 中 PREEMPT_LAZY / TIF_NEED_RESCHED_LAZY 还不存在。它在 6.13 引入:对 SCHED_OTHER 任务的抢占请求先"懒"地记下,等到 tick 或返回用户态再执行,只有 RT/DL 任务的唤醒才立即抢占——目的是在 RT 内核上挽回普通负载的吞吐。在 6.12 的 RT 内核上,所有抢占都是立即的。

2.8小结

  • 抢占的决策只看 preempt_count;ARM64 把取反的 need_resched 和计数打包成一个 u64,使"可以且需要抢占"成为单次比较。
  • ARM64 上的抢占点:返回 EL0 的 do_notify_resume()、EL1 中断返回的 arm64_preempt_schedule_irq()、preempt_enable()、主动 schedule()。跨 CPU 唤醒通过 GICv3 SGI(IPI_RESCHEDULE)送达。
  • PREEMPT_RT 没有增加抢占点,而是让 preempt_count 几乎总是 0:spinlock 不再加它,local_bh_disable 不再加它,softirq 也不在中断返回路径上执行。
  • 调度器自身保持原子(raw 锁),但新增了 migrate_disable、SM_RTLOCK_WAIT/saved_state 来支撑可睡眠的 spinlock,并调整了 TTWU_QUEUE、RT_PUSH_IPI、迁移批量等参数以缩短硬中断和 raw 锁区。