本页目录
上下文切换
覆盖: context_switch() 完整路径 → switch_mm (页表/TLB/PCID) → switch_to (寄存器/FPU) → lazy TLB → 切换开销分析 内核版本: 2.6 ~ 6.x,重点标注 5.x+ 的 PCID/ASID 和 FPU 优化
概述
上下文切换是调度器的"执行端"——调度器决定了"谁跑",上下文切换负责"怎么换"。每次切换,内核必须保存当前进程的 CPU 寄存器状态,恢复下一个进程的寄存器,同时处理地址空间切换(页表、TLB)和 FPU/SIMD 上下文。
一次上下文切换的典型开销在 1~5 μs 之间,取决于:
- 是否跨地址空间(进程切换 vs 线程切换)
- TLB 是否需要刷(PCID/ASID 可以减少)
- FPU 是否 dirty(需要保存/恢复 XSAVE 区域)
- Cache 局部性(刚被唤醒的进程可能数据还在 cache 里)
入口: context_switch()
// kernel/sched/core.c
// 由 schedule() → __schedule() 调用
static __always_inline struct rq *
关键洞察:context_switch() 调用 switch_to() 后,执行的已经是 next 了。switch_to 不返回(从 C 的视角看),它把控制流传给了 next 进程上次被切换出去时的 switch_to 返回点。等下次 prev 再被调度回来执行时,它会从 switch_to 的返回处继续执行 finish_task_switch()。
switch_mm: 地址空间切换
这是上下文切换中最昂贵的部分,尤其是跨地址空间时。
x86-64 实现
// arch/x86/mm/tlb.c
void
PCID: 避免刷 TLB
// x86 PCID (Process-Context Identifier):
// 每个地址空间一个 12-bit ASID (Address Space Identifier)
// 写 CR3 时附上 ASID → TLB 中另一个 ASID 的 entries 不会命中
// 也不用被刷掉
//
// 没有 PCID 时: 每次写 CR3 → 刷掉所有 non-global TLB entries → 极高的缓存 miss
// 有 PCID 时: 切换地址空间 → 只写了 CR3 + new ASID → TLB 保留旧 entries
// (旧 entries 有旧 ASID, 不会误命中)
//
// 内核通过 per-CPU 的 ASID bitmap 分配和回收 ASID
Lazy TLB
// 内核线程 (mm == NULL) 切换时:
// enter_lazy_tlb(prev->active_mm, next)
// → next->active_mm = prev->active_mm
// → 不写 CR3 (继续用 prev 的页表)
// → 内核地址空间是共享的,不需要切
//
// 这是内核线程切换特别快的原因——没有 TLB flush 开销
ARM64 的等价实现
// ARM64 用 ASID (8/16 bit) 代替 PCID
// TTBR0_EL1: 用户空间页表 (每个进程不同)
// TTBR1_EL1: 内核空间页表 (所有进程共享)
//
// switch_mm(): 写 TTBR0_EL1 + ASID
// → 不需要额外指令刷 TLB (ASID 硬件区分)
//
// VMID (Virtual Machine ID):
// 虚拟化扩展,由 hypervisor 管理
// stage-2 页表的 TLB tag
switch_to: 寄存器上下文
// arch/x86/entry/entry_64.S
// switch_to() 是一个宏,展开为:
//
// 1. 保存 prev 的通用寄存器到内核栈
// 2. 保存 prev 的 RIP/RSP 到 task_struct.thread
// 3. 恢复 next 的 RIP/RSP
// 4. 恢复 next 的通用寄存器
// 5. 跳转到 next 的 RIP
thread_struct — 保存的硬件上下文
// arch/x86/include/asm/processor.h
;
FPU 惰性切换
// arch/x86/kernel/fpu/core.c
// 现代 x86 (XSAVE/XSAVES) 下 FPU 的切换策略:
// 1. 每个进程有独立的 fpstate (XSAVE 区域, 最大 ~10KB for AMX)
// 2. 切换时"惰性"处理:
// - switch_to() 中不自动保存/恢复 FPU 状态
// - 设 TS (Task Switched) 或 XFD bit
// - 新进程第一次用 FPU → #NM exception → 内核才做 XRSTOR
//
// 3. 为什么? XSAVE 区域很大 (AMX 可以几 KB),很多进程根本不用 FPU
// 惰性保存避免了无意义的数据搬运
// 惰性保存的例外:
// - 如果下一个进程明确需要 FPU (通过 __fpu_state_active 检查)
// - 且 FPU 是 dirty 的 → 必须在 switch_to 中做 XSAVE/XSAVES
架构差异
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| 切换函数 | __switch_to_asm() (汇编) | cpu_switch_to() (汇编) | __switch_to() (汇编) |
| FPU | XSAVE/XRSTOR (~10KB) | FPSIMD/SVE (惰性) | FPU (惰性) |
| 地址空间 | 写 CR3 + PCID | 写 TTBR0 + ASID | 写 SATP + ASID |
| 屏障 | membar 显式 | isb after TTBR write | sfence.vma |
TLB Shootdown
当修改全局页表项(如 munmap),需要通知所有其他 CPU 刷它们的 TLB:
// kernel/smp.c → arch/x86/mm/tlb.c
void
多核 TLB shootdown 的开销很大(IPI + 等待),所以内核做了多种优化:
- lazy TLB mode: 如果目标 CPU 在内核线程上(不跑用户态),可以延迟刷
- batching: 多个 TLB 操作合并到一个 IPI
- self-snoop: 某些硬件可以自动检测页表变更,不用软件 IPI
切换开销分析
典型数字 (x86-64, ~3GHz):
| 场景 | 延迟 | 原因 |
|---|---|---|
| 线程切换 (同进程) | ~0.5-1 μs | 无 CR3 写,FPU 通常惰性 |
| 进程切换 (有 PCID) | ~1-3 μs | 写 CR3,但 TLB 保留 |
| 进程切换 (无 PCID) | ~3-5 μs | 写 CR3 + 全部 non-global TLB 冷启动 |
| 跨 NUMA | +1-3 μs | 新 CPU cache 冷,需远程取数据 |
测量方法
# 1. perf: 延迟直方图
# 2. bpftrace: 直接测量 schedule() 延迟
# 3. /proc/<pid>/sched
|
# nr_voluntary_switches: 进程主动让出 (block/sleep/yield)
# nr_involuntary_switches: 时间片用完被抢占
# 高 involuntary → CPU-intensive;高 voluntary → IO-intensive
参考与延伸
- 内核文档:
Documentation/x86/tlb.rst,Documentation/arch/x86/topology.rst - LWN:
- "PCID and the TLB" (lwn.net/Articles/729754/)
- "Lazy FPU state restoration" (lwn.net/Articles/675957/)
- 源码文件:
kernel/sched/core.c— context_switch()arch/x86/mm/tlb.c— switch_mm_irqs_off()arch/x86/kernel/process_64.c— __switch_to()arch/x86/entry/entry_64.S— 汇编层面的 switch_toarch/x86/kernel/fpu/core.c— FPU 惰性切换
关键词: context_switch, switch_mm, switch_to, PCID, ASID, TLB shootdown, lazy TLB, FPU lazy restore, XSAVE