什么是上下文切换?为什么线程开多了反而变慢?

进阶高频原理性能优化约 6 分钟读完

一句话回答

上下文切换是 CPU 从一个线程(或进程)切到另一个时,保存当前线程的寄存器、栈指针等状态,再恢复下一个线程的状态。进程间切换还要换页表,TLB 里的地址映射大多失效。除了保存恢复的直接开销,更大的是间接开销:新线程的数据不在 CPU 缓存里,要重新从内存加载。线程数远多于 CPU 核数时,CPU 时间被切换和锁竞争消耗掉,吞吐反而下降。线程数没有万能公式:CPU 密集型接近核数,I/O 密集型可以多一些,最终以压测为准。

详细解析

切换时保存什么

内容 线程切换(同进程) 进程切换
通用寄存器、程序计数器、栈指针 要 要
浮点和向量寄存器 要(按需保存) 要
内核栈 换成新线程的 换成新线程的
页表(x86 的 CR3 寄存器) 不用,地址空间相同 要换,TLB 大多失效

Linux 里线程和进程都是调度实体,切换流程相同,区别只是同一进程的线程不用换页表,见 进程、线程和协程。

什么时候发生切换

  • 自愿切换:线程主动让出 CPU,比如等 I/O、等锁、sleep、等条件变量
  • 非自愿切换:时间片用完,或者更高优先级的线程就绪,被调度器抢占

自愿切换多,说明线程经常在等资源(I/O、锁);非自愿切换多,说明可运行的线程太多,在抢 CPU。

注意区分:系统调用只是同一个线程在用户态和内核态之间转换,不一定发生线程切换,见 用户态和内核态。

开销在哪里

  • 直接开销:进入内核、运行调度器选择下一个线程、保存和恢复寄存器、切换栈和页表
  • 间接开销:L1/L2 缓存里是上一个线程的数据,新线程开始运行时大量缓存未命中;进程切换后 TLB 失效,访存要重新查页表。这部分通常比直接开销更大,也更难测量

线程开多了变慢,往往不只是切换本身:

  1. 可运行线程远多于核数,每个线程分到的时间片变短,切换次数和缓存失效增加
  2. 线程越多,锁竞争越激烈,大量线程在等锁、唤醒、再等锁
  3. 每个线程都有栈和内核结构,占内存;线程数受 ulimit -u、kernel.threads-max、kernel.pid_max 等限制

CPU 调度算法简述

算法 思路 问题
先来先服务 按到达顺序执行 长任务拖住短任务
短作业优先 先执行耗时短的 要预知耗时,长任务可能饥饿
时间片轮转 每个任务轮流执行一个时间片 时间片太短切换多,太长响应慢
优先级调度 高优先级先执行 低优先级可能饥饿,要配合老化
多级反馈队列 多个优先级队列,用完时间片就降级 兼顾交互和批处理,规则复杂

Linux 的普通进程调度器长期是 CFS(完全公平调度),按虚拟运行时间挑最"欠" CPU 的任务;6.6 版本起换成了 EEVDF,在公平的基础上兼顾延迟。nice 值影响的是任务分到 CPU 时间的权重。

线程数怎么定

  • CPU 密集型(计算、压缩、加解密):线程数接近 CPU 核数,再多也只是在抢 CPU
  • I/O 密集型(调用数据库、下游接口):线程大部分时间在等,可以比核数多,等待时间越长可以越多
  • 网上常见的"核数 × (1 + 等待时间 / 计算时间)"只能当起点,实际还受锁、连接池大小、下游承受能力影响
  • 正确做法:从估算值开始压测,逐步调整,观察吞吐、延迟、CPU 使用率和切换次数,找到吞吐不再上升的拐点。Java 线程池的配置见 线程池

另一个思路是不靠加线程提高并发:用事件循环(Node.js、Nginx、Redis)或协程(Go),少量线程就能处理大量连接。

代码示例

Shell
# cs 列是系统每秒的上下文切换次数,in 是每秒中断次数,r 是可运行的任务数
vmstat 1

# 每个进程每秒的切换次数:cswch/s 是自愿切换,nvcswch/s 是非自愿切换(sysstat 包)
pidstat -w 1
# -t 细分到线程
pidstat -wt -p 1234 1

# 进程启动以来的累计切换次数
grep ctxt_switches /proc/1234/status

cs 的绝对值没有统一的"正常范围",要和这台机器平时的基线对比。r 长期大于 CPU 核数、非自愿切换高,说明 CPU 不够用或线程太多;自愿切换高,要看是不是在等 I/O 或锁。

面试官可能追问

协程切换为什么比线程切换便宜?

协程切换在用户态完成,不进内核、不经过内核调度器,只保存少量寄存器;而且同一线程上的协程共享地址空间,没有页表切换。Go 的 goroutine 切换由运行时决定时机,大多发生在 I/O、channel、锁这类等待点上;长时间占着 CPU 的 goroutine 会被运行时抢占,但这仍然是用户态的调度,不经过内核调度器。

怎么减少上下文切换?

控制线程数,用线程池复用线程;减少锁竞争,缩小临界区、用无锁结构或分段锁;I/O 密集的服务改用事件驱动或协程;对延迟敏感的服务可以用 taskset 或 cgroup 的 cpuset 把进程绑在固定的核上,减少迁移带来的缓存失效。

容器里的 CPU 限制会影响线程数的选择吗?

会。容器通过 cgroup 限制 CPU 配额,但进程里看到的核数可能还是宿主机的核数。按宿主机核数开线程,线程在配额内互相争抢,还会被限流(throttle),延迟抖动明显。新版 JVM 能识别容器的 CPU 限制;Go 1.25 起 GOMAXPROCS 默认也会参考 cgroup 的 CPU 限制,更早的版本常用 automaxprocs 这类库来设置。

易错点

  • 线程切换和进程切换的主要区别是要不要切页表,不是"线程切换不进内核"
  • 不要背线程数公式,面试时说清思路,并强调要通过压测确定
  • vmstat 的 cs 高不一定有问题,要结合 CPU 使用率、可运行队列长度和业务延迟一起看

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。