抢占模型与调度器
"抢占"到底在什么时候、由谁、检查哪个变量来决定?本章从 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_NONE | PREEMPT_VOLUNTARY | PREEMPT | PREEMPT_RT | |
|---|---|---|---|---|
| Kconfig 描述 | Server | Desktop | Low-Latency Desktop | Real-Time |
| 内核态被动抢占 | 无 | 无(只在 might_sleep 处自愿让出) | 有:中断返回、preempt_enable | 有,且不可抢占区极少 |
cond_resched() | 真正的让出点 | 真正的让出点 | 空操作 | 空操作 |
spinlock_t | 自旋(关抢占为空操作) | 自旋 | 自旋 + 关抢占 | rtmutex,可睡眠 |
| 硬中断 handler | 硬中断上下文 | 硬中断上下文 | 硬中断上下文(可 threadirqs) | 强制线程化 |
| softirq | irq_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 能切换的。
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)
它用来做四件事
- 判断此刻能不能抢占(最核心)。规则只有一条:整个 count 为 0 才能抢占,任何一个字段非零都说明处在不可打断的上下文里。两个内核态抢占点问的都是这个:中断返回内核态时
arm64_preempt_schedule_irq()先检查preempt_count != 0就返回(arch/arm64/kernel/entry-common.c:270);preempt_enable()减到 0 并且有人在等,就立刻preempt_schedule()。 - 回答"我现在在什么上下文"。内核没有别处记录"是不是在中断里",
in_nmi()、in_hardirq()、in_serving_softirq()、in_task()全是查这些字段(include/linux/preempt.h:126)。硬件只知道PSTATE.DAIF.I有没有置位,并不知道"上下文"。 - 抓"在原子上下文里睡眠"的 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。 - 给 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":
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
#define softirq_count() \
(preempt_count() & SOFTIRQ_MASK)
/* local_bh_disable() = preempt_count += 2
* ⇒ BH 禁用区必然不可抢占 */
#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"汇总起来:
| 操作 | 非 RT | PREEMPT_RT |
|---|---|---|
preempt_disable() / raw_spin_lock() | PREEMPT +1 | PREEMPT +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 +1 | HARDIRQ / 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
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
- wake_up_process(app) → try_to_wake_up()kernel/sched/core.c:4142先 guard(preempt);持 p->pi_lock;ttwu_state_match 检查状态
- select_task_rq()core.c:3536app 亲和性只有 CPU2
- ttwu_queue()core.c:3958ttwu_queue_wakelist 在 RT 下因 TTWU_QUEUE=false 不走 wakelist
- rq_lock(CPU2 rq)rq->__lock 是 raw_spinlock_t,RT 下仍然自旋
- ttwu_do_activate()core.c:3668
- activate_task() → enqueue_task_rt()
- wakeup_preempt()core.c:2140同调度类调 class 回调;更高调度类直接 resched_curr
- wakeup_preempt_rt()kernel/sched/rt.c:1655p->prio < rq->curr->prio
- resched_curr()core.c:1083远端 CPU:set_nr_and_not_polling 后发 IPI(目标在 idle 轮询时免 IPI)
- smp_send_reschedule() → arch_smp_send_reschedule()arch/arm64/kernel/smp.c:1099
- smp_cross_call(IPI_RESCHEDULE) → __ipi_send_mask()smp.c:1010
- gic_ipi_send_mask() → gic_send_sgi() → gic_write_sgi1r()drivers/irqchip/irq-gic-v3.c:1428按 affinity 组装目标列表写 ICC_SGI1R_EL1,随后 isb
- el1h_64_irqarch/arm64/kernel/entry.S:526
- el1h_64_irq_handler() → el1_interrupt() → __el1_irq()entry-common.c:643 / 632 / 619
- gic_handle_irq() → __gic_handle_irq_from_irqson()irq-gic-v3.c:964 / 904读 ICC_IAR1_EL1 得到 SGI 号
- generic_handle_domain_irq() → handle_percpu_devid_irq()kernel/irq/chip.c:924IPI 是 per-CPU 中断,永不线程化
- ipi_handler() → do_handle_IPI(IPI_RESCHEDULE)smp.c:1004 / 945
- scheduler_ipi() → preempt_fold_need_resched()include/linux/sched.h:1976把 TIF_NEED_RESCHED 折叠进 preempt.need_resched
- …… EOI,返回 __el1_irq ……
- irq_exit_rcu()非 RT 可能在这里先跑 softirq;RT 下只可能唤醒 ksoftirqd
- arm64_preempt_schedule_irq()entry-common.c:270整字 preempt_count == 0 → 可以抢占
- preempt_schedule_irq()kernel/sched/core.c:7040
- __schedule(SM_PREEMPT)core.c:6613rcu_note_context_switch(preempt=true);被抢占者保持 TASK_RUNNING,不出队
- pick_next_task()stop → dl → rt → fair → idle,rt 类挑出 app
- context_switch()core.c:5296切换 mm(ASID)、prepare_task_switch
- switch_to() → __switch_to()arch/arm64/kernel/process.c:577fpsimd_thread_switch、TLS、MTE、ptrauth 等
- cpu_switch_to(prev, next)arch/arm64/kernel/entry.S:825保存 x19–x29、sp、lr 到 prev->thread.cpu_context,载入 next 的;sp_el0 = next
- … app 从它上次 schedule() 的位置继续,最终 exit_to_user_mode → ERET 到 EL0
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 节流
优先级常量定义在 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 在 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 的解法分三处:
- 阻塞前
current_save_and_set_rtlock_wait_state()把原状态存进task_struct::saved_state,当前状态设为专用的TASK_RTLOCK_WAIT(include/linux/sched.h:279); - 普通唤醒者在
ttwu_state_match()中发现状态匹配的是saved_state,就只把saved_state改成TASK_RUNNING,不真正唤醒,但对调用者报告成功(kernel/sched/core.c:3990 注释); - 拿到锁后
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: 期间被普通唤醒过
调度器为此增加了一个模式常量(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 的任务会被正常出队阻塞。
- spin_lock(&q->lock) → rt_spin_lock()kernel/locking/spinlock_rt.cfastpath cmpxchg 失败
- rtlock_slowlock() → rtlock_slowlock_locked()kernel/locking/rtmutex.ccurrent_save_and_set_rtlock_wait_state;入等待树;必要时 PI boost 持锁者
- schedule_rtlock()kernel/sched/core.c:6884
- __schedule_loop(SM_RTLOCK_WAIT) → __schedule()core.c:6613rcu_note_context_switch(preempt=true);任务出队
RT 专属的调度器开关
| 位置 | 非 RT | PREEMPT_RT | 为什么 |
|---|---|---|---|
TTWU_QUEUEkernel/sched/features.h:75 | true | false | 远端唤醒默认把任务挂到目标 CPU 的 wakelist、由目标 CPU 在 IPI 里入队,这段工作在硬中断上下文执行;RT 改为唤醒者直接拿远端 rq 锁入队,避免目标 CPU 上不可控的硬中断工作量 |
RT_PUSH_IPIfeatures.h:112 | false | true | 多个 CPU 同时降低优先级、都想去"拉"同一个 RT 任务时,会争抢同一 rq 锁(惊群);改为发 IPI 让持有过载 RT 任务的 CPU 自己"推"出去 |
SCHED_NR_MIGRATE_BREAKkernel/sched/sched.h:2833 | 32 | 8 | 负载均衡一次持 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 中 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 锁区。