上下文切换:这个骗局的账单
「保存寄存器、恢复寄存器」——听起来是几十条指令的事。如果你也这么估算,那你会低估这件事一个数量级以上。真正的账单在别处,而且它不出现在任何一张火焰图上。
看得见的那部分
一次上下文切换,内核要做的事列出来其实不长:
- 把当前任务的寄存器存进它的
task_struct(16 个通用寄存器 + 段寄存器 + 浮点/向量状态)。 - 更新调度统计(跑了多久、vruntime 加多少)。
- 挑出下一个任务(第 8 章那个红黑树取最左)。
- 如果换了进程:写
CR3,切到新的页表。 - 恢复新任务的寄存器,跳回它上次停下的地方。
这些加起来大约 1–3 μs。第 4 步是个分水岭——线程切换跳过它,进程切换必须做。
如果账单到此为止,那也还好。但第 4 步埋了一颗雷。
看不见的那部分:一个空掉的 TLB
先回忆一下(第 11 章会详讲):每次访问内存,CPU 都要把虚拟地址翻译成物理地址,而这需要走一趟四级页表——四次内存访问,约 120 ns。
如果每次访存都要这样,机器根本没法用。所以 CPU 里有个叫 TLB 的小缓存,记着最近翻译过的对应关系。命中的话只要 约 0.5 ns——快两百多倍。现代 CPU 的 TLB 大概能装几百到几千条。
问题来了:TLB 里存的是「虚拟页 → 物理页」,而每个进程的虚拟地址含义完全不同。进程 A 的 0x400000 和进程 B 的 0x400000 指向两块毫不相干的物理内存。
所以换进程的时候,TLB 里那几百上千条记录全部作废。切换回来之后,你的程序头几百次访存每一次都要重新走页表。
假设切换后有 200 次访存需要重建 TLB 条目,每次多花 120 ns:
200 × 120 ns = 24 μs
而直接成本才 3 μs。间接成本是直接成本的八倍。
更糟的是这笔钱记在你头上:内核的切换代码只花了 3 μs,剩下 24 μs 分散在你程序恢复执行后的几百条指令里。profiler 会告诉你「这个函数变慢了」,但不会告诉你为什么。
缓存也是同样的故事。你的数据本来好好躺在 L1/L2 里,别人跑了 5 毫秒,把缓存全挤走了。你回来一看,什么都得从主存重新拉——L1 命中 1 ns,主存 80 ns,八十倍。
下面这台计算器把三档切换(进程/线程/协程)放在一起算。重点操作:选中「进程切换」,然后把「工作集」从最小拖到最大——你会看到直接成本一动不动,而总账翻了好几倍。
三档切换,差两个数量级
| 协程切换 | 线程切换 | 进程切换 | |
|---|---|---|---|
| 要进内核吗 | 不用 | 要 | 要 |
| 换页表吗 | 不换 | 不换 | ★ 换 |
| TLB | 完好 | 完好 | ★ 全废 |
| 缓存 | 基本还热 | 部分变冷 | 大概率全冷 |
| 直接成本 | 约 20 ns | 约 1.2 μs | 约 3 μs |
| 谁来切 | 你的运行时(用户态) | 内核 | 内核 |
| 能被抢占吗 | 基本不能 | 能 | 能 |
协程为什么能便宜到这个地步?因为它根本不是一次上下文切换——它只是在用户态改了几个寄存器和一个栈指针,压根没进内核,页表和 TLB 动都没动。从 CPU 的角度看,那还是同一个线程在连续执行。
这就是为什么 Go 敢开十万个 goroutine、Kotlin 敢开十万个协程。而代价是「不能被抢占」——一个不主动让出的协程能卡住整条载体线程。这又绕回到第 4 章那个问题:软件抢不过软件,你需要一个硬件信号;而协程调度器没有那个信号可用。
Go 后来用「异步抢占」部分解决了这个问题:runtime 给线程发信号,在信号处理器里强行让协程让出。这已经是在借用内核的抢占能力了——某种程度上说,它把自己接回了第 4 章那根时钟中断上。
协程调度器怎么把 M 个协程铺到 N 个线程上、work-stealing 怎么做、为什么会有 GMP 这样的结构——那是《同时》那本书详细讲的内容。这里只需要记住这一章的结论:不进内核,所以便宜;不进内核,所以抢不了。
怎么少切几次
知道了账单,优化方向就清楚了。三个方向:
1. 减少可运行任务的数量
线程池大小是最典型的一个旋钮。很多人凭直觉把它设得很大(「多开点线程反正闲着也是闲着」),但:
- CPU 密集型任务:线程数超过核数,纯粹是在制造切换。理论最优就是核数。
- IO 密集型任务:线程大部分时间在
S状态睡着(第 5 章),不参与调度,所以可以多开。但每个线程的栈和调度器的管理成本还在。
这也解释了为什么异步 IO 和协程在高并发下赢得那么明显:它们把「一万个可运行任务」变成了「几个线程 + 一万个用户态对象」。调度器只需要在几个任务之间选,而不是一万个。
2. 让切换变便宜:绑核
sched_setaffinity() 或 taskset 可以把一个任务钉在固定的 CPU 核上。这样即使被切走,回来时还在同一个核上,L1/L2 缓存里可能还留着你的数据。
$ taskset -c 2,3 ./myserver # 只在 2、3 号核上跑
数据库、高频交易、DPDK 这类对延迟敏感的系统普遍这么干。副作用是失去了负载均衡——如果 2、3 号核忙而别的核闲着,内核也没法帮你挪。这是个明确的取舍,不是免费午餐。
3. 让 TLB 不用全冲:PCID
x86 后来加了 PCID(Process Context ID):给每个地址空间一个编号,TLB 条目上带着这个编号。换进程时不用清空 TLB,只是换个编号去匹配——原来那些条目还留着,切回来还能用。
ARM 上对应的机制叫 ASID,同样的思路。
这个特性在 KPTI(第 3 章提到的 Meltdown 缓解措施)之后变得特别关键——KPTI 让每次系统调用都要换页表,如果没有 PCID,那个性能损失会大到无法接受。
$ vmstat 1
procs -----------memory---------- ---system-- ------cpu-----
r b swpd free buff cache in cs us sy id wa st
2 0 0 1.2G 180M 4.5G 4231 18442 12 3 85 0 0
↑ ↑
中断次数 上下文切换次数(每秒)
怎么判断 cs 算不算高?拿它乘以 3 μs,看占了多少 CPU。
每秒 18000 次 × 3 μs = 54 ms/s ≈ 5.4% 的一个核。还行。
如果看到每秒几十万次,那就是 几百 ms/s,相当于好几个核在空转。常见原因:线程池开得太大、锁竞争严重(每次抢不到锁就睡过去一次、被唤醒一次,两次切换)、或者 sched_latency 被调得太小。
更精确的工具是 perf sched latency,它能告诉你每个任务被切走了多少次、每次等了多久。
- 用户态协程切换:约 20 ns
- 线程切换(同进程):约 1.2 μs——不换页表,TLB 保住
- 进程切换(换页表):约 3 μs 直接成本,加上可能几倍于此的间接成本
记住那个比例:协程 ≪ 线程 < 进程,跨度约两个数量级。这三行会在第 24 章的完整表里出现。
「为什么 Android 上频繁 post 到主线程会卡?」
每次 Handler.post(),如果主线程正在睡(epoll_wait 里等消息,第 19 章),就要:写管道唤醒它 → 主线程从 S 变 R → 等调度器挑中 → 上下文切换 → 处理消息 → 再睡回去。
这一整套至少两次上下文切换,加上唤醒延迟,实际开销远大于「post 一个 Runnable」听起来的样子。
所以在一个滚动列表里为每个 item 单独 post 一次是很糟的做法——合并成一次批量更新,切换次数从 N 降到 1。这和第 3 章「减少过线次数」是完全同一条原则:攒一批,一次办完。
顺带说,这也是 Compose 的重组批处理、RecyclerView 的 notifyItemRangeChanged、以及各种 debounce/conflate 操作符存在的根本理由——它们都在省这笔钱。
这一章的一句话
上下文切换的直接成本是保存寄存器和换页表,几微秒;真正的账单是换页表之后 TLB 全部作废、缓存整个变冷,这笔钱分散记在你程序恢复之后的几百条指令里,不出现在任何火焰图上。协程之所以便宜两个数量级,是因为它压根没进内核。
卷 II 到此结束——第一重假象(你独占 CPU)已经拆完了。下一卷是全书最硬的一卷:你以为你独占内存,而实际上你连一个真地址都没碰过。