图 1:本文封面,切换就是换一份档案袋上岗。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:介绍下你理解的操作系统中线程切换过程。把「一瞬间」拆成三个动作,再把「为什么贵」拆成两块成本,这道题就答完了。
切换的三个动作:保存、调度、恢复。
线程切换由内核完成,一次完整切换做三件事。保存:把当前线程的运行现场——程序计数器(下一条该执行哪条指令)、通用寄存器、栈指针——写进它的线程控制块(TCB)。调度:调度器按策略选出下一个可运行线程。Linux 的 sched(7) 手册写明:2.6.23 起默认调度器是 CFS「完全公平调度器」,它按动态优先级在可运行队列里挑人,普通分时策略 SCHED_OTHER 下的线程分享 CPU 时间片。恢复:把新线程 TCB 里的快照灌回寄存器,CPU 从它上次中断的那条指令继续跑——对新线程来说,仿佛什么都没发生过。
开销的两块:直接成本与缓存寒冬。
直接成本是保存、调度、恢复本身,微秒级,单次不起眼,高频累积可观。间接成本更隐蔽也更贵:缓存与 TLB 里全是旧线程的热点数据,新线程上线后要经历一段「未命中寒冬」——指令缓存、数据缓存、页表缓存逐一重新预热,流水线也要重排。这段冷窗口的代价常常超过切换本身,而且是隐形的:它不占调度器的账,全摊在新线程的运行时间里。
用模型把两块成本算出来。
下面这份模拟固定总量 CPU 密集工作,拆给不同数量的线程在 4 核上按时间片轮转,量化「线程数涨、切换涨、吞吐跌」,用 Node.js 22.15.0 直接运行(node ctxswitch.js):
'use strict';
// 固定总量的 CPU 密集工作,拆给 N 个线程在 C 个核上按时间片轮转。
// 每次切换 = 直接开销(保存/恢复寄存器与调度)+ 间接开销(缓存冷窗口内指令变慢)。
const CORES = 4;
const TOTAL_STEPS = 80000; // 固定总工作量:线程越多,每人分到的越少
const SLICE_STEPS = 500; // 一个时间片能跑的指令步数
const DIRECT_US = 3; // 保存/恢复 PC、寄存器、栈指针 + 调度本身,微秒级
const COLD_STEPS = 100; // 切换后前 100 步处于缓存冷态
const COLD_PENALTY_US = 1; // 冷态每步多花 1us(缓存/TLB 未命中、流水线重排)
function simulate(threads) {
const totalSlices = Math.ceil(TOTAL_STEPS / SLICE_STEPS);
const lastOnCore = new Array(CORES).fill(-1); // 每个核上一次跑的是谁
let time = 0, switches = 0;
for (let slice = 0; slice * CORES < totalSlices; slice++) {
for (let core = 0; core < CORES && slice * CORES + core < totalSlices; core++) {
const t = (slice * CORES + core) % threads; // 轮转:核依次接过下一个线程
if (lastOnCore[core] !== t) {
switches++;
time += DIRECT_US; // 直接开销:TCB 保存/恢复 + 调度
time += COLD_STEPS * COLD_PENALTY_US; // 间接开销:缓存冷窗口
}
lastOnCore[core] = t;
time += SLICE_STEPS; // 热态执行
}
}
return { time, switches };
}
console.log('线程数\t总耗时us\t切换次数\t相对4线程');
const base = simulate(CORES);
for (const n of [4, 8, 32, 128]) {
const r = simulate(n);
console.log(`${n}\t${r.time}\t${r.switches}\t${(r.time / base.time).toFixed(2)}x`);
}
实测输出:
线程数 总耗时us 切换次数 相对4线程
4 80412 4 1.00x
8 96480 160 1.20x
32 96480 160 1.20x
128 96480 160 1.20x
总量不变,4 线程时 4 个核各抱一个线程跑到底,全程只有启动的 4 次切换;线程数一超过核数,每个时间片核都要换人,160 次切换的直接开销加 160 段缓存冷窗口,把总耗时顶高 20%。这就是「压测时加线程反而变慢」的微观解释:多出来的线程没有多干活,只吃掉了切换与缓存的预算。
图 2:保存、调度、恢复,外加一段缓存寒冬。
治理手段与可能的追问。
减少不必要切换的三板斧:线程数对齐任务性质——CPU 密集对齐核数(略超无收益),IO 密集按等待占比放大,因为等待时不占 CPU;减少锁竞争与无谓唤醒——锁竞争激烈时大量切换是抢锁抢出来的,治锁比治调度优先;把切换挪到用户态——协程与异步模型由用户态调度,绕过内核切换的直接成本。「怎么观测切换开销?」看自愿与非自愿上下文切换计数(如 /proc 的 ctxt 与 pidstat 的 cswch/nvcswch),非自愿切换飙高通常意味着线程超配或锁争抢。「线程池是不是越大越好?」不是——池子超过核数后,新增线程对 CPU 密集任务只贡献排队与切换。
推理服务的线程配置就是这道题的应用题:上下文切换开销与缓存污染会直接吃掉吞吐,理解切换成本,才能解释为什么压测曲线在某个线程数之后掉头向下。你调线程池参数时,是按经验拍的,还是观测过切换计数与缓存命中?欢迎聊聊。