CPU 怎么做到一边刷网页一边跑编译?从内核视角拆解任务切换的两种上下文
我做后端性能调优有些年头了,每次遇到系统响应变慢、吞吐量上不去,第一件事就是去看上下文切换。很多人对“上下文切换”的理解停留在字面:不就是把 CPU 从任务 A 换到任务 B 嘛。可真要追下去——具体切了什么、哪些操作是致命的、为什么某些场景下切换开销高得离谱——能讲清楚的人并不多。
这篇文章我想从一个内核开发者的视角,把 CPU 任务切换这件事彻底拆开。标题里说的“两种上下文”,我指的是寄存器上下文和内存上下文。前者是硬件状态,后者是地址空间状态,两者合并起来,才构成一次完整的任务切换。整篇文章会落到 Linux 内核的实际代码路径上,从schedule()到__switch_to(),一步步交代清楚。适合正在看内核源码的人、做嵌入式开发的朋友,也适合搞性能分析、老被“上下文切换过高”困扰的运维同学。
1. 先搞清楚:任务切换到底在“切”什么
1.1 一个生活化的类比:多人共用一台电脑
你先想象一个场景:三个人合用一台电脑,每个人有自己的工作目录、打开的浏览器标签页和编辑器草稿。为了公平,规定每人只能用 10 分钟。张三用完了,李四要上来接着用——这时候你至少要做两件事:
第一,把张三当前的工作状态完整保存下来,光标在哪、文件改到哪了、浏览器开了哪些页面,全得记住,不然张三下次回来没法接着干。第二,把李四的工作环境恢复出来,他上次留下的那些窗口、草稿、目录得重新摆好。
CPU 切任务也是完全一样的逻辑。每个进程在使用 CPU 的时候,会在寄存器里留下大量“临时状态”,比如当前执行到哪条指令(PC 指针)、栈顶在哪(SP 指针)、各种中间计算结果(通用寄存器)。而进程要能正常工作,还必须拥有一个完整的虚拟地址空间——页表、内存映射、代码段数据段的位置,这些构成了它的“工作目录”。
任务切换就是把上一任进程的“工作状态”存档,再把下一任进程的“工作环境”完整恢复。前者的核心是寄存器上下文,后者的核心是内存上下文。
1.2 两种上下文各包含什么
先说寄存器上下文。你 CPU 里的通用寄存器(x86_64 下就是 RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP、R8~R15)、指令指针 RIP、标志寄存器 RFLAGS,再加上段寄存器、控制寄存器 CR3,都属于硬件状态。这些寄存器里的数值,是进程“瞬间快照”的全部意义——只要完整保存和恢复它们,进程就能像没被打断过一样继续跑。
再说内存上下文。每个进程有自己独立的虚拟地址空间,这是由页表决定的。x86 架构下页表基地址放在 CR3 寄存器里,ARM 架构下对应 TTBR0/TTBR1。切换进程时,CR3 要换成新进程的页表基址,旧进程的页表不能继续用。这意味着整个虚拟地址空间的映射关系都变了,CPU 访问任何内存都要走一套全新的翻译流程。
这里有个非常关键的细节:寄存器上下文属于“硬件状态”,内存上下文属于“地址空间状态”。前者只管恢复计算现场,后者管恢复“这个进程看到的世界长什么样”。两者缺一不可。
注意:CR3 寄存器既有寄存器上下文属性(它是寄存器的一份子),又决定了内存上下文(页表基址)。所以在内核源码里,切换 CR3 往往被划到 mm 切换部分,而不是纯寄存器保存部分。后面讲流程的时候你就能看到这个区别。
2. 内核视角的切换流程:从schedule()到__switch_to()
2.1 触发切换的时机:主动让出与被动抢占
任务切换不是 CPU 自己“想换就换”,而是内核在特定时机主动做出的决策。理解这一点,才算入了内核的门。
触发时机大体分三类:
- 主动让出:进程自己调用
sleep()、等待 I/O、加锁阻塞,或者调用sched_yield(),明确告诉内核“我暂时不干了”。这时候进程进入睡眠状态,内核必然会选一个新的任务来跑。 - 时间片到期:进程的时间片用完了,即使它还不想让出 CPU,内核也会强制把它的运行资格挂起,换下一个进程。这保证了一个进程无法长期霸占 CPU。
- 高优先级抢占:外部中断唤醒了一个高优先级任务(比如实时线程),或者新就绪的任务优先级更高,内核会在最近的调度点抢占当前任务。
无论哪种时机,最终都会走到一个统一入口——schedule()函数。我用一个实际例子说明:你在终端里敲cat file.txt,磁盘 I/O 还没返回时,cat进程就阻塞在read()系统调用上。内核在系统调用的返回路径上发现当前任务需要等待,于是调用schedule(),切到别的进程运行。等磁盘数据到达、中断唤醒cat之后,它才重新进入就绪队列。
2.2 从schedule()到context_switch()的调用链
schedule()是切换的入口,但真正的切换动作由context_switch()完成。我把核心步骤拆成一张表,方便你对照源码看:
| 阶段 | 函数/宏 | 核心动作 |
|---|---|---|
| 调度决策 | schedule()→__schedule() | 选出 next 任务,关抢占 |
| 切换地址空间 | context_switch()→switch_mm_irqs_off() | CR3 写入新进程页表,处理 TLB |
| 切换寄存器现场 | context_switch()→switch_to() | 当前进程寄存器保存到内核栈,恢复 next 寄存器 |
| 善后处理 | finish_task_switch() | 释放旧任务的锁,更新统计,重新开抢占 |
以上是 Linux 内核主路径的简化模型。两个进程 A 和 B 切换,最终控制流是A 的内核栈 → CPU 寄存器 → B 的内核栈,这个跳跃不是函数调用,而是直接改写栈指针和指令指针。
在调试内核时,最直观的感受是:switch_to()执行完之后,当前代码路径的“身份”就变了。你在这个函数里看到的是 A 的寄存器被压栈,出来的时候执行流已经站在 B 的代码里了。
2.3__switch_to()到底做了什么:寄存器的打包与解包
__switch_to()是架构相关的汇编级函数,它是切换寄存器现场的执行者。x86_64 上,内核通过arch/x86/kernel/switch_to_64.S(或 C 包装加内嵌汇编)来切换栈、保存标记。
我先说说task_struct里那个关键的thread字段。每个进程在创建时,内核都会为它分配一个thread_struct,里面存放的就是首次切换时需要恢复的寄存器初始状态(比如新进程第一次运行要执行什么函数、用户栈在哪)。switch_to()做的事情可以用一句话概括:“把当前 CPU 寄存器倒进当前进程的thread_struct,再把下一个进程thread_struct里的寄存器的值倒进 CPU。”
我在 32 位 ARM 平台上调过这段路径,当时的__switch_to汇编就是典型的保存-切换-恢复三段式:
// 保存当前寄存器(旧进程) stmfd sp!, {r4 - r12, lr} // 压栈 str sp, [r0, #THREAD_SP] // 栈指针存入旧进程 thread 结构 // 切换到新进程 ldr sp, [r1, #THREAD_SP] // 从新进程 thread 结构取出栈指针 // 恢复新进程寄存器 ldmfd sp!, {r4 - r12, lr} // 弹栈注意这段话是伪代码,真实内核会用switch_to宏加异常返回机制来实现,但原理完全一致。核心要点是:保存现场和恢复现场是围绕每个进程自己的内核栈进行的。旧进程的新一轮寄存器快照压进旧进程内核栈,新进程的寄存器快照从新进程内核栈弹出。CPU 的寄存器是共享的,但每个进程的内核栈是独占的。
实操心得:跟踪这段代码时,别在
switch_to()里面设断点,设了也没用——执行完这条指令,你看到的进程已经变了。我当年调试任务切换,是在switch_to()前后分别打印current->pid,看到变化的那一刻,才算真正理解了“切换”的含义。
3. 内存上下文的切换:容易被忽略的重头戏
3.1 页表切换:CR3 写入的连锁反应
寄存器切换是显式的、直观的,但很多人低估了内存上下文切换的开销。现代 CPU 访问内存依赖虚拟地址翻译,翻译的依据就是页表。你的项目就算只改一个字节,CPU 也要先走“虚拟地址 → 页表 → 物理地址”的路径。为了加速这个翻译,CPU 内部有 TLB(Translation Lookaside Buffer)缓存最近的翻译结果。
切换进程时,CR3 要写入新进程的页表基址。问题来了:TLB 里缓存的是旧进程的翻译结果,这些结果在新进程的上下文里是完全无效的。如果不清掉,CPU 可能把 A 进程的虚拟地址翻译成 B 进程的物理地址,这是灾难性的。
传统做法是切换 CR3 时全量刷新 TLB(x86 的mov cr3操作会让非全局 TLB 项全部失效)。代价很明显:新进程跑起来后的相当一段时间,TLB 是冷的,每次访存都可能缺页翻译,性能断崖式下降。这也解释了为什么上下文切换频率高的系统,整体性能会明显变差——大量时间花在“恢复地址翻译缓存”上了。
3.2 PCID(x86)与 ASID(ARM):减少 TLB 冲刷的硬件方案
为了解决“一切换就要全刷 TLB”的痛点,x86 引入了 PCID(Process Context Identifier),ARM 也有类似的 ASID(Address Space Identifier)。思路都是在 TLB 条目上打标签,标明这个翻译属于哪个地址空间。切换进程时,如果 TLB 里同时保留了多个进程的翻译条目,CPU 通过比对 PCID/ASID 来判定命中与否,不需要全量清空。
我拿 x86_64 的实践举例:内核激活 PCID 后,CR3 切写时带上新进程的 PCID,TLB 中旧进程条目留着不清理。下次切回旧进程时,它的 TLB 条目可能还有效,地址翻译直接命中,省去了一次翻译开销。
但这带来一个容易被忽视的问题:不同 PCID 的 TLB 条目共存,可能挤占 TLB 容量。PCID 用 12 位,最多 4096 个标识,如果系统里活跃进程很多,TLB 塞满了不同进程的条目,相互挤占,命中率反而下降。所以内核在switch_mm_irqs_off()里会根据实际情况判断:全量刷新 TLB 还是保留部分条目。这恰恰是很多人调优时忽略的变量。
注意:PCID/ASID 是硬件特性,不是所有平台都支持。做嵌入式开发的朋友要注意目标 CPU 是否具备 ARM 的 ASID 支持,没有的话,内存上下文切换的代价就是硬性的 TLB 全清,对实时性指标影响很大。
3.3 内核线程的特殊性:不换地址空间的切换
前面说的内存上下文切换,并不适用于所有情况。内核线程(比如 kworker、kthreadd)是特殊的存在:它们没有自己的用户态地址空间,只在内核态运行,task_struct里的mm字段是 NULL。
按常理,切换到一个内核线程时,CR3 应该怎么处理?答案是:继承上一个用户进程的地址空间。在 Linux 中,内核线程运行时使用“借用的”mm,也就是上一次发生切换的用户进程的页表。
这样做的好处非常明显:虽然任务切换了,但地址空间可能不需要动,CR3 可以保持不变,TLB 不用因为内存上下文切换受影响。于是我们能看到一个有趣的现象:从进程 A 切到内核线程 K,再从 K 切回 A,如果中间没有切到其他用户进程,整个过程中内存上下文一次都没变,只有寄存器上下文做了两次保存和恢复。
我在测系统调用延迟时发现,很多“任务切换开销高”的误判,其实是把“内核线程频繁切换”和“用户进程切换”混在一起了。前者的开销远小于后者,因为省掉了 TLB 的损失。所以分析性能数据时,先判断切换对象里有没有大量内核线程,这个细节能避免很多误诊。
4. 用户态与内核态:模式切换不等于任务切换
4.1 系统调用/中断:只切栈,不切任务
初学者最容易把两个概念搞混:用户态到内核态的切换(模式切换)和任务切换(上下文切换)。我单独把这个问题拉出来讲,是因为混淆这两个概念会让你的性能分析报告彻底失真。
模式切换发生在一个任务内部。比如进程调用read()系统调用,CPU 从用户态陷入内核态,会经历这样几个步骤:特权级从 Ring 3 切到 Ring 0,栈从用户栈切到内核栈,相关寄存器(尤其是栈指针和指令指针)要重新定位,但没有进程层面的“换人”发生。系统调用执行完了,CPU 回到用户态,还是同一个进程接着跑。
那为什么模式切换也被很多人戏称“半个上下文切换”?因为它的开销也来自保存和恢复现场。Linux 在进入内核态时,会把用户态寄存器存到内核栈pt_regs结构里,返回时将pt_regs恢复回去。成本几十纳秒到几百纳秒不等,比真正的任务切换(微秒级起)便宜一个量级。
4.2 区分两种“切换”的实操判断法
给你一个我常用的判断方法,不靠文档,靠现象:
- 如果切换前后的
current进程PID 没有变,只是特权级变了,这是模式切换。 - 如果切换前后的 PID变了,哪怕是从进程 A 切到一个内核线程,也是任务切换。
排查时用perf或者内核 tracepoint 能直接看到sched:sched_switch事件的prev_pid和next_pid,一眼辨认。我见过有人拿着vmstat的cs列跟老板汇报“上下文切换单核每秒几十万”,但实际上cs列统计的是内核态入口数量,其中包含大量系统调用和中断进入,并不全是任务切换。这个误读很典型,数值看着吓人,实际可能只是高并发系统调用。
注意事项:分析
/proc/stat里的ctxt或vmstat的cs,先算一下每秒系统调用数。如果二者量级接近,那这个“上下文切换”主要是模式切换,而非任务切换。任务切换要单独看sched:sched_switch或pidstat -w输出。
5. 实战:如何观测和排查上下文切换开销
5.1 三条常用观测路径
直接给命令和解释,你可以照着在自己的机器上跑。下面这些工具,过去几年帮我在生产环境里定位了不止一次问题。
第一,看全局趋势用vmstat 1,输出里的cs列是每秒上下文切换次数,in列是中断次数。如果一个系统cs长期高于in两三倍以上,基本可以确定任务切换频繁。
第二,按进程看用pidstat -w 1,cswch/s是自愿切换(进程主动让出),nvcswch/s是非自愿切换(时间片到期或抢占)。如果nvcswch/s异常偏高,排第一的通常是抢占了太多 CPU 的进程,或者 CPU 核数严重不足。
第三,要精细分析切换路径,用perf sched record配合perf sched timehist看单次调度延迟和切换耗时。这个命令能还原每次切换的时间分布,帮你判断是调度器决策慢,还是取决于上下文切换本身的硬开销。
5.2 开销过高的典型场景与调优思路
我在这里总结几个我在生产环境里实际踩过的坑和对应的解法,有些可能和你预想的“加大 CPU”这种外行思路完全不同。
| 现象 | 可能原因 | 排查/调优方向 |
|---|---|---|
| cs 高,但系统负载不高 | 大量短生命周期线程频繁创建销毁 | 改用线程池,减少线程数 |
| nvcswch/s 高,CPU 使用率却不饱和 | 线程数超过 CPU 核数数倍 | 调整线程池大小接近核数 |
| 切换耗时波动大 | 内存上下文切换导致 TLB 抖动 | 启用 PCID/ASID,绑定 CPU 亲核 |
| 单核 cs 极高且伴随高中断 | 网络软中断频繁唤醒进程 | 开启 RPS/RFS,调中断亲和性 |
还有一个容易被忽视的点:锁竞争严重时,上下文切换会像雪崩一样爆发。多个线程抢同一把锁,抢不到就休眠,等锁释放又被唤醒,内核来回切换。这种场景光靠调调度器参数是没用的,得从锁粒度、锁类型下手(比如用rwlock、seqlock替代互斥量,或者改成无锁结构)。
5.3 我自己常用的几个内核参数与技巧
kernel.sched_autogroup_enabled:默认开启。它会自动把同终端下的进程分到同一个调度组,平均分配 CPU。生产环境里如果你希望每个进程被独立调度,有时要关掉它,但大多数场景保持默认就好。kernel.sched_min_granularity_ns:调小这个值可以让调度器更频繁切换,但代价是开销增加。追求吞吐量的服务器一般调大,追求交互响应的桌面调小。- 绑定 CPU 亲和性(
taskset或sched_setaffinity):把一组高频协作的线程绑到固定几个核上,能显著减少“跨核迁移 + TLB 刷新”的额外开销。我测过一些场景,仅这一项就能把切换相关的延迟降低 20%~30%。
实操心得:生产环境改动调度器参数,我坚持“一次只改一个变量、灰度一批机器”的原则。调度器参数牵一发动全身,改完要盯至少几天的
vmstat、负载和延迟分位数,不能看完十分钟就下结论。
在项目日志调整过后,我通常还会多留一份sched:sched_switch的 trace 数据做对照。原因很简单:调优是个反复实验的过程,没有基准数据,后面讨论任何“是否有效”都是空话。
6. 关于“两种上下文”的最终理解
所谓两种上下文,最后落到代码上就是这两个动作:保存/恢复寄存器(switch_to)和切换地址空间(switch_mm)。一个管“继续算”,一个管“看到什么”。理解了这两个动作,你再回去看调度器代码、看性能报告中的上下文切换数值、看内核线程是否有 mm,会清晰得多。Linux 内核里的很多设计——比如schedule()的调用时机、task_struct里thread结构的内存布局、PCID 和 ASID 的引入——全都是围绕这两个核心动作展开的。
我个人看代码时的体会是:任务切换看起来是“调度器选下一个任务”的决策问题,实际上更像“如何最小化两个状态切换代价”的工程问题。寄存器上下文切换再快,扛不住内存上下文切换的 TLB 冷启动;内存上下文切换做得再好,也绕不开寄存器保存恢复这条必经之路。真正的高手做优化时,注意力往往不放在让switch_to更快,而是想办法减少切换发生的次数——把活干完,比切换得更快更重要。
最后分享一个我的小习惯:拿到一份系统响应慢的排查请求,我总会先看一眼/proc/pressure/cpu(PSI 数据里的 CPU 压力值),再结合pidstat -w判断是调度延迟还是切换本身成了瓶颈。CPU 压力高,说明任务排队等 CPU,要减负载;切换开销高,说明调度过于频繁,要降频率或改亲和性。这两个方向完全不同,错过一个,排查就会绕远路。