多线程上下文切换是什么意思?代价有多大?
结论先行:上下文切换(context switch)是 CPU 从一个执行流换到另一个执行流时,保存当前现场、恢复下一个现场的过程。它本身不干业务活,纯粹是开销。线程不是越多越好,根本原因就在这里。
1. 四类切换,代价差三个数量级
| 切换类型 | 由谁触发 | 保存/恢复的内容 | 量级 |
|---|---|---|---|
| 进程切换 | 内核调度器 | 页表、寄存器、内核栈,可能刷新 TLB | 几微秒 ~ 毫秒 |
| 线程切换(同进程) | 内核调度器 | 寄存器、程序计数器、栈指针 | 约 1~10 微秒 |
| 模式切换 | 系统调用、中断 | 只切 CPU 特权级,不换执行流 | 几百纳秒 |
| 协程切换 | 用户态运行时 | 少量寄存器 + 栈指针 | 几十纳秒 |
面试里说"上下文切换很贵",通常指的是线程切换;说"系统调用贵",指的是模式切换。两者不是一回事。
2. 切换时 CPU 到底在干什么
[ 线程 A 运行 ] ──保存现场──▶ [ 恢复线程 B 现场 ] ──▶ [ 线程 B 运行 ]
└──── 切换窗口:CPU 不执行任何业务指令 ────┘
一次线程切换要做的事:
- 把 A 的通用寄存器、PC、SP 压到 A 的内核栈;
- 切到 B 的内核栈,恢复 B 的寄存器与 PC;
- 如果是跨进程,还要换页表、刷 TLB;
- 副作用:A 的缓存局部性被破坏,B 的指令和数据要重新从内存/LLC 加载。
第 4 点最容易被忽略,但往往是真实成本的大头:切换后前几千条指令的 cache miss 都算在切换头上。
3. 怎么观测
# 每 1 秒采样:cs 列是上下文切换总次数,r 列是运行队列长度
vmstat 1
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa
# 8 0 0 812345 12000 980000 0 0 4 12 900 12000 30 8 60 2
# 指定进程:cswch/s 主动切换(等 IO/锁),nvcswch/s 被动切换(时间片用完被抢占)
pidstat -w -p 12345 1
判读要点:
| 现象 | 含义 |
|---|---|
cs 极高(几万以上)且 CPU sy 占比高 | 线程过多或锁竞争激烈,内核在忙着调度 |
r 长期大于核数 | 有大量线程在等 CPU,说明线程数超过实际并行能力 |
cswch/s 高、nvcswch/s 低 | 线程大多在等 IO 或锁,属于"自愿让出" |
nvcswch/s 高 | 线程在抢 CPU 时间片,通常是 CPU 打满 |
us 低但 cs 高 | 忙于切换、没干正事,典型"线程加多了" |
4. 减少无效切换的手段
// 1. 用有界队列控制排队量,避免线程数随流量无限膨胀
new ThreadPoolExecutor(8, 8, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500)); // 固定 8 线程,超出的排队或拒绝
// 2. 短任务批量合并,减少"提交一次跑一次"的调度次数
List<Order> batch = new ArrayList<>(200);
for (Order o : orders) {
batch.add(o);
if (batch.size() == 200) { flush(batch); batch.clear(); }
}
// 3. 临界区尽量短,锁粒度尽量小:锁竞争会引发大量主动切换
synchronized (this) { counter++; } // 只保护这一行
实践中的四条经验:
- 线程数控制在核数的合理倍数内(CPU 密集≈核数,IO 密集按等待占比放大);
- 缩小锁的临界区,或用无锁结构(
AtomicLong、LongAdder)替代; - 批量处理,把 N 次调度合并成 1 次;
- IO 密集用协程/异步,把"阻塞等"变成"挂起让出",虚拟线程正是为此而生。
5. 常见追问
追问一:线程切换一定比进程切换便宜吗? 同进程内的线程切换不需要换页表、TLB 通常仍有效,所以便宜。跨进程切换要换地址空间,代价高一个量级。
追问二:为什么线程多了反而更慢? 两个原因叠加:一是切换开销线性增长;二是每个线程都要占用栈内存和内核结构,缓存里能装下的热点数据被摊薄。压测时经常能看到"线程数 200 时 QPS 反而低于 50",就是总开销吃掉了并行收益。
追问三:协程为什么便宜? 切换发生在用户态,不需要陷入内核、不换内核栈,运行时自己保存几个寄存器就够了。但协程不能消除阻塞本身,它只是把"线程被占住"变成"栈被挂起"。
追问四:上下文切换次数越多,说明性能越差吗?
不一定。IO 密集服务的切换次数天然很高(每次读 socket 都可能让出),只要 CPU 利用率高、r 不大、延迟正常,就属于健康状态。要看的是"切换多且没产出",不是切换多本身。
小结
上下文切换 = 保存现场 + 恢复现场 + 缓存失效。工程上只需记住三件事:线程数要有上限、锁的临界区要短、阻塞等待尽量换成协程挂起。