先记住一句话:
线程多了,真正“干活”的时间被分摊了,但“排队、争抢、搬数据、切换”的时间却随线程数一起涨。当后者涨得比前者省得快,总时间就开始变长。
一、先用一个比喻理解
把程序想成一间厨房,线程就是厨师:
1 个厨师:自己切菜、炒菜,一切顺畅 4 个厨师:分工合作,快了很多 16 个厨师: → 只有 1 把刀,大家排队等(锁竞争) → 两个人共用一个案板,你放一下我挪一下(伪共享) → 灶台只有 8 个,多出来的人轮流上(线程数 > 核心数) → 冰箱门只有一个,大家都去拿菜(内存带宽) → 厨房太热,每个人动作都变慢(CPU 降频)人多了,干活的总量没变,但互相妨碍的成本越来越大。
二、先搞清楚:是“不再变快”还是“变慢”
这两个是不同的问题。
1. 不再变快:正常现象(阿姆达尔定律)
任何程序都有一部分只能一个人做(串行部分):
总时间 = 串行部分 + 并行部分 ÷ 线程数比如 10% 是串行的:
| 线程数 | 总时间(相对) | 加速比 |
|---|---|---|
| 1 | 100 | 1.0× |
| 4 | 10 + 22.5 = 32.5 | 3.1× |
| 16 | 10 + 5.6 = 15.6 | 6.4× |
| 64 | 10 + 1.4 = 11.4 | 8.8× |
| ∞ | 10 | 最多 10× |
曲线会越来越平,但不会往下掉。
2. 越来越慢:说明有“随线程数增长的额外成本”
总时间 = 串行部分 + 并行部分 ÷ 线程数 + 额外成本(线程数) ↑ 这一项随线程增加而变大本文要找的,就是这个“额外成本”是什么。
三、第一步:先测出“扩展曲线”
不要凭感觉,先跑一组数据:
线程数:1、2、4、8、16、32 每组记录: ① 总耗时 ② CPU 总利用率 ③ 机器的物理核心数 / 逻辑核心数典型的异常曲线:
耗时 │● │ ● │ ● ● │ ● │ ● ← 拐点之后开始变慢 └──────────────────→ 线程数 1 2 4 8 16 32拐点出现的位置本身就是线索:
| 拐点位置 | 首先怀疑 |
|---|---|
| 正好在物理核心数附近 | 超线程收益低、线程数超过核心 |
| 2~4 线程就开始变慢 | 锁竞争、伪共享、原子操作热点 |
| 随数据量变化而移动 | 缓存容量、内存带宽 |
| 跑久了才变慢 | 降频、GC、内存碎片 |
四、第二步:看 CPU 利用率,把问题分成两大类
这是整个定位中最关键的一刀:
线程增加后变慢 │ ┌─────────────┴─────────────┐ CPU 利用率低 CPU 利用率高 (核心在闲着) (核心很忙,但没干正事) │ │ 线程在“等” 线程在“空转 / 搬数据” │ │ · 锁等待(互斥锁) · 自旋锁空转 · I/O、网络、数据库 · 伪共享 · 线程间同步、屏障 · 原子操作争抢 · 隐式全局锁(malloc、日志) · 内存带宽打满 · 缓存被挤爆 · 上下文切换过多- 利用率低 → 去找谁在等什么
- 利用率高 → 去找CPU 忙在了哪里
五、常见原因逐个分析
原因 1:锁竞争(最常见)
std::mutex m;voidworker(){for(...){std::lock_guard<std::mutex>lock(m);// 所有线程抢同一把锁result+=compute();// 连计算都放在锁里}}线程越多,排队越长;而且锁被抢时,操作系统要让线程睡眠再唤醒,每次都有上下文切换开销。
1 线程:没人抢,加锁几乎免费 16 线程:15 个在排队,只有 1 个在干活 → 实际上又变回了单线程,还多了排队成本特征:CPU 利用率低,Profiler 里大量时间在lock / wait / futex。
⚠️ 如果用的是自旋锁,等待的线程不睡眠而是原地空转,表现会变成CPU 利用率很高但没进展,很容易被误判为“计算太重”。
修复:
· 缩小锁的范围:只锁真正共享的那一小段 · 每个线程先算自己的局部结果,最后合并一次 · 拆锁:一把大锁 → 按数据分成多把小锁(分段锁) · 读多写少用读写锁;或用无锁数据结构原因 2:伪共享(False Sharing)——最隐蔽
这和之前讲的内存对齐直接相关。
CPU 缓存是按缓存行(通常 64 字节)搬数据的。如果两个线程写的变量不同,但落在同一行:
longcounters[8];// 8 个线程,每个线程写自己的 counters[i]内存里: ┌────────────────────── 一个缓存行64字节 ──────────────────────┐ │ c[0]│ c[1]│ c[2]│ c[3]│ c[4]│ c[5]│ c[6]│ c[7]│ └────────────────────────────────────────────────────────────────┘ 线程0线程1线程2……逻辑上大家没有共享任何数据,但硬件上:
线程 0 写 c[0] → 这一整行在其他核心的缓存里全部作废 线程 1 写 c[1] → 必须重新从线程 0 那里把整行拿过来,再让别人作废 …… → 这一行在各个核心之间来回“弹跳”(cache line ping-pong)线程越多,弹跳越频繁,可能比单线程还慢好几倍。
特征:CPU 利用率很高;代码里看不到任何锁;缓存未命中(尤其是 HITM)大幅增加。
修复:让每个线程的数据独占一个缓存行:
structalignas(64)PaddedCounter{longvalue;// 补齐到 64 字节,独占一行};PaddedCounter counters[8];// C++17 可用 std::hardware_destructive_interference_size 代替 64更好的做法:线程内用局部变量累加,最后只写一次。
voidworker(intid){longlocal=0;// 在自己的寄存器 / 栈上for(...)local+=compute();counters[id].value=local;// 只写一次}原因 3:原子操作热点
std::atomic<int>processed;// 每处理一个元素processed++;// 所有线程都在改同一个变量原子操作不需要锁,但本质上和伪共享一样:同一个缓存行被所有核心轮流独占。处理的元素越小、频率越高,越严重。
修复:本地计数,每处理一批(比如 1000 个)再加一次;或者干脆最后汇总。
原因 4:线程数超过 CPU 核心数
8 个核心,开 32 个计算线程 → 每个核心上 4 个线程轮流跑 → 操作系统不停地切换:保存现场、恢复现场 → 切换时缓存里的数据被别的线程冲掉,切回来又要重新加载还有几个容易忽略的情况:
| 情况 | 说明 |
|---|---|
| 超线程 | 16 个“逻辑核心”可能只有 8 个物理核心。计算密集任务开到 16 往往收益很小,甚至变慢 |
| 嵌套并行 | 线程池里的任务又开了一个线程池(比如每个任务里调用了 OpenMP 或并行库),线程数变成 N × N |
| 多个程序抢 | 同一台机器还跑着其他服务,可用核心比你以为的少 |
| 容器限制 | Docker 限制了 2 个 CPU,但程序读到的核心数是宿主机的 64 |
特征:上下文切换次数(context switches)随线程数暴涨。
修复:计算密集型任务,线程数 ≈ 物理核心数;用一个全局线程池,禁止嵌套创建线程。
原因 5:内存带宽 / 缓存容量瓶颈
如果任务本身计算很少、读写数据很多(比如遍历大数组做简单加法):
瓶颈不在 CPU,而在“从内存搬数据”的速度 内存带宽是全部核心共享的,像一条固定宽度的路 → 4 个线程已经把路占满 → 再加线程,只是让大家一起堵车另一种情况是缓存被挤爆:
L3 缓存所有核心共享,比如 32MB 1 个线程的数据 10MB → 能放进缓存,很快 8 个线程各 10MB → 80MB 放不下,大家互相挤出对方的数据 → 频繁去内存取,每个线程都变慢特征:CPU 利用率高,但IPC(每周期指令数)明显下降,末级缓存(LLC)未命中大增。
修复:
· 优化数据布局:连续存放、减少无用字段(结构体瘦身) · 分块处理(tiling):让每个线程的工作数据能放进缓存 · 减少数据拷贝;用更小的数据类型(float 代替 double 等) · 接受现实:线程数开到带宽饱和点就够了原因 6:藏起来的全局锁
代码里没写锁,但调用的库内部有:
| 隐藏的锁 | 说明 |
|---|---|
内存分配malloc / new | 很多默认分配器有全局锁。多线程频繁小块分配会严重争抢 |
| 日志 / printf / cout | 输出通常加锁,调试日志忘了关掉是常见原因 |
| rand() | 全局随机状态,有的实现加锁 |
| shared_ptr 拷贝 | 引用计数是原子操作,热点对象会变成原子热点 |
| GC(C# / Java) | 多线程大量分配 → GC 更频繁 → GC 时可能暂停所有线程 |
| 文件 / 数据库 / 连接池 | 连接池只有 10 个连接,开 50 个线程就有 40 个在等 |
修复:线程本地分配器(jemalloc、tcmalloc、mimalloc),预先分配、复用对象,每个线程独立的随机数生成器,关闭热路径上的日志。
原因 7:任务切得太碎
一个任务只要 0.1 微秒 但从线程池领取任务、调度、同步要 1 微秒 → 90% 的时间在调度,线程越多,抢任务队列越激烈修复:加大任务粒度,每次领取一批,比如每次处理 1000 个元素而不是 1 个(Unity 的IJobParallelFor里的innerloopBatchCount就是干这个的)。
原因 8:CPU 降频(硬件层面)
单核负载:睿频到 5.0 GHz 全核满载:可能只有 3.5 GHz 笔记本 / 手机发热后:可能更低开 16 个线程时,每个核心本身就慢了 30%,抵消了一部分并行收益。手机上尤其明显,大核小核混合时还可能被调度到小核上。
排查:测试时监控 CPU 频率;在固定频率下对比。
原因 9:NUMA(多路服务器)
多颗 CPU 的服务器上,每颗 CPU 有自己“附近”的内存:
线程在 CPU 0 上,数据在 CPU 1 的内存里 → 跨插槽访问,延迟和带宽都差很多 线程数超过一颗 CPU 的核心数时,问题突然出现排查 / 修复:numactl --hardware查看拓扑;绑核、按节点分配内存(numactl --cpunodebind --membind),首次访问(first-touch)由使用它的线程完成。
六、第三步:用工具拿证据
Linux
# 1. 总体指标:对比 1 线程和 16 线程perfstat-etask-clock,context-switches,cpu-migrations,\cycles,instructions,cache-misses,LLC-load-misses ./app# 2. CPU 时间花在哪(热点函数,可做火焰图)perf record-g./app perf report# 3. 专门查伪共享perf c2c record ./app perf c2c report# 看 HITM 高的缓存行和对应代码# 4. 线程在等什么(off-CPU 分析,需要 bcc 工具)offcputime-p<pid>Windows
| 工具 | 用途 |
|---|---|
| Intel VTune | Threading(锁等待)、Memory Access(带宽)、Microarchitecture 分析 |
| Visual Studio Concurrency Visualizer | 直观看每个线程何时运行、何时阻塞、被谁阻塞 |
| Windows Performance Analyzer(WPA) | 上下文切换、等待链分析 |
其他语言 / 引擎
| 环境 | 工具 |
|---|---|
| Java | JFR(Java Flight Recorder)看锁竞争、jstack看线程在等什么 |
| .NET | dotnet-counters(Monitor Lock Contention Count、GC 次数)、dotnet-trace |
| Unity | Profiler 的 Timeline 视图,看 Worker 线程是否有大段空闲、主线程是否在WaitForJobGroup |
七、指标 → 嫌疑对照表
对比少线程和多线程两次运行,看哪个指标异常增长:
| 现象 | 最可能的原因 |
|---|---|
| CPU 利用率低,大量时间在 wait / futex / lock | 锁竞争、I/O、连接池 |
CPU 利用率高,代码里无锁,perf c2c有高 HITM | 伪共享 |
| CPU 利用率高,热点在某个 atomic 或 spin | 原子热点、自旋锁 |
| context-switches 暴涨 | 线程数超过核心、锁导致频繁睡眠唤醒 |
| IPC 明显下降,LLC miss 大涨 | 内存带宽 / 缓存容量 |
| 热点在 malloc / free / GC | 内存分配全局锁、GC 压力 |
| 热点在线程池的队列操作 | 任务粒度太小 |
| 每核频率随线程数下降 | 降频 |
| 超过一颗 CPU 的核心数后突然变慢 | NUMA |
| sys(内核态)时间占比升高 | 锁、系统调用、切换开销 |
八、第四步:用“控制变量”验证
工具给出怀疑对象后,用小实验确认:
怀疑锁竞争 → 每个线程先算局部结果,去掉锁,看是否恢复扩展 怀疑伪共享 → 共享数组每个元素补齐到 64 字节,看是否明显变快 怀疑内存分配 → 换成 jemalloc / mimalloc,或预分配,看变化 怀疑带宽 → 把数据规模缩小到能装进缓存,看是否能正常扩展 怀疑调度开销 → 把任务内容换成空函数,测纯调度成本 怀疑超线程 → 只用物理核心数,或在 BIOS 关掉超线程对比 怀疑降频 → 固定 CPU 频率后重测一次只改一个变量,改完重新画扩展曲线。曲线恢复正常,就找到了元凶。
九、一个完整案例
现象:统计 1 亿个数字里每个数字出现的次数,用 8 个线程比 1 个线程还慢 3 倍。
std::atomic<int>histogram[256];voidworker(constuint8_t*data,size_t n){for(size_t i=0;i<n;i++)histogram[data[i]]++;// 所有线程改同一个直方图}定位过程:
1. 扩展曲线:2 线程就开始变慢 → 怀疑争抢类问题 2. CPU 利用率:接近 100% → 不是“等”,是“空转” 3. 代码里没有 mutex → 排除普通锁 4. perf c2c:histogram 所在的缓存行 HITM 极高 → 结论:原子操作热点 + 伪共享(256 个 int 挤在 16 个缓存行里)修复:
voidworker(constuint8_t*data,size_t n,int*local/* 每线程独立的 256 个计数 */){for(size_t i=0;i<n;i++)local[data[i]]++;// 普通变量,只有自己写}// 所有线程结束后,把 8 份局部直方图加起来结果:8 线程获得接近线性的加速。
十、预防清单
□ 计算密集任务,线程数不超过物理核心数 □ 全局只有一个线程池,没有嵌套并行 □ 热路径上没有锁;必须加锁时锁的范围尽量小 □ 每个线程优先写自己的局部变量,最后统一合并 □ 多线程频繁写的数据,按 64 字节对齐 / 填充 □ 没有高频的共享原子计数器 □ 热路径上没有内存分配、日志、全局随机数 □ 任务粒度足够大,调度开销占比很小 □ 数据布局紧凑、连续,适合缓存 □ 用扩展曲线做性能回归测试,而不只测一个线程数一句话总结
多线程越多越慢,本质是“随线程数增长的额外成本”超过了并行带来的收益。定位时先画扩展曲线,再看 CPU 利用率:利用率低说明线程在“等”(锁、I/O、隐藏的全局锁),利用率高说明线程在“空转或搬数据”(伪共享、原子热点、自旋、内存带宽、上下文切换);用 perf / VTune 等工具拿到证据后,再通过控制变量的小实验确认。最通用的修复原则是:让每个线程尽量只碰自己的数据,最后再合并。