深蓝黑背景封面:右侧大标题「线程切换的一瞬间,内核做了什么」,副标题「保存与恢复哪些状态,开销从哪来」;左侧原创示意图——CPU 核心上方两个线程的上下文快照正在交换:线程 A 的寄存器组与程序计数器被压进它的 TCB 档案袋,线程 B 的档案袋被倒出灌回寄存器,旁边一枚缓存芯片上 A 留下的热点数据正在褪色;左上标注「面试经典 · 操作系统」,右上标注「Day 10 · 11-07」 图 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%。这就是「压测时加线程反而变慢」的微观解释:多出来的线程没有多干活,只吃掉了切换与缓存的预算。

机制分解图:上方三栏讲一次切换的完整动作——保存:程序计数器、通用寄存器、栈指针写入当前线程的 TCB;调度:内核按优先级与时间片选出下一个可运行线程;恢复:把新线程 TCB 里的快照灌回寄存器,从它上次中断的指令继续。中部讲开销的两部分:直接开销是保存恢复与调度本身,微秒级但高频累积可观;间接开销更隐蔽——缓存与 TLB 里全是旧线程的热点,新线程上线先经历一段未命中寒冬,流水线也要重排。下方三栏讲治理手段:线程数对齐核数与任务性质,CPU 密集略超核数、IO 密集按等待占比放大;减少锁竞争与无谓唤醒,锁竞争激烈时大量切换是抢锁抢出来的;考虑协程或异步模型把切换挪到用户态。底部提示:压测时加线程反而变慢,多半就是切换与缓存污染在吃收益 图 2:保存、调度、恢复,外加一段缓存寒冬。

治理手段与可能的追问。

减少不必要切换的三板斧:线程数对齐任务性质——CPU 密集对齐核数(略超无收益),IO 密集按等待占比放大,因为等待时不占 CPU;减少锁竞争与无谓唤醒——锁竞争激烈时大量切换是抢锁抢出来的,治锁比治调度优先;把切换挪到用户态——协程与异步模型由用户态调度,绕过内核切换的直接成本。「怎么观测切换开销?」看自愿与非自愿上下文切换计数(如 /proc 的 ctxt 与 pidstat 的 cswch/nvcswch),非自愿切换飙高通常意味着线程超配或锁争抢。「线程池是不是越大越好?」不是——池子超过核数后,新增线程对 CPU 密集任务只贡献排队与切换。

推理服务的线程配置就是这道题的应用题:上下文切换开销与缓存污染会直接吃掉吞吐,理解切换成本,才能解释为什么压测曲线在某个线程数之后掉头向下。你调线程池参数时,是按经验拍的,还是观测过切换计数与缓存命中?欢迎聊聊。