切换的三种:进程、线程与系统调用
先把术语校准——常说的「上下文切换」其实是三种不同成本的操作,混为一谈是性能分析的第一坑:
- 进程切换:换地址空间(换页表基址 CR3)+ 换内核栈与寄存器。最贵。
- 线程切换:同一地址空间内换内核栈与寄存器,页表不动。中等。
- 系统调用/模式切换:不换任务,只换特权级(第 15 篇)。常被误计为「切换」,实为另一本账。
vmstat 的 cs 列、pidstat -w 的 cswch/s(自愿)与 nvcswch/s(非自愿)把这三者的一部分区分暴露出来:自愿切换(voluntary)= 任务主动让出(等 IO、等锁、sleep);非自愿切换(non-voluntary)= 时间片用尽或高优先级抢占。两者的含义截然不同:自愿切换高说明「等」得多(IO 密集或锁竞争),非自愿切换高说明「算」的任务太多、CPU 在真抢——调优方向完全不同。
直接成本:教科书数字的来源
教科书口径的直接成本:保存当前任务的寄存器组、切换内核栈指针、调度器决策、恢复目标任务的寄存器——x86-64 裸机器上约 1–5 微秒(含调度器逻辑)。这个数字流传最广,也最具误导性:它只算了「切换动作」本身,没算切换的后果。
一个诚实的实验方法:lat_ctx(lmbench)或自己写两个进程互相 ping-pong(管道各写一字节),测出的就是纯切换成本。现代 CPU 上单次约 1–2 微秒——听起来不贵,但按「每秒 10 万次切换 = 20% CPU 纯开销」换算,账就大了。
间接成本:缓存与 TLB 污染才是大头
真正的账在切换之后:新任务开始在 CPU 上跑,它的热数据不在 L1/L2(刚被前一个任务用过),它的页表映射不在 TLB。接下来几毫秒里,它每次内存访问都在 miss——这段「冷启动税」通常数倍于切换本身。
第 18 篇讲过缓存层次:L1 命中 1ns,DRAM 80ns。切换后的一段时间内,任务的有效内存延迟从 1ns 退到几十 ns——对内存敏感的负载(数据库、JVM),一次切换的真实代价可以到 10–100 微秒的等效损失。这就是「切换率高的服务,P99 尾延迟毛刺多」的物理基础:毛刺不是切换那一微秒,是切换后缓存重建期的整段慢访问。
TLB 的账更狠(第 2、19 篇讲过页表与 TLB):进程切换要换 CR3,整个 TLB 理论上作废(PCID/ASID 机制给 TLB 条目打上进程标签,缓解了全量作废,但 TLB 容量有限,热映射仍然互相挤)。换一次地址空间 ≈ 重新预热一批页表翻译——每次翻译 miss 最多 4 次内存访问。
# 三板斧看切换
vmstat 1 # cs 列:系统级切换率
pidstat -w -p PID 1 # 自愿/非自愿分开看
perf stat -e context-switches,cpu-migrations -p PID sleep 10
# 切换后缓存税:L1 miss 率与切换率的相关性
perf stat -e L1-dcache-load-misses,LLC-load-misses -p PID sleep 10
跨 CPU 迁移与 TLB shootdown:核间的第三本账
CPU 亲和性失效(task migration):调度器为负载均衡把任务搬到另一个核——新核的 L1/L2 全冷(L3 共享还算保住一半),迁移本身还要锁运行队列。cpu-migrations 指标(上面命令里)就是它的计数。
TLB shootdown:某个 CPU 要改页表(munmap、mprotect、页迁移),其他 CPU 的 TLB 里可能还有旧映射的缓存条目——必须核间中断(IPI)通知所有相关 CPU 使其失效。多线程进程频繁做内存映射操作(JVM 的堆整理、某些 GC 的页操作)时,shootdown 风暴能把几十个核的 IPI 打满——perf stat -e tlb:tlb_flush 与 /proc/interrupts 的 TLB 行可见。这是「多线程 + 频繁 mmap/munmap」组合的专属陷阱,第 17 篇 mmap 的工程检查清单里「避免热路径频繁改映射」的依据就在这里。
定位切换来源:从指标到嫌疑人
切换率异常的标准排查路径:
- 分清自愿/非自愿(pidstat -w)。非自愿高 → CPU 饱和,看 run queue(vmstat 的 r 列)是否远超核数。
- 自愿高 → 找「等什么」。等锁:
perf lock或 pprof 的阻塞分析;等 IO:pidstat -d的 IO 等待;等网络:连接数与 epoll 空转。 - 找出切换大户:
pidstat -w 1 | sort -k4按切换数排序,锁定具体进程/线程。 - 评估是否真有害:切换率高 ≠ 病。压测工具(wrk)的连接线程本来就高频切换;判断标准是切换率与吞吐/延迟的相关性——加并发吞吐不升反降,切换税开始吃掉收益,才是过载信号。
一个经典反例的澄清:「线程数 = 核数」不是铁律。CPU 密集型负载成立(切换纯损耗);IO 密集型负载的正确公式是「核数 × (1 + 等待比)」——线程在等 IO 时切换是必要成本而非浪费。优化目标不是消灭切换,而是让每次切换值得:减少无效唤醒(第 25 篇惊群的教训)、合并小 IO(第 21 篇攒批)、用事件驱动替代线程阻塞(第 5 篇 epoll 的初衷)。
现代解法:三条路线各自的还债方式
Go 的 M:N 调度:goroutine 切换在用户态完成(保存/恢复少量寄存器,~百纳秒),只有 goroutine 阻塞在系统调用时才牵动 OS 线程(M)——把高频逻辑切换的账从内核账本挪到用户态账本。GOMAXPROCS 默认=核数,OS 级切换率天然低。
Java 虚拟线程(Loom):同样的思路移植到 JVM——虚拟线程的挂起/恢复由 JVM 在用户态完成(续体 continuation),阻塞 API 自动让出底层载体线程。百万级并发连接不再对应百万 OS 线程,切换成本从微秒级降到百纳秒级。
io_uring 与事件驱动:干脆不切换——单线程事件循环(Redis、Nginx worker)用 epoll/io_uring 批量收割事件,任务切换退化为函数调用与状态机推进。这是「消灭切换」的极致:代价是编程模型复杂化(回调/状态机),异步编程的心智成本就是这笔账的记账费。
三条路线的共同哲学:高频切换的账,能挪出内核就挪出内核,能不发生就不发生——与第 15 篇 vDSO 挪时钟调用、第 22 篇 O_DIRECT 挪缓存管理是同一句工程咒语。
实时调度与优先级反转:延迟敏感服务的最后手段
普通进程的 CFS 调度(第 1 篇)追求公平,延迟敏感场景(音频处理、高频交易、工业控制)需要的是确定性——这时候要动用 POSIX 实时调度策略。SCHED_FIFO:同优先级先来先服务,高优先级永远抢占低优先级,直到主动让出;SCHED_RR:在 FIFO 基础上加时间片轮转。两者都是实时优先级(1-99),整体凌驾于所有 CFS 任务之上——这既是能力也是危险:一个死循环的实时线程能让整台机器的其他任务全部饿死,所以 real-time throttling(默认 950/1000,留 5% 给普通任务)是保命的默认配置,别轻易关。配套的坑是优先级反转:高优先级任务等低优先级任务持有的锁,而低优先级又被中间优先级任务抢占——三者的组合让最高优先级反被最低优先级拖延(火星探路者号 1997 年的著名故障就是这个)。解法是优先级继承(futex 的 PI 变体:持锁者临时继承等待者的优先级),pthread_mutexattr_setprotocol(PTHREAD_PRIO_INHERIT) 一行配置;glibc 的普通 mutex 默认不带 PI,实时场景必须显式开。工程边界要划清:99% 的后端服务应该留在 CFS(公平性与吞吐更重要),只有「单次抖动就是事故」的路径才值得上实时调度,且要配套 CPU 隔离(isolcpus 或 cpuset,把实时任务与普通负载分核而治)、内存锁定(mlockall 防缺页中断)与中断绑定(第 26 篇,把网卡中断赶出实时核)。这套组合拳的总成本很高,但换来的 P99.999 确定性,对某些场景就是产品本身。
cgroup CPU 与 throttling:毛刺的另一个来源
CPU 维度的 cgroup 限制(cpu.max,cgroup v2)采用配额加周期的模型:如 quota 400000/period 100000 表示每 100ms 窗口最多用 400ms 的 CPU 时间(4 核的量)。它有个与直觉相悖的毛刺机制:窗口内配额耗尽后,整个 cgroup 被暂停到下一个窗口——多线程应用在窗口起点全员并发起跑,配额瞬间烧完,剩余 60-80ms 全员冻结,业务延迟呈锯齿状(P99 剧烈而均值好看)。这是容器「limit 设了 4 核但延迟比物理 4 核差得多」的标准解释。缓解手段按代价排序:其一,拉长周期(cpu.max 的 period 调大到 1s 级,窗口内配额耗尽的概率降低,牺牲的是限流精度);其二,加配额余量(P99 用量的 1.5 倍起步,配额烧不满就不触发暂停);其三,改用 cpuset(CPU 绑定核数替代配额,没有窗口语义、没有暂停毛刺,代价是缺乏弹性且核数必须整数);其四,Kubernetes 的 static CPU manager(Guaranteed Pod 独占核,等价于 cpuset 路线)。观测入口:cgroup 的 cpu.stat 里 nr_throttled(被暂停次数)与 throttled_usec(累计暂停时长)——「容器 CPU 利用率不高但延迟高」的排查第一站就是这两个计数器。与第 19 篇的 NUMA 对齐、第 26 篇的 IRQ 绑核合起来,「延迟敏感容器的三件套」完整了:拓扑对齐、中断隔离、CPU 限制方式三选一(配额余量拉满或干脆 cpuset)。
把切换账算进容量规划:从核数到并发模型
上下文切换的成本最终要落进容量规划的算式,本篇给出一个可操作的框架。第一步测单请求的切换足迹:压测单接口在「1 并发」下的 cswch/s(基准),再在「业务峰值并发」下测——两次之差除以 QPS 就是每请求的切换次数(典型值:阻塞 IO 的同步服务 2-6 次,事件驱动服务 0.2 次以下,这个比值本身就是架构效率的体检指标)。第二步算切换预算:核数 × 1e6 微秒 ÷ 单次切换的等效成本(直接 2 微秒 + 间接摊销 5-10 微秒,内存敏感负载取高值)≈ 每秒可承受的切换总量——超预算的并发就是「切换吃掉吞吐」的临界点。第三步选并发模型:预算紧张时按「每请求切换次数」排序的改造清单是——同步阻塞改线程池复用(砍线程创建的切换)、线程池改事件驱动(砍阻塞等待的切换)、进程内再改协程(Go/虚拟线程,把剩余切换挪到用户态)。一个校准直觉的换算:1000 QPS 的同步服务每秒约 5000 次切换 ≈ 半个核的等效损耗——不大,但 10 倍 QPS 时就是 5 个核,「加机器能解决」的方案里每一台都有五分之一的算力在为切换模型买单。容量评审时把「每请求切换次数」加进标准指标清单,它比 QPS 与 CPU 利用率更能预示横向扩展的真实斜率。
小结
上下文切换的真实账单分三层:直接成本 1–5 微秒(寄存器与内核栈),间接成本数倍于此(缓存与 TLB 冷启动,内存敏感负载可达几十微秒等效损失),核间成本另计(迁移全冷 + TLB shootdown 的 IPI 风暴)。自愿/非自愿切换的区分是排查的第一分叉:自愿高找「等什么」,非自愿高看 CPU 饱和。Go M:N、Java 虚拟线程、事件驱动三条现代路线偿还同一笔债的方式都是「把切换挪出内核或消灭它」。下一篇处理并发正确性的地基:内存屏障——为什么切换便宜了,无锁队列还会坏。
读者留言
COMMENTS 暂无还没有留言,来说第一句?