01 · 抢占模型:不可抢占区的消失
结论:非 RT 内核里,任何持有
spinlock_t、处理 softirq、关中断的代码段都是「不可抢占区」,高优先级任务醒来后必须等它们结束;PREEMPT_NONE/VOLUNTARY更进一步,连preempt_enable()和中断返回都不调度。PREEMPT_RT 把这些区域绝大部分改成可抢占,只留下raw_spinlock_t、hardirq 桩和显式preempt_disable()三类极短区域。代价:抢占更频繁、上下文切换更多,且 6.12 还没有PREEMPT_LAZY来缓解普通任务的过度抢占。
第一层:一颗 CPU 上的时间线
先看结论:非 RT 的调度延迟由「最长不可抢占区」决定;RT 把它压缩到 hardirq 桩 + raw 区的长度。
左图是 CONFIG_PREEMPT(低延迟桌面)内核:任务 A 在系统调用里拿了一把 spinlock_t,此时网卡中断唤醒了 SCHED_FIFO 的任务 B。虽然 NEED_RESCHED 已经置位,但 preempt_count 非零,调度只能等 A 释放锁、softirq 跑完、preempt_enable() 把计数减到零之后才发生。驱动里一段几百 µs 的临界区,就是几百 µs 的抖动。
右图是同一场景在 RT 内核上:spin_lock() 不再增加 preempt_count(include/linux/preempt.h:157-162 把 PREEMPT_LOCK_OFFSET 定义为 0),hardirq 处理函数只做唤醒,中断返回时 arm64_preempt_schedule_irq()(arch/arm64/kernel/entry-common.c:270)发现 64 位 preempt_count 为零(计数为零且需要调度)立即调度。A 的临界区被切开,等 B 跑完再继续——这之所以安全,是因为 RT 下 spinlock_t 是可睡眠的 rt_mutex(第 02 章)。
第二层:preempt_count 的来源与抢占点
先看结论:RT 没有改 preempt_count 的结构,改的是「谁往里加计数」——spinlock、bh、RCU 读区三大来源都换成了不计数的机制。
preempt_count 的布局(两种内核相同)
include/linux/preempt.h:27-55 定义了 32 位计数的分段:PREEMPT [0..7]、SOFTIRQ [8..15]、HARDIRQ [16..19]、NMI [20..23]。arm64 把 need_resched 放在计数之外的高 32 位:arch/arm64/include/asm/thread_info.h:29-40 是一个 union { u64 preempt_count; struct { u32 count; u32 need_resched; } preempt; },arch/arm64/include/asm/preempt.h:8 定义 PREEMPT_NEED_RESCHED = BIT(32),极性反转——need_resched == 0 表示需要调度(:32, :37, :42)。好处是「可抢占且需要调度」变成一次 64 位比零(:79-80 should_resched());代价是 arm64 的计数 RMW 不是原子的(:45-57 用 READ_ONCE/WRITE_ONCE),__preempt_count_dec_and_test() 要在 :74 重读整个 u64 以防中断在两半操作之间清了 need_resched。x86 则把 NEED_RESCHED 反向塞在同一个 32 位字的 bit 31。SOFTIRQ_DISABLE_OFFSET = 2 * SOFTIRQ_OFFSET(:55):bit 8 表示「正在跑 softirq」,bit 9 表示「bh 已关闭」。
非 RT:三种模型的抢占点
| 抢占点 | PREEMPT_NONE | PREEMPT_VOLUNTARY | PREEMPT | 源码 |
|---|---|---|---|---|
| 返回用户态 | ✓ | ✓ | ✓ | kernel/entry/common.c exit_to_user_mode_loop |
显式 schedule() / 阻塞 |
✓ | ✓ | ✓ | — |
cond_resched() / might_sleep() |
✗ | ✓ | ✓ | kernel/sched/core.c:7251-7273 __cond_resched() |
preempt_enable() 计数归零 |
✗ | ✗ | ✓ | include/linux/preempt.h:229-235(无 PREEMPTION 时 :250-255 只减计数) |
| 中断返回内核态 | ✗ | ✗ | ✓ | arm64:arch/arm64/kernel/entry-common.c:270-302 arm64_preempt_schedule_irq(),由 __el1_irq() 在 :628-630 调用(arm64 没有 select GENERIC_ENTRY,不走 kernel/entry/common.c);x86 对应 kernel/entry/common.c:303-313 |
kernel/Kconfig.preempt:14-16 里 choice 的默认值仍是 PREEMPT_NONE;发行版内核普遍打开 PREEMPT_DYNAMIC(:96-101),用 preempt=none|voluntary|full 启动参数在三者间切换——注意它 depends on !PREEMPT_RT,所以 RT 与 DYNAMIC 互斥,一个内核镜像不可能既是发行版通用内核又是 RT 内核。
RT:计数来源被替换成什么
| 非 RT 会增加 preempt_count 的操作 | RT 的替代 | 源码 |
|---|---|---|
spin_lock() / spin_lock_irqsave() → preempt_disable() |
rt_spin_lock():migrate_disable() + rcu_read_lock(),不动 preempt_count |
kernel/locking/spinlock_rt.c:46-52;include/linux/preempt.h:161 |
rcu_read_lock()(非抢占 RCU)→ preempt_disable() |
PREEMPT_RCU:只加 rcu_read_lock_nesting |
include/linux/rcupdate.h:70-101 |
local_bh_disable() → +SOFTIRQ_DISABLE_OFFSET |
拿 per-CPU softirq_ctrl.lock;计数存到 current->softirq_disable_cnt |
kernel/softirq.c:156-185;include/linux/preempt.h:110-116 |
get_cpu() / kmap_atomic() |
migrate_disable() |
include/linux/highmem-internal.h:97-132 |
raw_spin_lock() / 显式 preempt_disable() |
不变,仍是真正的不可抢占区 | — |
hardirq 入口 HARDIRQ_OFFSET |
不变,但处理函数缩短为「唤醒线程」 | 第 03 章 |
RT 引入了两个新的 per-task 计数来承接这些语义:task->migration_disabled(include/linux/sched.h:888-890,16 位)和 task->softirq_disable_cnt(:1225-1227)。in_task() 与 softirq_count() 在 RT 下的定义也随之改变(include/linux/preempt.h:110-133)。
RT 对调度器本身的调整
| 项 | 非 RT | RT | 源码 | 为什么 |
|---|---|---|---|---|
TTWU_QUEUE 特性 |
true | false | kernel/sched/features.h:75-83 |
远程唤醒排队要靠 IPI 延后处理,增加唤醒延迟 |
RT_PUSH_IPI |
false | true | features.h:108-116 |
用 IPI 把 RT 任务推到别的 CPU,避免在本 CPU 长时间关抢占遍历 |
SCHED_NR_MIGRATE_BREAK |
32 | 8 | kernel/sched/sched.h:2833-2837 |
负载均衡一次搬更少任务,缩短持 rq 锁时间 |
schedule_rtlock() |
无 | 新增 | kernel/sched/core.c:6883-6889 |
在 RT 自旋锁上阻塞的专用入口(SM_RTLOCK_WAIT) |
| RT 限流 | 95%(sysctl_sched_rt_runtime = 950000) |
同 | kernel/sched/rt.c:17-21 |
RT 内核也默认限流,长跑 RT 线程每秒被停 50ms,需 sched_rt_runtime_us=-1 |
PREEMPT_LAZY |
无 | 无(6.13 才有) | 全树无 PREEMPT_LAZY/NEED_RESCHED_LAZY |
6.12 RT 上 SCHED_OTHER 任务也会在每个抢占点被立刻抢占 |
ARM64 的中断返回抢占路径
arm64 没有选 GENERIC_ENTRY,arch/arm64/kernel/entry-common.c 自己实现了入口/出口;__exit_to_kernel_mode() 的注释(:70)明说它只做 trace/lockdep/RCU,「preemption handled elsewhere」。真正的抢占点是 arm64_preempt_schedule_irq()(:270-302),它按顺序做四件事:
need_irq_preemption()(:262-267):PREEMPT_DYNAMIC下是静态键sk_dynamic_irqentry_exit_cond_resched,RT 下退化为IS_ENABLED(CONFIG_PREEMPTION),编译期恒真。READ_ONCE(current_thread_info()->preempt_count) != 0就返回(:280-281)——一次 64 位比较同时判断「计数为零」和「需要调度」。- 伪 NMI 例外(
:284-290):if (system_uses_irq_prio_masking() && read_sysreg(daif)) return;——开启irqchip.gicv3_pseudo_nmi=1后,从伪 NMI 返回时不做抢占;el1_interrupt()也把!interrupts_enabled(regs)的情况分流到__el1_pnmi()(:637-640),那条路径根本不调用抢占函数。 system_capabilities_finalized()之前不抢占(:294-301):早期启动与 secondary CPU 上线期间存在一个不可抢占窗口。
另两处 arm64 特有的不可抢占区:调试异常上下文显式 preempt_disable()(:445-462);arch/arm64/Kconfig:58-83 的 ARCH_INLINE_*_LOCK if !PREEMPTION——RT 下锁操作全部走 out-of-line 函数,比非抢占 arm64 内核多一次调用开销。
ARM64 的入口开销:KPTI 与 Spectre 缓解
抢占点在异常入口/出口上,所以入口本身的开销直接进「唤醒→运行」延迟:
- KPTI(
CONFIG_UNMAP_KERNEL_AT_EL0,arch/arm64/Kconfig:1696)只对kpti_safe_list(arch/arm64/kernel/cpufeature.c:1737,含 Cortex-A35/A53/A55/A57/A72/A73、Carmel、Kryo 2xx/3xx/4xx Silver)之外且CSV3 == 0的核开启(典型是 Cortex-A75);A76 及之后的核CSV3 == 1,自动关闭。开启时每次 syscall/中断多一次页表切换与 TLB 失效,约几百 ns。kpti=(:1982;kernel-parameters.txt:2657)可强制。 - Spectre-BHB(
arch/arm64/kernel/proton-pack.c:829-1034;CONFIG_MITIGATE_SPECTRE_BRANCH_HISTORYarch/arm64/Kconfig:1708):A72/A73/A75/A76/A77/A78/A78AE/X1 等在异常入口执行分支历史清除循环,每次入口多几十 ns。nospectre_bhb(kernel-parameters.txt:4108)、nospectre_v2(:4116;proton-pack.c:90)、ssbd=(:413)、总开关mitigations=(kernel/cpu.c:3230;kernel-parameters.txt:3515)可关闭——功能安全平台关闭前需过安全评审。 - PAC/BTI(
arch/arm64/Kconfig:2005, 2115)开销可忽略;MTE(:2178)同步模式对内存访问有可见开销,RT 生产内核通常不开;SVE(:2249)状态会增加使用它的线程的切换成本(arch/arm64/kernel/fpsimd.c:1565fpsimd_thread_switch()),无 SVE 用户时arm64.nosve(kernel-parameters.txt:461)。
性能影响
净影响:最坏延迟下降约两个数量级;抢占更频繁、切换更多,吞吐型负载损失 0–35%。
| 指标 | 非 RT(PREEMPT) | RT | 说明 |
|---|---|---|---|
| 唤醒→运行延迟(最坏) | 取决于最长不可抢占区:常见 100 µs – 数 ms | 取决于最长 raw 区 + hardirq 桩:常见 10 – 50 µs | 见第 11 章量级 |
| 抢占频率 | 低 | 高 | RT 无 LAZY,普通任务的时间片经常被切碎 |
| 上下文切换总数 | 基线 | +20% ~ +100%(中断密集时更高) | 线程化中断 / softirq 线程 / 锁睡眠都要切换 |
| 缓存局部性 | 好 | 变差 | 频繁切换 + 被钉住的任务无法迁移 |
| 吞吐(CPU 密集) | 100% | 95% – 100% | 纯计算几乎无差别 |
| 吞吐(调度密集,如 hackbench) | 100% | 65% – 90% | 切换与锁成本叠加 |
源码定位
| 主题 | 位置 |
|---|---|
| 抢占模型 choice | kernel/Kconfig.preempt:14-87(PREEMPT_RT :70-73,帮助文本 :75-82) |
| PREEMPT_DYNAMIC 与 RT 互斥 | kernel/Kconfig.preempt:96-101 |
| ARCH_SUPPORTS_RT | arch/Kconfig:1359-1360;x86 arch/x86/Kconfig:128、arm64 :105、riscv :68 |
| preempt_count 位布局 | include/linux/preempt.h:27-65 |
PREEMPT_LOCK_OFFSET = 0 |
include/linux/preempt.h:157-162 |
preempt_enable() → __preempt_schedule() |
include/linux/preempt.h:229-255 |
| 中断返回抢占(arm64) | arch/arm64/kernel/entry-common.c:262-302, 628-640;x86 参考 kernel/entry/common.c:303-358 |
| arm64 preempt_count 布局 | arch/arm64/include/asm/preempt.h:8-90;arch/arm64/include/asm/thread_info.h:29-40 |
| arm64 KPTI / Spectre | arch/arm64/kernel/cpufeature.c:1731-1789, 1982;arch/arm64/kernel/proton-pack.c:84-94, 413, 829-1034;arch/arm64/Kconfig:1696-1708 |
preempt_schedule() / preempt_schedule_irq() |
kernel/sched/core.c:6891-7058 |
preempt_disable_nested()(RT 才真关抢占) |
include/linux/preempt.h:438-478 |
migrate_disable() 设计说明 |
include/linux/preempt.h:373-426 |
调优要点
- arm64 上若为 perf/硬死锁检测打开了
irqchip.gicv3_pseudo_nmi=1,记住伪 NMI 返回不抢占、local_irq_disable()变成ICC_PMR_EL1写 + 同步(第 03 章);生产 RT 系统按需开启。 - 想验证抢占点是否生效:
trace-cmd record -p preemptirqsoff或/sys/kernel/tracing/tracing_max_latency,RT 内核上最长关抢占区应在 10 µs 量级。 sysctl kernel.sched_rt_runtime_us=-1,否则默认 95% 限流会让长跑的 SCHED_FIFO 线程每秒停 50 ms。- 6.12 上普通吞吐型任务与 RT 任务混跑时,抢占过于激进的问题只能靠 CPU 分区缓解(第 12 章);如果需要
PREEMPT_LAZY,考虑 6.13+。
小结
- 不可抢占区从「持锁 / softirq / 关中断」三大类缩到「raw 锁 / hardirq 桩 / 显式关抢占」三小类,这是全文所有差异的根。
PREEMPT_LOCK_OFFSET = 0、softirq_count()改读任务字段、migrate_disable计数——三处改动让抢占几乎处处可发生。- 6.12 没有
PREEMPT_LAZY,普通任务也会被激进抢占;RT 限流默认 95% 仍在。 - 承上启下:抢占能处处发生的前提是「持锁可以睡」——这正是下一章
spinlock_t → rt_mutex的由来。