本页目录
Linux 上下文切换
调度器回答“下一个运行谁”,上下文切换负责把 CPU 从 prev 交给 next。这不只是保存一组通用寄存器,还可能涉及:
- 用户地址空间与 CR3/PCID;
- 内核栈和 callee-saved 寄存器;
- FPU/SIMD/扩展状态;
- perf、ptrace、I/O bitmap、TLS 等架构状态;
- RCU、membarrier、rseq 和调度器锁的收尾。
“上下文切换耗时”“任务从唤醒到运行的延迟”和“缓存/TLB 冷却造成的后续性能损失”是三项不同指标,不能用一个固定的微秒数概括。
1. 调用路径
典型路径:
schedule()
└─ __schedule()
├─ pick_next_task()
└─ context_switch(rq, prev, next, rf)
├─ prepare_task_switch()
├─ 切换或借用 mm
├─ prepare_lock_switch()
├─ switch_to(prev, next, prev)
└─ finish_task_switch(prev)
context_switch() 定义在 kernel/sched/core.c。下面的代码只保留控制流,是带注释的简化版,不是可以与任意内核版本逐行对应的源码:
;
;
if else
;
;
return ;
应始终对照目标版本的当前 context_switch() 源码,因为 mm 引用管理和同步步骤会变化。
2. mm 与 active_mm
用户任务拥有 mm;纯内核线程没有用户地址空间,因此 mm == NULL。但 CPU 仍必须装载一套页表,所以内核线程会临时借用前一个用户任务的 active_mm。
| prev → next | 地址空间动作 |
|---|---|
| 用户 → 用户 | 调用 switch_mm_irqs_off(),必要时切换 CR3/ASID |
| 用户 → 内核线程 | 进入 lazy TLB,内核线程借用 prev->active_mm |
| 内核线程 → 内核线程 | 继续 lazy TLB,并转移 borrowed active_mm |
| 内核线程 → 用户 | 切到 next->mm,随后释放旧的 borrowed active_mm |
“内核线程借用用户页表”不表示它可以访问任意用户地址。内核态执行仍受页表、KPTI、SMEP/SMAP 和显式用户访问 API 等约束。
3. switch_mm_irqs_off()、CR3 与 PCID
在 x86-64 上,地址空间切换最终围绕 CR3 和 TLB 状态展开。PCID 为 TLB entry 加上地址空间标签,使切换 CR3 时不必无条件丢弃所有非 global entry。
但“有 PCID 就永不 flush”也不正确。以下情况仍可能要求失效:
- ASID/PCID 被回收并重新分配;
- 页表内容发生变化;
- KPTI 需要维护用户/内核 CR3 组合;
mm的 TLB generation 表明当前 CPU 落后;- 安全修复或架构约束要求更强的失效操作。
Linux 在每 CPU 的 cpu_tlbstate 中跟踪当前 mm、ASID 和 TLB generation。具体实现位于 arch/x86/mm/tlb.c。
4. switch_to() 真正保存什么
x86-64 的 switch_to 宏调用 __switch_to_asm(prev, next)。汇编入口主要做三件事:
- 将
rbp、rbx、r12–r15等 callee-saved 寄存器压入prev的内核栈。 - 把当前
rsp写入prev->thread.sp,再载入next->thread.sp。 - 从
next的内核栈恢复寄存器,然后跳转到 C 函数__switch_to()完成架构状态切换。
# arch/x86/entry/entry_64.S(简化)
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
通用寄存器并不是全部复制进一个 thread_struct 数组。callee-saved 状态主要保存在内核栈的 inactive_task_frame 中,thread.sp 让内核能够找到该帧。
“switch_to 返回到了谁”
执行 switch_to(prev, next, prev) 后,CPU 已经使用 next 的内核栈和任务上下文。代码从 next 上次被切出时留下的调用链继续执行。
因此更准确的说法是:switch_to 会返回,但返回发生在切入任务的上下文里;第三个参数接收真正被切出的任务,供 finish_task_switch() 做清理。简单说“它从 C 视角不返回”会掩盖这个双重返回语义。
5. 现代 x86 FPU 状态切换
现代 Linux 不再把常规 FPU 上下文描述成“每次切换只设置 TS,等新任务第一次执行浮点指令触发 #NM 再保存旧状态”的经典 lazy-FPU 模型。
当前调度路径中的 switch_fpu() 会在需要时:
- 把旧任务的 FPU 寄存器保存到 fpstate;
- 设置
TIF_NEED_FPU_LOAD; - 在返回用户态或内核需要使用该状态前恢复寄存器。
如果任务回到仍持有其寄存器状态的同一 CPU,内核可以避免不必要的恢复。XFD/#NM 仍可用于按需启用 AMX 等动态扩展状态,但不能把它泛化为所有 FPU/SIMD 状态的调度策略。
参考当前 switch_fpu() 与 x86 FPU 文档。
6. TLB shootdown 不是普通 context switch 的固定步骤
munmap()、权限变化或页表回收等操作修改了某个 mm 的页表后,其他正在或曾经运行该 mm 的 CPU 可能仍缓存旧 TLB entry。内核需要让相关 CPU 执行失效操作,必要时通过 IPI 协调。
修改页表
├─ 本 CPU 失效对应 entry/range
└─ 向持有该 mm 旧 TLB 状态的远端 CPU 发起 shootdown
└─ 等待必要的完成确认
这与“调度器把 CPU 从 A 切到 B”相关但不等价:普通上下文切换可能只装载已有 ASID,而一次页表更新也可能在没有任务切换时触发 shootdown。
7. 怎样测量
先明确要测哪一项。
7.1 统计切换数量
数量高不自动等于性能差。I/O 型程序会产生大量主动切换;问题取决于延迟目标、CPU 利用率和缓存影响。
7.2 观察调度事件
perf sched latency 更接近任务等待调度的延迟,而不是单条 switch_to 汇编花费的周期数。
7.3 测量 wakeup-to-run 延迟
这个直方图测的是“任务被唤醒到真正获得 CPU”的时间,包含 run queue 等待,不应标成纯 context-switch cost。
7.4 微基准
它测量两个任务通过 pipe 往返的综合成本,包含 syscall、唤醒、调度与切换。报告结果时至少注明:CPU、内核版本、mitigation、CPU affinity、频率策略、是否跨 NUMA,以及线程还是进程。
8. 常见误区
- “一次 context switch 固定 1–5 μs”:不同测量定义和硬件无法直接比较。
- “线程切换不碰地址空间”:同一
mm通常避免 CR3 切换,但仍有调度、栈、寄存器和可能的 FPU 成本。 - “PCID 消除了 TLB flush”:它减少无条件 flush,不消除页表失效和 ASID 回收。
- “所有 FPU 状态都等首次使用再恢复”:这是过时的泛化;现代实现使用 eager save 与 return-to-user restore,并对动态扩展另行处理。
- “TLB shootdown 就是 context switch”:前者是页表一致性机制,后者是任务执行权转移。
- “
finish_task_switchtracepoint 间隔就是切换耗时”:它通常混入任务运行时间或排队时间,必须先定义测量边界。
9. 上游资料
- Linux
context_switch() - x86
__switch_to_asm() - x86 TLB implementation
- x86 FPU scheduler hooks
- Scheduler tracing documentation
- perf sched documentation
阅读内核文章时,最可靠的习惯是把“稳定概念”“某版本的实现”和“测量结果”分别标注。上下文切换长期存在,但它周围的安全加固、FPU 策略、TLB 管理和调度器收尾并不静止。