上下文切换 —— CPU 从一个 task 换到另一个,到底切了什么
"上下文切换(context switch)"和"CPU 切换线程/进程任务"是不是一回事?——基本是同一件事的两种说法,但里面藏着必须分清的层次:上下文切换是那个机制/实现("怎么切"),切进程还是切线程只是这机制被触发的场景("切谁"),而且这两种场景在实现上有关键差异(换不换地址空间)。本篇为导读:破题后给出四类切换、实现机制、开销观测、实战案例的速览与完整子篇导航,讲清上下文切换切的"上下文"到底是什么、切线程 vs 切进程差在哪、为什么"用户↔内核态切换"不是上下文切换。
相关:什么时候触发切换(调度时机)见 scheduling;被切换的 task 长什么样见 task-struct;换地址空间为什么贵见 tlb;本文涉及的硬件寄存器(CR3/CR0.TS/XMM/YMM 等)速查见 x86-64-registers;观测见 pidstat、vmstat;与中断的关系见 interrupts;read/write 全链路中的切换时机见 read-write-process。
零、先破题:三个词是什么关系
用户常把这几个说法混着用,先理清:
一句话破题:上下文切换是"怎么切"的机制,"切进程"还是"切线程"是"切的双方是谁"的场景区分——不是三个并列的东西。 因为 Linux 里进程和线程统一都是 task(见 task-struct),内核的切换函数根本不问"这是进程还是线程",它只做一件事:保存旧 task 状态、装上新 task 状态。唯一的实质差别是——如果新旧 task 属于不同进程,要额外换一次地址空间(页表);同进程的线程则省掉这一步。
一、四大类型速览
按"在切什么"分类,Linux 体系下有四种上下文切换(完整展开见 分类体系):
| 类型 | 切换主体 | CR3 变吗 | 有 task_struct 吗 | 开销级别 |
|---|---|---|---|---|
| 进程上下文切换 | 不同进程的 task | ✅ 必换 | ✅ | 最高 |
| 线程上下文切换 | 同一进程内的不同线程 | ❌ 共享 | ✅ | 中等 |
| 中断上下文切换 | 中断处理程序接管 CPU | ❌ 借用 | ❌ 没有 | 轻(但有限制) |
| 内核抢占上下文切换 | 内核态中被更高优先级的 task 抢走 | 取决于新旧 task | ✅ | 等同进程/线程切换 |
- 切进程最贵:换 CR3 → TLB 大面积失效,后续访存要重走 page walk(见 tlb);同进程切线程共享 mm、不换 CR3、TLB 基本保留——这就是"多线程比多进程轻"的底层原因(PCID 只能减轻换 CR3 的惩罚)。
- 中断上下文是"借壳":没有 task_struct、借用被打断 task 的地址空间、不可睡眠,只在 iret 返回前检查
TIF_NEED_RESCHED才可能触发真调度(细节见 interrupts)。 - 模式切换 ≠ 上下文切换:系统调用/中断导致的"用户态↔内核态"切换还是同一个 task(只是特权级变了),不是上下文切换——一次上下文切换通常包含模式切换,反之不然。
二、实现与触发场景速览
切换由 schedule() 发起,核心是 context_switch(),干两件事:换地址空间(switch_mm,仅跨进程)+ 换寄存器现场(switch_to)。switch_to() 是"对称代码"——保存旧 task 寄存器、把 SP 换成新 task 的内核栈,SP 一换执行流就"变成"了新 task。全程在内核态完成;FPU/SIMD 状态用惰性切换(CR0.TS + #NM 异常)省开销。完整机制与四大触发场景时序图见 实现篇。
四大触发场景(详见实现篇):
| 场景 | 触发方式 | 统计类型 |
|---|---|---|
| 时间片耗尽 | 时钟中断→schedule() | nonvoluntary |
| 主动阻塞 | 自己调 schedule()(等 IO/锁/sleep) | voluntary |
| 被高优先级抢占 | 设 TIF_NEED_RESCHED→下次进内核时切 | nonvoluntary |
| syscall 返回前发现需调度 | syscall 完成→检查 need_resched→切走 | nonvoluntary |
三、切线程 vs 切进程:差在这一步
一句话:寄存器现场和内核栈任何切换都要换,地址空间(CR3/页表)只有跨进程才换。切同进程线程省掉 switch_mm,TLB 基本保留;切不同进程换 CR3,TLB 大多失效、缓存变冷,代价显著更高。对比表与 PCID 优化细节见 分类体系 §三。
四、开销与观测速览
- 直接开销:同进程线程 ~200-500 周期(100-250ns),跨进程 ~500-1500 周期(250-750ns)。
- 隐性成本更大:切换后 cache/TLB/分支预测器"冷启动恢复期" 5-50μs,跨核迁移再翻 2-5 倍(KPTI 再 +30%~100%)。
- 判定标准(每核每秒):<1,000 健康、1万-5万偏高、>10万严重(3GHz 下 10 万次/秒 ≈ 30% CPU 被切换吃掉)。
- 观测:
vmstat 1看cs、pidstat -w 1区分自愿/非自愿、perf stat -e context-switches,cpu-migrations精确计数。自愿高 = 阻塞在锁/IO;非自愿高 = 被抢占/CPU 过载。 - 完整量化、瓶颈判断流程与优化策略见 开销与观测篇。
五、实战速览
两个真实案例见 实战案例篇:
- perf stat 三层定位:75,200 次/秒切换 + IPC 3.41 的数据,先宏观看指标,再用
pidstat -w拆到线程级(4 个线程贡献 90% 且全是自愿切换),再用perf report追调用栈——最终根因是std::this_thread::sleep_for(10μs)高频短睡眠轮询,改成事件驱动后切换从 75,000 降到 ~100 次/秒。 - write() 全链路:一次
write()里模式切换 ×2(syscall 进出)+ 自愿进程切换 ×1(脏页超限阻塞)+ 中断上下文 ×1(DMA 完成)+ 唤醒后抢占切换 ×1——高并发磁盘写下 vmstatcs走高的底层原理。
六、和本仓库其他文档的关系
- 何时切换:scheduling(调度时机、
schedule()、抢占)——本篇讲"怎么切",那篇讲"何时切、切给谁"。 - 被切的实体:task-struct(task 的寄存器现场/内核栈)、mm-struct(地址空间为何线程共享、进程独立)。
- 切进程为何贵:tlb(换 CR3→TLB 失效→page walk)。
- 区别于模式切换:syscall(用户↔内核态陷入)、interrupts(中断完整流程:IDT/do_IRQ/上半部/下半部/IST 栈切换/不能睡眠/中断返回调度)。
- IO 全链路中的切换:read-write-process(read/write 全过程中何时触发上下文切换)。
- 中断上下文切换:interrupts §2.4~§3.4(中断上下文的所有细节);分类体系 §1.3 给出关键特征总览和引用。
- 观测:pidstat(
-w自愿/非自愿)、vmstat(cs列)。
七、一句话总结
上下文切换是"把 CPU 从一个 task 的执行状态换成另一个 task"的机制。按"切什么"分为四类——进程切换(换 CR3,TLB 大面积失效,最贵)、线程切换(同进程不换 CR3,较便宜)、中断上下文切换(无 task_struct,借壳执行,不换 CR3 不调度,但不可睡眠有严格限制)、内核抢占切换(内核态中途被抢);按"怎么触发"也分四种场景——时间片耗尽、主动阻塞、被高优先级抢占、syscall 返回前发现需调度。Linux 里进程线程都是 task,切换函数不区分,唯一实质差别是跨进程要多做一步
switch_mm(换 CR3→地址空间切换→TLB 大面积失效),同进程线程共享 mm 省掉这步、便宜得多。切换全程在内核态由context_switch()完成:switch_mm换地址空间(仅跨进程)+switch_to保存旧寄存器现场/加载新的(SP 一换执行流就变成新 task)。注意它不同于"用户态↔内核态"的模式切换(系统调用/中断,还是同一个 task)——上下文切换换的是 task。切换是纯开销(直接 200-1500 周期 + 间接 cache/TLB 污染),自愿多 = 阻塞在锁/IO,非自愿多 = 被抢占/CPU 过载,用 pidstat -w、vmstat cs 观测。
拆分导航
本篇为拆分后的导读页,四个主题各自独立成文:
| 文档 | 内容 |
|---|---|
| 分类体系 | 四类切换(进程/线程/中断借壳/内核抢占)+ 模式切换辨析 + 切线程 vs 切进程 |
| 实现篇 | 上下文三块组成 + schedule()→context_switch() 流程 + 四大触发场景时序图 + switch_to 汇编 + FPU 惰性切换 |
| 开销与观测篇 | 直接/间接开销量化 + 频繁判定标准 + 观测命令 + 显性/隐性成本 + 瓶颈判断 + 优化策略 + x86/ARM 对比 |
| 实战案例篇 | perf stat 三层定位实战(含 pidstat/perf report 深入定位)+ write() 全链路实战 |