进阶 #os#context-switch

上下文切换

上下文切换(Context Switch)是操作系统暂停当前进程、恢复另一个进程执行的过程——它让多任务成为可能,但也是不容忽视的性能开销

暂停、保存、恢复

你在写代码,突然微信弹窗了——你停下来,看一眼微信,回个消息,切回编辑器继续码字。你大脑做了一次”上下文切换”。

上下文切换(Context Switch) 就是操作系统从一个进程/线程切换到另一个的过程——保存当前状态,恢复目标状态。

🏫 类比:图书馆占座 你去图书馆自习(进程 A 运行中)。突然想去接水——你拿书包占座(保存上下文),去接水(进程等 I/O)。回来坐下(恢复上下文),继续学习。 要是座位被占了(上下文被覆盖),就出大事了。

切换时发生了什么?

步骤拆解

时间 →
进程 A 正在执行         操作系统(内核)          进程 B 开始执行
                       
CPU 执行 A 的指令 ──→ 时钟中断发生
                      ──→ 保存 A 的寄存器到 PCB_A
                      ──→ 更新 A 的状态(运行→就绪)
                      ──→ 检查 PCB_B 的状态(就绪)
                      ──→ 恢复 B 的寄存器从 PCB_B
                      ──→ 切换页表(更新 CR3 寄存器)
                      ──→ 跳转到 B 的 PC 位置
                                                CPU 执行 B 的指令

具体保存的内容

进程 A 的 PCB 中保存:
┌──────────────────────────┐
│ 通用寄存器(RAX, RBX...)   │ ← 计算中间结果
│ 程序计数器(RIP)           │ ← 正在执行哪条指令
│ 栈指针(RSP)              │ ← 栈的位置
│ 栈帧指针(RBP)            │ ← 局部变量
│ 段寄存器                   │
│ 浮点寄存器(FPU)          │
│ 页表基址(CR3)            │ ← 地址空间
└──────────────────────────┘
; 上下文切换的简化伪代码
context_switch:
    ; 保存当前进程的寄存器到 PCB
    mov [PCB_A.RAX], rax
    mov [PCB_A.RBX], rbx
    mov [PCB_A.RCX], rcx
    mov [PCB_A.RIP],  [rsp]   ; 返回地址
    mov [PCB_A.RSP],  rsp
    mov [PCB_A.RBP],  rbp
    
    ; 切换到内核栈(保存当前内核栈指针)
    
    ; 恢复目标进程的寄存器
    mov rax, [PCB_B.RAX]
    mov rbx, [PCB_B.RBX]
    mov rcx, [PCB_B.RCX]
    mov rsp, [PCB_B.RSP]
    mov rbp, [PCB_B.RBP]
    
    ; 切换页表
    mov cr3, [PCB_B.CR3]   ; ← 这会导致 TLB 全部失效!
    
    ; 跳转到目标进程的执行位置
    jmp [PCB_B.RIP]

什么情况会触发上下文切换?

1. 时间片用完(时钟中断)

CPU 内部有一个可编程间隔定时器(PIT)——每隔固定时间(比如 10ms)就发一个中断。

时间片 10ms
进程 A ──→ 时钟中断 → 切换到进程 B → 时钟中断 → 切换到进程 C ...

2. I/O 等待

进程 A 请求读取磁盘——磁盘很慢(毫秒级),让 A 等着,先让别的进程跑。

进程 A:read(file, buf, 100);

A 发出 I/O 请求 → A 进入阻塞状态 → 切换到进程 B

                I/O 完成 ←────────── 中断通知
                A 变为就绪 → 等待调度

3. 系统调用中的阻塞操作

read()write()sleep() 等系统调用可能导致当前进程被阻塞。

4. 更高优先级进程就绪

当一个高优先级进程被唤醒(比如用户在卡顿的 UI 上点了一下),它会抢占当前低优先级进程。

上下文切换的成本

这是容易忽视的性能问题——上下文切换不免费

直接成本:
  ─ 保存/恢复寄存器(几十条指令)
  ─ 切换内核栈
  ─ 切换页表(CR3)

间接成本(更大!):
  ─ TLB 全部失效(每次换进程都要重新填充)
  ─ 缓存污染(新进程的数据不在缓存中)
  ─ 分支预测器失效
类型时间开销类比
线程切换(同进程)~0.1-1 µs换个座位
进程切换(不同进程)~1-10 µs换间教室
TLB 刷新成本~10-100 µs重新熟悉教室布局

💡 每秒 1000 次进程切换,光切换本身就能消耗 CPU 1% 以上的时间——加上缓存和 TLB 导致的性能损失,实际成本远高于寄存器保存/恢复的指令数。

评估切换开销

// 可以通过 lmbench 测量上下文切换时间
$ ./lat_ctx -P 1 -N 100 2 4 8 16
Context switching over a pipe, using 2-16 processes
2 processes: 2.1 µs
4 processes: 3.5 µs
8 processes: 6.2 µs
16 processes: 12.8 µs

进程数越多,切换越频繁,每个切换的时间成本也越大(因为缓存的干扰更严重)。

进程 vs 线程的切换成本差异

为什么同一进程的线程切换比进程切换快?

进程切换:                线程切换(同进程):
┌─────────────────┐      ┌─────────────────┐
│ 保存寄存器       │      │ 保存寄存器       │
│ 切换页表(CR3)  │      │ 不需要切换页表    │
│ TLB 全部失效     │      │ TLB 有效         │
│ 切换内核栈       │      │ 检查是否同进程    │
│ 恢复寄存器       │      │ 恢复寄存器       │
└─────────────────┘      └─────────────────┘

💡 这是为什么服务器通常用多线程(而非多进程)处理并发请求——同一进程的线程切换不需要切换地址空间,TLB 和缓存命中率更高。

如何优化上下文切换

  1. 减少不必要的线程N = CPU 核数 通常最佳(对于 CPU 密集型)
  2. 使用异步 I/O:用 epoll / io_uring 代替多线程阻塞 I/O
  3. 增大时间片:减少切换频率(但会降低响应速度)
  4. 使用协程(Coroutine):用户态的上下文切换,成本几乎是零
// 协程的例子——完全在用户态切换,无需内核参与
// Python 中的 asyncio、Go 中的 goroutine、C++ 中的 fiber
async function server() {
    while (true) {
        req = await accept();      // "await" 处切换
        result = await process(req); // 这里又切换
        await send(result);
    }
}

💡 Go 语言的 goroutine 本质上就是用户级线程——几十万个 goroutine 跑在一个小线程池上,切换成本远低于内核线程。

小结

概念要点
本质暂停当前进程,恢复另一个进程
保存内容寄存器、PC、栈指针、页表基址等
触发条件时间片到、I/O 阻塞、系统调用、优先级抢占
主要成本保存/恢复 + TLB 刷新 + 缓存污染
进程 vs 线程线程更快(不需要切换地址空间)

为什么先学这个? 上下文切换是多任务的基础机制——它解释了为什么进程切换”不免费”、为什么线程比进程轻量、为什么调度算法设计要权衡。接下来看看多线程并发遇到的核心问题:竞争条件与临界区