上下文切换
上下文切换(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 和缓存命中率更高。
如何优化上下文切换
- 减少不必要的线程:
N = CPU 核数通常最佳(对于 CPU 密集型) - 使用异步 I/O:用
epoll/io_uring代替多线程阻塞 I/O - 增大时间片:减少切换频率(但会降低响应速度)
- 使用协程(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 线程 | 线程更快(不需要切换地址空间) |
为什么先学这个? 上下文切换是多任务的基础机制——它解释了为什么进程切换”不免费”、为什么线程比进程轻量、为什么调度算法设计要权衡。接下来看看多线程并发遇到的核心问题:竞争条件与临界区。