CPU“偷梁换柱”的真相:上下文切换到底切了什么? 如果你平时用top或者写过多线程程序大概率会冒出过一个问题我的电脑明明只有 4 个核凭什么能同时跑几十个进程、几百个线程答案有点“伤人”CPU 并不会真的同时执行这么多任务它在“骗”你。准确地说是操作系统在帮你“偷梁换柱”——把正在运行的进程从处理器上悄悄抽走再换上另一个进程整个过程快到你的眼睛和大脑根本感知不到。这个“偷梁换柱”的动作就是所谓的上下文切换也是题目里问的那句“任务切换到底切了什么”。这篇文章我不想只甩给你一个操作系统的标准定义。我会带你从底层现场出发把一次任务切换过程中 CPU 到底保存了什么、恢复了什么、付出了什么代价讲清楚。内容适合正在学操作系统、被并发编程折腾到失眠的开发者也适合日常做性能排查、想搞懂vmstat里cs列的工程师。读完你应该能回答几个最实际的问题为什么线程切换不是免费的为什么协程轻快系统切换次数高是不是一定不好以及——用什么命令能把这个“偷梁换柱”的动作看穿。1. 一场“偷梁换柱”CPU 如何同时假装多任务1.1 一次只能做一件事这不是短板是物理规律先放下“并发”“并行”这些词回到最底层的硬件。一颗 CPU 核心在一个时钟周期内只能取一条指令、执行一条指令。无论操作系统吹得再好单核在任意一个时间点上真正在做的事情只有一件。多任务给我们的“同时运行”体验本质上是一种高速切换制造出来的幻觉。我把这种切换比作“偷梁换柱”梁是任务运行到一半时留在 CPU 上的全部“痕迹”柱是这条流水线本身。操作系统在你毫不知情的时候把梁抽走换上另一根然后继续往下演。任务 A 可能刚算到一半任务 B 就被推上来了B 跑了一阵子A 又被换回来接着上次断掉的地方继续算。这一段一段的“接着算”靠的就是切换时把断点记得清清楚楚。你可能会问那为什么不干脆让一个任务跑完再换下一个因为如果一个程序在等磁盘、等网络、等用户输入CPU 干等着就是巨大的浪费。操作系统的调度器希望 CPU 始终“有活干”所以一旦发现当前任务无法继续推进或者分配给它的时间片用完就立刻换下一个能跑的任务上来。这就是任务切换存在的根本原因。1.2 “梁”是什么一整套处理器现场清单既然要“接着算”就必须把上次算到哪儿、算到一半的数据放在哪、状态是什么全部存下来。这就是上下文。上下文不是单个变量而是一整套现场信息至少包括以下几样程序计数器PC下一条要执行的指令地址。没有它切回来不知道从哪继续。通用寄存器组包括算术逻辑运算用的 RAX、RBX、RCX、RDX以及指针相关的 RSP栈指针、RBP基址指针等。这些是任务运行时的“草稿纸”。状态寄存器如 RFLAGS记录进位、零标志、中断开关等状态。少一个字都可能导致后续分支判断错误。浮点与向量寄存器如果你的程序在做浮点运算、SIMD 指令这些寄存器也必须保存。栈每个任务线程/进程有自己的内核栈和用户栈栈指针保存后切回来才能继续调用函数。地址空间信息对进程而言还有页表基址例如 x86 的 CR3 寄存器。因为不同进程的虚拟地址空间不同切进程必须把地址空间也换掉。其他内核资源打开的文件描述符、信号掩码、定时器等通常记录在进程控制块PCB或线程控制块TCB中不需要全部塞进 CPU但切换前要保证状态一致。你可以把这一整套东西想象成一位说书人手里的扇子、醒木和台词本。换一个说书人上台不仅人要换扇子、醒木、台词本也得换否则台下一听就露馅。操作系统要做的就是把这个“换”的过程做到既快又不出错。2. 还原切换现场中断、调度器与三张“记账单”2.1 谁来发起切换三种常见触发路径上下文切换不会自己发生总得有人“踩一脚刹车”。实际系统中主要有三种触发路径时间片耗尽每个任务分到一段 CPU 时间比如 1ms 到 100ms。时钟中断按时到来操作系统一看“你的时间到了”于是强制换下一个任务。这是抢占式调度最常见的触发源。任务阻塞程序主动或被动地陷入等待比如读磁盘、等网络包、等待锁。这种时候任务无法继续执行内核收到系统调用后会把它移入等待队列然后调度其他任务运行。高优先级任务就绪如果中高优先级任务突然变成可运行状态比如等到了 I/O 事件调度器可能立刻剥夺当前低优先级任务的 CPU。这种叫抢占同样会触发切换。无论哪种触发最终都要走到同一个内核模块调度器scheduler。调度器的任务是从就绪队列里挑出“下一个幸运儿”然后执行切换。挑人的策略五花八门CFS完全公平调度、实时调度策略、分时调度等。但对“上下文切换”本身来说挑人的算法只是前半场后半场是纯粹的体力活——保存现场、恢复现场。2.2 内核态流水线从保存现场到恢复现场的完整路径一次完整的上下文切换大致要经历下面这些步骤。这里以 Linux 进程切换为例某任务正在用户态运行触发了时钟中断或系统调用CPU 自动陷入内核态。内核保存当前任务在用户态的寄存器现场到该任务的内核栈。这个过程由 CPU 硬件和内核入口代码共同完成通常包括保存 PC、栈指针、通用寄存器、状态寄存器等。进入内核的中断/异常处理路径更新当前任务的内核状态比如记录运行时间、把任务状态从RUNNING改为RUNNABLE或WAITING。调度器开始工作从就绪队列选中下一个要运行的任务。如果需要切换地址空间也就是切换进程而不是同进程内的线程内核会更新页表基址寄存器把 TLB页表缓存置为失效。这一步很昂贵后面会展开说。把当前任务的 CPU 寄存器上下文保存到它的 TCB/PCB 中如果第 2 步只保存了用户态现场这里还要保存部分内核态现场再加载下一个任务的 PCB/TCB 上下文。切换内核栈从当前任务的内核栈指针换到下一个任务的内核栈指针。从下一个任务的内核态返回路径恢复现场CPU 跳转到新任务之前被中断或阻塞的位置回到用户态继续跑。整个过程听起来不长但每一步都有代价。尤其是第 5 步的 TLB 刷新直接让后续几乎每次内存访问都要重新走页表翻译缓存命中率瞬间下降。2.3 用户态到内核态算不算上下文切换这里必须澄清一个普遍误解系统调用比如read、write进入内核态并不算任务切换。进入内核态、再返回同一个用户任务只是同一根“梁”在用户态和内核态之间进出执行上下文还是属于同一个线程。真正算上下文切换的是“换了一个执行流”从任务 A 的执行上下文切到任务 B 的执行上下文。为什么这个区分很重要因为很多人写程序时会有一个错误预期——“我调read读取文件这就是一次切换”。实际上read如果立刻有数据返回整个过程可能根本没有发生任务切换只是经历了一次用户态/内核态模式切换。但假如read需要等待磁盘内核知道当前任务无法继续才会把它挂起并切换到其他任务。也就是说阻塞式系统调用往往伴随着任务切换但非阻塞/快速返回的系统调用不一定。做性能分析时必须把这两种情况分开看否则很容易误判。3. 切换的账本看得见的开销和看不见的损失3.1 直接开销寄存器、栈和调度器的“工钱”先把账算明白。每次上下文切换的直接成本包括保存和恢复寄存器的指令执行时间。几十个寄存器每次切换都要 push、pop 一遍。几十个字节的数据单个操作只有几纳秒但次数多了也是钱。内核调度器本身的执行时间。调度器要从就绪队列里找任务可能需要操作红黑树、维护运行队列、处理负载均衡。这个时间取决于 CPU 数量和就绪队列长度通常在微秒量级。内核栈切换。换任务就要换内核栈涉及到栈指针的设置以及可能的内核对象引用计数更新。TLB 刷新和地址空间切换。换进程时最贵的一笔账。现代 CPU 用 TLB 缓存虚拟地址到物理地址的映射一旦切换进程旧映射不能继续用必须刷新后续访问内存会频繁触发 page walk可能带来几十甚至上百纳秒的惩罚。这些直接成本叠加起来一次进程切换在现代机器上通常要消耗1~10 微秒。听起来很少如果一个进程每秒切换 5 万次那就是 0.05~0.5 秒的 CPU 时间被切走相当于直接损耗 5%~50% 的单核吞吐。在延迟敏感的服务上这个数字会非常刺眼。3.2 隐形开销缓存冷掉比切寄存器贵得多真正的灾难往往不是寄存器保存本身而是缓存冷却。CPU 的 L1/L2/L3 缓存、TLB、分支预测器都属于“热”硬件它们会记住当前任务的访问模式。切换任务之后新任务的代码、数据、栈都是冷的缓存里几乎没有它想要的东西。于是在切换后的相当长一段时间里CPU 执行效率是打折扣的——每访问一个变量都可能从内存重新加载每调用一个函数都可能让分支预测器重新学习。举个例子任务 A 正在循环处理一个 10MB 的数组数组已经热在了 L3 缓存里。此时任务 B 插进来把缓存空间占掉一部分甚至全部。等 A 再回来之前缓存的数据大概率没了必须从内存重新读一遍。如果循环正好卡在关键路径上一次切换造成的性能损失可能比那 1 微秒的寄存器保存时间高出几个数量级。这也是为什么很多高并发服务会刻意控制线程数而不是简单认为“多开会更好”。3.3 量化一下一次切换到底值多少钱下表是我在 Intel x86 机器上做小实验时的典型量级供参考环节典型耗时说明协程/用户态线程切换10~100 ns不陷内核仅保存少量寄存器同进程线程切换1~3 微秒需进入内核但共享地址空间进程切换2~10 微秒额外承担 TLB 刷新和页表切换切换后缓存预热损失数微秒到数十微秒与工作集大小、缓存层级强相关实测时要注意不同 CPU、内核版本、容器环境差异很大。但结论是稳定的用户态协程 线程切换 进程切换。这也是后面要讲协程为什么“轻”的依据之一。4. “偷梁换柱”的变体线程、协程与进程的切换差异4.1 进程切换 vs 线程切换不要想当然很多教材说“同一进程的线程切换比进程切换开销小”这句话大方向没错但理由要想清楚。线程切换之所以轻不是因为它不需要保存寄存器——寄存器照样要保存而是因为它不需要切换地址空间。同一个进程里的多个线程共享虚拟内存、页表、文件描述符表所以切换线程时CR3 寄存器不用换TLB 不用全部刷新缓存命中率也更高。但别高兴太早。多线程程序在切换时还会有另一类开销同一进程内多线程并发时的锁竞争与同步。如果线程之间频繁互相唤醒、争抢同一个锁调度器就会在它们之间来回切换伴随大量上下文切换。这种切换每次都不贵但数量一多累计成本就上去了。更麻烦的是锁竞争导致的线程阻塞和唤醒会带来延迟抖动这种延迟可能比切换本身更让业务方头疼。因此衡量线程切换是否“便宜”不能只看单次成本还要看整个并发模型是否过度依赖锁和唤醒。你可以在vmstat里看到上下文切换数飙升但进程数根本没变这时多半就是线程在频繁互相唤醒。4.2 协程用户态自导自演的“轻量换柱”协程以及 Go 里的 goroutine之所以被人津津乐道是因为它把“偷梁换柱”从内核搬到了用户态。协程切换不需要陷入内核也不经过内核调度器只需在用户态保存和恢复少量寄存器、栈指针。一次切换可能只需要几十纳秒比线程切换快一个数量级以上。但协程并不是魔法。它的“轻”建立在两个前提下协作式切换协程必须显式让出执行权或者由运行时在“异步等待点”自动切换。如果一个协程里执行了阻塞式的系统调用比如直接read磁盘文件它仍然会卡住整个线程进而卡住同线程的其他协程。所以成熟的协程库都会把阻塞 I/O 改成非阻塞 事件驱动模式。一个线程上的所有协程共享同一个内核线程。CPU 并行度不会因为协程数增加而提升真正能并行执行的依然只有那几个内核线程。换句话说协程优化的是“单线程内部大量轻量任务切换”的场景。任务之间的确可以频繁切换但每次切换都发生在同一个内核线程里不需要惊动内核也就不会产生内核上下文切换。把协程理解成“用户态自导自演的换柱戏法”我觉得非常贴切。4.3 多核时代跨 CPU 切换的特殊代价现在的服务器基本都是多核多 NUMA非一致内存访问架构。所谓“跨 CPU 切换”是指一个任务之前跑在 CPU 0后来被调度到 CPU 1 上运行。这不仅仅是“换了个核”这么简单还可能带来两个额外成本缓存彻底丢失L1/L2 是每核私有的L3 在多核之间共享但延迟不同。任务从 CPU 0 迁移到 CPU 1之前积累在 CPU 0 私有缓存里的数据全部作废。NUMA 访存差异如果任务的内存分配在 CPU 0 的内存控制器附近而计算被调度到 CPU 1访问这些内存可能需要跨越内存总线延迟和带宽都会受影响。因此对性能有强要求的服务可以开启CPU 亲和性CPU affinity让关键线程固定在某个 CPU 核心上减少跨核迁移。Linux 下的taskset命令或者sched_setaffinity系统调用都能做。这也算一种“少换梁”的调优思路。5. 如何知道自己被“换”了多少次观测与调优5.1 三行命令看清切换频率讲再多概念不如动手看数据。Linux 下最常用的观测命令是vmstat我建议这样用vmstat 1 5输出里有一列cs就是每秒钟上下文切换次数单位是千次/秒。例如procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 0 0 8021912 123456 6123456 0 0 x y z 23456 12 8 78 2 0cs为 23456说明系统每秒约发生 2.3 万次上下文切换。这个数字本身没有绝对好坏需要结合 CPU 使用率和负载看。如果cs很高同时sy内核态 CPU也偏高很可能就是大量系统调用和切换在消耗 CPU。想看清是哪个进程在频繁切换用pidstatpidstat -w 1输出中的cswch/s表示自愿上下文切换nvcswch/s表示非自愿上下文切换。自愿切换通常指任务自己等 I/O 或等锁非自愿切换通常指时间片耗尽或被更高优先级任务抢占。如果一个线程的nvcswch/s居高不下可能说明它的时间片总是不够用或者就绪队列里竞争太激烈。还可以直接看/proc/pid/statusgrep -E voluntary|nonvoluntary /proc/1234/status这两行的累计值可以帮你判断一个长期运行进程的切换风格。5.2 切得太多怎么办可落地的优化手段如果数据表明上下文切换确实拖累了性能可以从几个方向下手减少锁的粒度和争抢。用读写锁替换互斥锁、用原子操作替换锁、用无锁队列替代加锁队列。锁争抢是线程被迫阻塞和唤醒的头号原因也是自愿切换的重要来源。用异步 I/O 替代阻塞 I/O。当你用阻塞式系统调用读网络或磁盘时线程会被挂起等数据回来再被唤醒。换来换去切换次数自然高。换成 epoll 非阻塞 I/O或者用协程库封装异步操作可以显著降低线程级切换。合理设置线程池大小。线程数越多就绪队列越长时间片轮转和抢占就越多切换越频繁。一个经验法则是 CPU 密集型任务线程数接近核心数I/O 密集型可以适当加但不是无脑加。开启 CPU 亲和性。对关键业务线程绑定核心避免跨核迁移带来的缓存失效。调整内核调度策略。对实时性要求高的任务可以使用SCHED_FIFO或SCHED_RR对后台批处理任务可以调低 nice 值减少它抢占 CPU 的频率。具体用chrt命令或sched_setscheduler系统调用。这些手段不是互相排斥的往往要组合使用。最好的做法是先观测再小步调整最后用压测验证避免拍脑袋。5.3 一个关键认知切换次数高不等于性能差最后我必须泼一盆冷水上下文切换次数高不代表系统就有问题。有些系统每秒切换几十万次是正常的“业务形态”——比如大量短连接、大量轻量任务互相配合。要看切换是否影响性能请结合以下指标一起判断CPU 是否还有空闲如果id列还有大量空闲切换高的影响可能没那么严重。自愿/非自愿切换比例如果大量是自愿切换可能是在等 I/O这未必是坏事如果是非自愿切换暴涨更可能是调度竞争激烈。延迟是否超标真正影响用户体验的是任务无法及时执行而不是切换本身。也就是说不要为了把cs数字压到最低而阉割正常并发逻辑。我见过有人为了让上下文切换变少把多线程程序硬改成单线程结果吞吐反而下降。正确的目标不是“零切换”而是“切换成本可控、延迟满足要求”。结语一场看得见的“换柱”戏法把上下文切换想成“偷梁换柱”之后我再看那些进程调度、线程切换、协程并发的资料顿时觉得清楚了很多所谓调度不过是在不断决定“下一根梁该换成谁”所谓切换开销不过是在回答“换这根梁要花多少成本”。理解到这个层面日常排查性能问题时你就不会只盯着 CPU 使用率而是会下意识地问一句是不是上下文切换太频繁了切换是在进程之间、线程之间、还是协程之间最后再分享一个小技巧如果你怀疑某个进程频繁切换可以用perf做采样跟踪看切换事件发生在哪些函数附近perf record -e context-switches -c 1 -g -- ./your_app 2 /dev/null perf reportperf report会告诉你程序在哪些调用路径上反复发生切换很多时候答案会让你意外——可能是某个第三方库内部狂开线程也可能是某把锁被无意识地反复争抢。找到了根因优化才算真正开始。