本页目录

上下文切换

覆盖: 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(struct rq *rq, struct task_struct *prev,
               struct task_struct *next, struct rq_flags *rf) {

    prepare_task_switch(rq, prev, next);

    // === 第一步: 切换地址空间 ===
    // 如果两个进程有不同的 mm → 需要切换页表
    if (!next->mm)
        // 内核线程: 没有 mm → 借 prev 的 active_mm
        enter_lazy_tlb(prev->active_mm, next);
    else
        // 用户进程: 正常切换
        switch_mm_irqs_off(prev->active_mm, next->mm, next);

    // === 第二步: 切换寄存器上下文 ===
    // 保存 prev 的寄存器 → 恢复 next 的寄存器 → 返回到 next 上次停下的地方
    switch_to(prev, next, prev);

    // 从这里开始,已经在 next 的上下文中执行了
    // 但 prev 的内核栈和 rq 还在用——finish_task_switch() 会清理

    return finish_task_switch(prev);
}

关键洞察: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 switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
                         struct task_struct *tsk) {
    unsigned cpu = smp_processor_id();

    if (likely(prev != next)) {
        // 真的需要切换地址空间

        // 1. 切换 CR3 (页表根指针)
        u64 new_asid = this_cpu_read(cpu_tlbstate.loaded_mm_asid);

        if (IS_ENABLED(CONFIG_PTI)) {
            // KPTI: 有两个页表 → 切换时写内核 CR3
            // 用户态 CR3 在 entry_SYSCALL_64 中切换
            load_new_mm_cr3(next->pgd, new_asid, true);
        }

        // 2. 刷新本 CPU 的 TLB (如果需要)
        //    PCID 使得非 global TLB entry 可以被标记而不用刷新
        //    没有 PCID → 写 CR3 会刷掉所有非 global entry

        // 3. 如果需要通知其他 CPU 刷 TLB → 发 IPI (TLB shootdown)
        //    见 arch/x86/mm/tlb.c: flush_tlb_mm_range()

        this_cpu_write(cpu_tlbstate.loaded_mm, next);
    } else {
        // prev == next: 线程切换或空闲进程复用 → 不需要改 CR3
        // 但仍需确认 lazy TLB 状态
    }
}

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
struct thread_struct {
    // C 编译器保护 (callee-saved) 寄存器 — 由 switch_to 的编译器行为隐式保存
    unsigned long           sp;         // 内核栈指针 (RSP)
    unsigned long           sp0;        // 特权级 0 时使用的栈指针

    // FPU/SIMD 状态
    struct fpu              fpu;        // 内嵌 fpu 结构

    // IO 位图 (ioperm)
    struct io_bitmap        *io_bitmap;

    // 调试
    unsigned long           debugreg[8];
    unsigned long           cr2, trap_nr, error_code;

    // 虚拟化
    unsigned long           gs_base;
};

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-64ARM64RISC-V
切换函数__switch_to_asm() (汇编)cpu_switch_to() (汇编)__switch_to() (汇编)
FPUXSAVE/XRSTOR (~10KB)FPSIMD/SVE (惰性)FPU (惰性)
地址空间写 CR3 + PCID写 TTBR0 + ASID写 SATP + ASID
屏障membar 显式isb after TTBR writesfence.vma

TLB Shootdown

当修改全局页表项(如 munmap),需要通知所有其他 CPU 刷它们的 TLB:

// kernel/smp.c → arch/x86/mm/tlb.c
void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
                         unsigned long end, unsigned int stride_shift,
                         bool freed_tables) {
    // 1. 本地: 刷自己的 TLB
    // 2. 远程: 发 IPI (Inter-Processor Interrupt) 给所有在跑这个 mm 的 CPU
    //    → 目标 CPU 收到 IPI → 在中断处理中刷 TLB → 回复完成
    // 3. 等待所有远程 CPU 完成 (通过 per-CPU 的 flush state 追踪)
}

多核 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: 延迟直方图
perf sched record -- sleep 1
perf sched latency -s max

# 2. bpftrace: 直接测量 schedule() 延迟
bpftrace -e '
kprobe:finish_task_switch {
    $ns = nsecs - @last;
    if (@last > 0) { @lat_us = hist($ns / 1000); }
    @last = nsecs;
}'

# 3. /proc/<pid>/sched
cat /proc/self/sched | grep nr_switches
# 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_to
    • arch/x86/kernel/fpu/core.c — FPU 惰性切换

关键词: context_switch, switch_mm, switch_to, PCID, ASID, TLB shootdown, lazy TLB, FPU lazy restore, XSAVE