线程越多越慢?真相揭秘 先记住一句话线程多了真正“干活”的时间被分摊了但“排队、争抢、搬数据、切换”的时间却随线程数一起涨。当后者涨得比前者省得快总时间就开始变长。一、先用一个比喻理解把程序想成一间厨房线程就是厨师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_guardstd::mutexlock(m);// 所有线程抢同一把锁resultcompute();// 连计算都放在锁里}}线程越多排队越长而且锁被抢时操作系统要让线程睡眠再唤醒每次都有上下文切换开销。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];// C17 可用 std::hardware_destructive_interference_size 代替 64更好的做法线程内用局部变量累加最后只写一次。voidworker(intid){longlocal0;// 在自己的寄存器 / 栈上for(...)localcompute();counters[id].valuelocal;// 只写一次}原因 3原子操作热点std::atomicintprocessed;// 每处理一个元素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 拷贝引用计数是原子操作热点对象会变成原子热点GCC# / Java多线程大量分配 → GC 更频繁 → GC 时可能暂停所有线程文件 / 数据库 / 连接池连接池只有 10 个连接开 50 个线程就有 40 个在等修复线程本地分配器jemalloc、tcmalloc、mimalloc预先分配、复用对象每个线程独立的随机数生成器关闭热路径上的日志。原因 7任务切得太碎一个任务只要 0.1 微秒 但从线程池领取任务、调度、同步要 1 微秒 → 90% 的时间在调度线程越多抢任务队列越激烈修复加大任务粒度每次领取一批比如每次处理 1000 个元素而不是 1 个Unity 的IJobParallelFor里的innerloopBatchCount就是干这个的。原因 8CPU 降频硬件层面单核负载睿频到 5.0 GHz 全核满载可能只有 3.5 GHz 笔记本 / 手机发热后可能更低开 16 个线程时每个核心本身就慢了 30%抵消了一部分并行收益。手机上尤其明显大核小核混合时还可能被调度到小核上。排查测试时监控 CPU 频率在固定频率下对比。原因 9NUMA多路服务器多颗 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-ppidWindows工具用途Intel VTuneThreading锁等待、Memory Access带宽、Microarchitecture 分析Visual Studio Concurrency Visualizer直观看每个线程何时运行、何时阻塞、被谁阻塞Windows Performance AnalyzerWPA上下文切换、等待链分析其他语言 / 引擎环境工具JavaJFRJava Flight Recorder看锁竞争、jstack看线程在等什么.NETdotnet-countersMonitor Lock Contention Count、GC 次数、dotnet-traceUnityProfiler 的 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 的核心数后突然变慢NUMAsys内核态时间占比升高锁、系统调用、切换开销八、第四步用“控制变量”验证工具给出怀疑对象后用小实验确认怀疑锁竞争 → 每个线程先算局部结果去掉锁看是否恢复扩展 怀疑伪共享 → 共享数组每个元素补齐到 64 字节看是否明显变快 怀疑内存分配 → 换成 jemalloc / mimalloc或预分配看变化 怀疑带宽 → 把数据规模缩小到能装进缓存看是否能正常扩展 怀疑调度开销 → 把任务内容换成空函数测纯调度成本 怀疑超线程 → 只用物理核心数或在 BIOS 关掉超线程对比 怀疑降频 → 固定 CPU 频率后重测一次只改一个变量改完重新画扩展曲线。曲线恢复正常就找到了元凶。九、一个完整案例现象统计 1 亿个数字里每个数字出现的次数用 8 个线程比 1 个线程还慢 3 倍。std::atomicinthistogram[256];voidworker(constuint8_t*data,size_t n){for(size_t i0;in;i)histogram[data[i]];// 所有线程改同一个直方图}定位过程1. 扩展曲线2 线程就开始变慢 → 怀疑争抢类问题 2. CPU 利用率接近 100% → 不是“等”是“空转” 3. 代码里没有 mutex → 排除普通锁 4. perf c2chistogram 所在的缓存行 HITM 极高 → 结论原子操作热点 伪共享256 个 int 挤在 16 个缓存行里修复voidworker(constuint8_t*data,size_t n,int*local/* 每线程独立的 256 个计数 */){for(size_t i0;in;i)local[data[i]];// 普通变量只有自己写}// 所有线程结束后把 8 份局部直方图加起来结果8 线程获得接近线性的加速。十、预防清单□ 计算密集任务线程数不超过物理核心数 □ 全局只有一个线程池没有嵌套并行 □ 热路径上没有锁必须加锁时锁的范围尽量小 □ 每个线程优先写自己的局部变量最后统一合并 □ 多线程频繁写的数据按 64 字节对齐 / 填充 □ 没有高频的共享原子计数器 □ 热路径上没有内存分配、日志、全局随机数 □ 任务粒度足够大调度开销占比很小 □ 数据布局紧凑、连续适合缓存 □ 用扩展曲线做性能回归测试而不只测一个线程数一句话总结多线程越多越慢本质是“随线程数增长的额外成本”超过了并行带来的收益。定位时先画扩展曲线再看 CPU 利用率利用率低说明线程在“等”锁、I/O、隐藏的全局锁利用率高说明线程在“空转或搬数据”伪共享、原子热点、自旋、内存带宽、上下文切换用 perf / VTune 等工具拿到证据后再通过控制变量的小实验确认。最通用的修复原则是让每个线程尽量只碰自己的数据最后再合并。