ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

线程越多越慢?真相揭秘

线程越多越慢?真相揭秘

先记住一句话:

线程多了,真正“干活”的时间被分摊了,但“排队、争抢、搬数据、切换”的时间却随线程数一起涨。当后者涨得比前者省得快,总时间就开始变长。


一、先用一个比喻理解

把程序想成一间厨房,线程就是厨师:

1 个厨师:自己切菜、炒菜,一切顺畅 4 个厨师:分工合作,快了很多 16 个厨师: → 只有 1 把刀,大家排队等(锁竞争) → 两个人共用一个案板,你放一下我挪一下(伪共享) → 灶台只有 8 个,多出来的人轮流上(线程数 > 核心数) → 冰箱门只有一个,大家都去拿菜(内存带宽) → 厨房太热,每个人动作都变慢(CPU 降频)

人多了,干活的总量没变,但互相妨碍的成本越来越大。


二、先搞清楚:是“不再变快”还是“变慢”

这两个是不同的问题。

1. 不再变快:正常现象(阿姆达尔定律)

任何程序都有一部分只能一个人做(串行部分):

总时间 = 串行部分 + 并行部分 ÷ 线程数

比如 10% 是串行的:

线程数总时间(相对)加速比
11001.0×
410 + 22.5 = 32.53.1×
1610 + 5.6 = 15.66.4×
6410 + 1.4 = 11.48.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 VTuneThreading(锁等待)、Memory Access(带宽)、Microarchitecture 分析
Visual Studio Concurrency Visualizer直观看每个线程何时运行、何时阻塞、被谁阻塞
Windows Performance Analyzer(WPA)上下文切换、等待链分析

其他语言 / 引擎

环境工具
JavaJFR(Java Flight Recorder)看锁竞争、jstack看线程在等什么
.NETdotnet-counters(Monitor Lock Contention Count、GC 次数)、dotnet-trace
UnityProfiler 的 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 等工具拿到证据后,再通过控制变量的小实验确认。最通用的修复原则是:让每个线程尽量只碰自己的数据,最后再合并。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进