本页目录

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。下面的代码只保留控制流,⁠是带注释的简化版,不是可以与任意内核版本逐行对应的源码⁠:

prepare_task_switch(rq, prev, next);
arch_start_context_switch(prev);

if (!next->mm) {                 // 切到内核线程
    enter_lazy_tlb(prev->active_mm, next);
    next->active_mm = prev->active_mm;
    // 根据 prev 是否为用户任务维护 active_mm 引用
} else {                         // 切到用户任务
    membarrier_switch_mm(rq, prev->active_mm, next->mm);
    switch_mm_irqs_off(prev->active_mm, next->mm, next);
    // 从内核线程切回用户任务时延迟释放 borrowed active_mm
}

prepare_lock_switch(rq, next, rf);
switch_to(prev, next, prev);
return finish_task_switch(prev);

应始终对照目标版本的当前 context_switch() 源码,因为 mm 引用管理和同步步骤会变化。

2. mmactive_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)。汇编入口主要做三件事:

  1. rbprbxr12r15 等 callee-saved 寄存器压入 prev 的内核栈。
  2. 把当前 rsp 写入 prev->thread.sp,再载入 next->thread.sp
  3. 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() 会在需要时:

  1. 把旧任务的 FPU 寄存器保存到 fpstate;
  2. 设置 TIF_NEED_FPU_LOAD
  3. 在返回用户态或内核需要使用该状态前恢复寄存器。

如果任务回到仍持有其寄存器状态的同一 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 统计切换数量

perf stat -e context-switches,cpu-migrations,page-faults -- ./workload

grep -E 'voluntary_ctxt_switches|nonvoluntary_ctxt_switches' \
  /proc/<pid>/status

数量高不自动等于性能差。I/O 型程序会产生大量主动切换;问题取决于延迟目标、CPU 利用率和缓存影响。

7.2 观察调度事件

perf sched record -- ./workload
perf sched timehist
perf sched latency

perf sched latency 更接近任务等待调度的延迟,而不是单条 switch_to 汇编花费的周期数。

7.3 测量 wakeup-to-run 延迟

bpftrace -e '
tracepoint:sched:sched_wakeup {
    @wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch /@wake[args->next_pid]/ {
    @wake_to_run_us = hist((nsecs - @wake[args->next_pid]) / 1000);
    delete(@wake[args->next_pid]);
}'

这个直方图测的是“任务被唤醒到真正获得 CPU”的时间,包含 run queue 等待,不应标成纯 context-switch cost。

7.4 微基准

perf bench sched pipe -l 100000

它测量两个任务通过 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_switch tracepoint 间隔就是切换耗时”:它通常混入任务运行时间或排队时间,必须先定义测量边界。

9. 上游资料

阅读内核文章时,最可靠的习惯是把“稳定概念”“某版本的实现”和“测量结果”分别标注。上下文切换长期存在,但它周围的安全加固、FPU 策略、TLB 管理和调度器收尾并不静止。