上下文切换的分类体系 —— 切进程、切线程、中断"借壳"、内核抢占
换个角度看上下文切换:不关心"什么时候切"(那是触发场景的事),而是问"这次在切什么"。按这个维度,Linux 体系下有四类上下文切换——切进程、切线程、中断借壳、内核抢占,它们的共同点是都要换寄存器现场,但要不要换地址空间、有没有 task_struct、能不能睡眠,差别很大。本篇还顺带讲清一个高频误解:系统调用/中断导致的用户态↔内核态切换不是上下文切换(那是模式切换),以及切同进程线程 vs 切不同进程到底差在哪一步(CR3 + TLB)。
相关:切换的机制与触发场景见上下文切换实现,开销与观测见context-switch-cost,实战案例见context-switch-case;总纲见 context-switch。什么时候触发切换见 scheduling;换地址空间为什么贵见 tlb;中断上下文细节见 interrupts。
一、四类切换总览
按"在切什么"分类,Linux 体系下上下文切换可分为四种类型:
| 类型 | 切换主体 | CR3 变吗 | 有 task_struct 吗 | 开销级别 |
|---|---|---|---|---|
| 进程上下文切换 | 不同进程的 task | ✅ 必换 | ✅ | 最高 |
| 线程上下文切换 | 同一进程内的不同线程 | ❌ 共享 | ✅ | 中等 |
| 中断上下文切换 | 中断处理程序接管 CPU | ❌ 借用 | ❌ 没有 | 轻(但有限制) |
| 内核抢占上下文切换 | 内核态中被更高优先级的 task 抢走 | 取决于新旧 task | ✅ | 等同进程/线程切换 |
先记住这个表,下面逐类展开。
1.1 进程上下文切换(粒度最大,代价最高)
进程拥有独立的虚拟地址空间、独立的页表、独立的文件描述符表、独立的内存映射。切换进程时:
- 保存当前进程的用户态寄存器、内核栈、程序状态
- 将当前进程放回就绪队列
- 修改 CR3 寄存器,载入新进程的页表根——整个虚拟地址空间发生替换
- CR3 一换,TLB 中的地址翻译条目大面积失效(详见 tlb)
- 载入新进程的内核上下文和通用寄存器,恢复执行
核心特征:地址空间隔离导致 CR3 必定变更,后续所有访存都需要重建 TLB。
1.2 线程上下文切换(开销显著低于进程)
同一进程内的线程共享虚拟地址空间和页表,CR3 不变。只需置换:
- 通用寄存器、PC、线程私有栈
- 信号掩码、TLS(线程局部存储)
不需要切换:页表、文件描述符表、内存映射、全局变量。因此 TLB 缓存绝大部分可保留,这也是"多线程比多进程轻"的底层原因。
具体对比已在 §三 切线程 vs 切进程 中详细展开。
1.3 中断上下文切换(特殊的"借壳"上下文)
CPU 收到外设的中断信号,会打断当前正在运行的线程,跳转到中断处理函数。这形成了一种特殊的上下文切换——它没有独立可调度的实体,而是 "借用"被打断线程的执行环境 来运行内核中断处理代码。
与真正的进程/线程上下文切换不同,中断上下文切换有以下关键特征:
| 维度 | 进程/线程切换 | 中断上下文"切换" |
|---|---|---|
| 有 task_struct | ✅ | ❌ — 用 preempt_count bit 标记 |
| 换 CR3(页表) | 跨进程才换 | ❌ — 借用被打断 task 的地址空间 |
| 运行栈 | task 自己的内核栈 | IST 独立中断栈(per-CPU,16KB) |
| 能睡眠/调度 | ✅ schedule() | ❌ — 无 task 可挂起,睡了全核卡死 |
| 触发真调度 | schedule() 内直接切 | iret 返回前检查 TIF_NEED_RESCHED 才切 |
中断处理的完整细节见 interrupts:IST 栈切换的硬件流程(§3.1,含用户态/内核态两张时序图和对比表)、中断上下文的运行时限制(§3.2,含无 task_struct、不换页表、Ring 0 特权)、为什么不能睡眠(§2.4,含危险操作表、死锁场景与防御机制)、中断返回如何触发真正的进程调度(§3.3,含完整时序图)、磁盘 DMA 完成中断端到端示例(§3.4)。
1.4 内核抢占上下文切换
Linux 2.6 之后引入内核抢占(Kernel Preemption)。当内核态执行代码时,如果出现更高优先级的 task 需要运行,可以直接抢占当前内核路径:
- 老式非抢占内核:必须等当前内核代码执行完毕、退出内核态之后,才能调度新 task。高优先级任务的调度延迟不可控。
- 抢占内核:内核路径中途就可以被抢占,降低实时响应延迟。
从切换机制来看,内核抢占和普通线程切换完全相同——都要经历 schedule() → context_switch() → 视情况 switch_mm + switch_to。但要注意:内核态持有自旋锁时不可抢占(preempt_count > 0),避免死锁。具体时序见机制篇 §2.3 被高优先级任务抢占。
二、特别注意:模式切换 ≠ 上下文切换
有个高频误解要澄清:系统调用/中断导致的"用户态→内核态"切换,不是上下文切换。
| 模式切换(mode switch) | 上下文切换(context switch) | |
|---|---|---|
| 换的是 | 同一个 task 的特权级(用户态↔内核态) | 两个不同 task |
| 换 task 吗 | 不换——还是原来那个进程/线程 | 换——CPU 去跑另一个 task |
| 触发 | 系统调用、中断、异常 | 调度器决定换 task |
| 换页表吗 | 不换(还是同一进程) | 跨进程才换 |
| 开销 | 较小(见 syscall) | 较大(尤其跨进程) |
- 系统调用(syscall):进程从用户态陷入内核态执行,还是同一个进程,只是特权级变了——这是模式切换,不是上下文切换。
- 中断(interrupts):CPU 被打断进内核跑 handler,通常也只是模式切换(借用被打断 task 的上下文)——除非中断处理完唤醒了更高优先级任务、触发
schedule(),那才引发一次真正的上下文切换。 - 一次上下文切换通常包含模式切换(因为切换在内核态做),但模式切换不一定带来上下文切换。别把两者等同。
三、切线程 vs 切进程:差在这一步

| 切同进程线程 | 切不同进程 | |
|---|---|---|
| 寄存器现场 | 换 | 换 |
| 内核栈 | 换 | 换 |
| 地址空间/CR3 | 不换 | 换(switch_mm) |
| TLB | 基本保留(地址空间没变) | 大多失效,后续访存重走 page walk(见 tlb) |
| L1/L2 缓存 | 大多仍有效(数据地址空间相同) | 多半变冷(工作集不同) |
| 相对开销 | 较小 | 较大 |
- 为什么切进程更贵:换 CR3 会让 TLB(地址翻译缓存)大面积失效——新进程的虚拟地址翻译不在 TLB 里,接下来每次访存都可能触发一次 page walk(4 次访存,见 tlb)。缓存也因工作集不同而变冷。所以"切进程"比"切同进程线程"多付一大笔隐性代价。
- PCID 优化:现代 x86 有 PCID(进程上下文标识符),给 TLB 表项打上进程标签,换 CR3 时不必全清 TLB——同一进程的翻译还能留着,减轻切进程的 TLB 惩罚。但仍不如"不换地址空间"的线程切换便宜。
- 这也是"多线程比多进程轻"的一个底层原因:同进程线程间切换省掉了地址空间切换和 TLB 失效。呼应 task-struct"共享得越多越像线程"。
一句话总结
上下文切换按"在切什么"分四类:进程切换(换 CR3、TLB 大面积失效、最贵)、线程切换(同进程共享 mm、不换 CR3、较便宜)、中断上下文切换(没有 task_struct、借用被打断 task 的地址空间、不换 CR3 也不调度、但不可睡眠有严格限制)、内核抢占切换(内核态中途被抢,机制等同普通切换,但持自旋锁时不可抢占)。模式切换(用户↔内核态,还是同一个 task)不是上下文切换——一次上下文切换通常包含模式切换,反之不然。四类里最值得记住的差异是:同进程切线程不换 CR3,跨进程切换要换——这正是"多线程比多进程轻"的底层原因,PCID 只能减轻换 CR3 的 TLB 惩罚,抹不平这个差距。