设计并发代码
读完能按场景选对任务划分策略(数据划分 / 递归划分 / 流水线),能识别并避免 false sharing(伪共享)这类「明明没共享却互相拖慢」的性能陷阱,并能用 Amdahl 定律估算「加多少核能加速多少」的上限——明白串行部分才是真正卡住天花板的那只手。
学习目标
- 能比较数据划分、递归划分与流水线,按依赖、粒度和背压为具体任务选择结构,并保留顺序回退路径
- 能分析争用、伪共享、局部性和 CPU 任务的超额订阅,设计可复现实验而不是只按核数猜线程数
- 能回答:一个任务里 90% 的工作可以并行、剩下 10% 必须串行,那么用 16 个核最多能加速几倍?(用 Amdahl 定律手算,并说出「为什么再加核也突破不了某个上限」)
机制总览
任务划分、缓存局部性与可伸缩性
- 1
划分任务
按数据块、递归子问题或流水线阶段切分,并标出依赖。
- 2
布局数据
让每个线程主要访问自己的缓存行并保持数据接近。
- 3
评估扩展
用串行比例和实测开销解释速度上限,避免超额订阅。
章级决策实验
任务划分、缓存局部性与可伸缩性
选择设计层级,判断瓶颈来自串行比例、同步、缓存还是过度并行。
选择推理阶段
当前阶段 · 划分任务
按数据块、递归子问题或流水线阶段切分,并标出依赖。
可核验证据
任务 DAG、关键路径与负载分布。
并发设计先优化关键路径和数据布局,再决定线程数;核数不能突破串行部分和共享瓶颈。
失效—证据矩阵
任务划分、缓存局部性与可伸缩性
划分任务
典型失效
强依赖任务被硬拆,线程大部分时间互相等待。
核验证据
任务 DAG、关键路径与负载分布。
布局数据
典型失效
不同变量落在同一缓存行,产生 false sharing。
核验证据
cache miss、行失效与对齐实验。
评估扩展
典型失效
只报告单次加速比,不测核数曲线和尾延迟。
核验证据
scaling curve、Amdahl 估算与上下文切换。
从“多一个线程是否值得”开始
前八章你学会了让多个厨师同时干活:怎么招厨师、怎么分账本、怎么排队取菜、怎么不上锁也不打架。可真到了忙时,老板会发现一件怪事——明明多请了人,出菜却没快多少,甚至更慢了。这一章就回答:人多了,活该怎么分,才真的更快?
先想清楚「怎么分活」。一种分法是按工序:洗菜的、切菜的、掌勺的各管一段,菜在他们手上流水般传过去。另一种是按菜量:同一道菜要做二十份,就分给四个厨师每人做五份。分法选错,人再多也白搭。
更隐蔽的坑还有两个。一是两个厨师各写各的便签、本来互不相干,可两张便签恰好写在同一张得来回传的纸上,于是这张纸被反复抢来抢去,两人都被拖慢——明明没共享内容,却互相绊脚。二是不管活多活少,总有几道菜必须一个人从头做到尾,这几道的耗时再多厨师也省不掉,它才是出菜快慢的真正天花板。
怎么把活分给多个线程:任务划分三策略
设计并发代码的第一步,是 ↡把一份工作拆开、分给多个线程去做的方式。怎么拆决定了能不能并行、并行得好不好——拆错了,线程之间反复等待、反复传数据,人多也快不起来。常见三策略:数据划分、递归划分、流水线。:把一份工作拆成多块,交给不同线程。怎么拆?常见有三条路,下面这张对比图把它们并排摆出来——先看图,再逐个说:
第一条是 ↡把同一份工作的数据切成 N 份,每个线程独立处理其中一份,做的是同一件事、只是数据不同。适合数据量大、各份处理彼此不依赖的场景(如逐元素变换、并行求和)。也叫数据划分。:一大块数据切成 N 份,每个线程领一份、做同样的处理。比如把一个百万元素的数组分成四段,四个线程各算各段的和。它适合数据量大、各份之间互不依赖的活。
第二条是递归划分(分治):把问题递归拆成两半,子问题再并行。并行快排就是典型——选个基准把数组分成两边,左右两边交给不同线程各自再快排下去。它适合天然能分治的递归算法。
第三条是 ↡把工作按工序拆成若干阶段,数据依次流过各阶段,不同阶段由不同线程负责,做的是不同的事。像工厂流水线:第一个工位干完交给第二个工位。适合数据连续到达、工序固定的场景。它属于任务并行。:按工序分阶段,数据一件件流过。视频处理常这样——解码、滤镜、编码三段各派一个线程,第一帧解完码就交给滤镜线程,自己接着解第二帧。它和数据并行的根本区别是:数据并行是「每个线程做同样的事、数据不同」,↡把不同的工作(不同的任务/工序)分给不同线程,各线程做的事不一样。流水线是典型——每个阶段是一种不同的工序。与数据并行(各线程做同样的事、只是数据不同)相对。是「每个线程做不同的事」。
三者没有固定最优。递归划分必须设 grain-size 阈值并交给线程池,否则每层创建线程会让调度成本指数膨胀;流水线吞吐受最慢阶段限制,阶段间队列必须有界并提供背压,否则“并发更高”只会变成内存持续增长。数据划分也要让每块工作足够大,才能摊薄提交、同步和合并成本。
为什么人多了反而更慢:影响并发性能的因素
分法对了,性能还可能被几个隐蔽因素拖垮。最阴险的一个叫 ↡两个线程各自访问不同的变量,本不该互相影响;但这两个变量恰好落在同一条缓存行(CPU 在核间搬数据的最小单位,通常 64 字节)上,于是任一线程写自己的变量,都会让整条缓存行在两个核之间来回失效、传输,互相拖慢——明明没有真正共享数据,却付出了共享的代价。所以叫「伪」共享。靠 alignas / 填充把变量对齐到不同缓存行可根治。。它的根子在于:CPU 在核之间搬数据不是一个字节一个字节地搬,而是以一整条 ↡CPU 缓存与内存之间、以及核与核之间搬运数据的最小单位,通常 64 字节。读写任何一个变量,都会把它所在的整条缓存行一起搬动。两个变量若落在同一条缓存行,写其中一个就会牵动另一个。(通常 64 字节)为单位。两个线程各写各的变量,本该井水不犯河水,可一旦这两个变量挤在同一条缓存行上,任一线程写一下,都会让整条行在两核间被抢来抢去、反复失效——这种来回弹叫 ↡同一条缓存行被多个核反复争抢、来回传输失效的现象。每次一个核写了该行,其他核手里的副本就失效、得重新取,于是缓存行像乒乓球一样在核间弹来弹去,开销巨大。false sharing 是它最常见的诱因。。下面这张动画把这场无谓的拉锯演给你看,先猜一猜:
猜一猜:两个线程各改各的变量、毫不共享,为什么还会互相拖慢?
盯住第三、四拍:core0 写 a、core1 写 b,本是两件不相干的事,可整条缓存行却被反复抢来抢去。修复办法在第六拍——用 alignas 把两个变量各塞进一条独立缓存行,乒乓立刻消失(代码见 §6)。
除了伪共享,还有两个常被忽略的因素。↡一个线程频繁使用的数据在地址空间中彼此接近,能更有效利用缓存行和预取;跨线程热写数据则需要适当分离。局部性与防伪共享之间要共同权衡。 提醒我们把同一线程的工作集放近,但不能把不同线程频繁写的槽位挤进同一缓存行。↡持续可运行的线程数超过硬件执行上下文,导致调度、上下文切换和缓存工作集竞争增加。阻塞型任务可能需要更多线程,因此它不是简单的线程数大于核数。 主要针对 CPU-bound runnable 任务;I/O 阻塞工作常需要更高并发。hardware_concurrency() 只是硬件提示,线程预算还要结合任务粒度、阻塞比例、其他进程、SMT 与 NUMA 实测。
为并发设计数据结构与异常安全
并发数据结构性能取决于访问模式,不只取决于锁的数量。读写热点应分片,跨分片操作要有稳定锁序;每线程数据要兼顾局部性与缓存行隔离;不可变快照和最后合并常比持续共享更新更易扩展。拆锁前先用争用分析证明瓶颈,否则更多 mutex 只会增加内存与证明成本。
并行算法还要把这些结构封装成调用者可理解的契约:最小任务粒度、执行资源、结果顺序、取消语义和异常传播。递归算法应提交到有界执行器而不是递归创建线程;流水线需要慢阶段背压;数据并行要保证分块不重叠并定义合并次序。
并发还放大了异常安全。一个任务失败时,所有已启动任务仍必须停止或跑完,线程必须 join,promise 必须以值、异常或 broken_promise 结束,然后调用者才能收到异常。不能在第一个 future.get() 抛出后直接离开并遗忘其余工作;应先记录首个异常、发出协作取消、等待全部执行单元收尾,再重新抛出。C++17 没有 stop_token,取消通常需要共享原子标志和每个任务显式检查点。
可伸缩性与 Amdahl 定律:加多少核到头?
设计并发代码绕不开一个问题:这段代码 ↡可伸缩性(scalability):往程序里加更多处理器(核)时,性能能跟着提升多少。理想是加一倍核就快一倍(线性可伸缩);现实里因为有必须串行的部分,加速比会越来越逼近一个上限、加核的收益递减。Amdahl 定律就是量化这个上限的公式。好不好——多给几个核,它能快多少?凭直觉很多人以为「核翻倍、速度翻倍」,但只要程序里有哪怕一小段必须一个人顺序做的活,这个直觉就会大错特错。把它量化的就是 ↡Amdahl 定律(Amdahl's Law):估算并行加速上限的公式。设可并行部分占比为 p、处理器数为 N,则加速比 S = 1 / ((1−p) + p/N)。核心结论是:当 N 趋于无穷大,S 的上限是 1/(1−p)——由串行部分 (1−p) 决定。串行部分越大,再多核也快不过这个天花板。。
符号表(本节首次出现的符号,各一行):
- :加速比(speedup)——并行后比单核快了几倍
- :可并行部分占总工作的比例()
- :必须串行、只能一个人顺序做的部分占比
- :处理器(核)数
设单核跑完整个任务耗时记为 1(一个单位)。其中可并行的那部分占 、串行的那部分占 。把任务交给 个核:串行部分谁也帮不上,仍耗 ;可并行部分被 个核分摊,从 缩到 。所以总耗时是:
这个式子在说:用 个核跑,总时间 = 那段甩不掉的串行时间 ,加上被 个核分摊后的并行时间 。
加速比就是「单核时间 ÷ 多核时间」,单核时间为 1,于是:
这个式子在说:加速比 = 1 除以多核总耗时。分母里 这一项会随核数 增大而变小、贡献越来越少,而 这一项岿然不动——它就是卡住分母不让它继续变小的那只手。
这是固定问题规模、负载均匀且忽略调度、同步、通信和缓存成本的理想上限模型。若把这些并行开销合并记为非负项 ,实际时间更接近:
因此实测加速通常低于 Amdahl 理想值,且 必须来自顺序基线的测量。扩大问题规模时讨论的是另一种缩放问题,不能继续把原来的固定 当成不变量。
让核数无限多(),,加速比逼近:
这个式子在说:哪怕给你无穷多个核,加速比也突破不了 这个天花板——而它只由串行部分 决定。串行部分占 10%(),上限就是 10 倍;串行部分占 50%(),上限就只有 2 倍,加再多核都白搭。这就是「一桌菜里总有几道得一个人顺序做,再多厨师也快不过那几道的总时长」的数学版本。
下面这张交互曲线让你亲手拖动 ,看串行部分怎么把曲线死死压住——先猜一猜,再拖:
猜一猜:把可并行比例 调小(串行部分变大),曲线的「天花板」会怎么变?
Amdahl 加速比曲线加载中…
拖到 试试:曲线很快就贴着 2 倍那条虚线趴下,64 个核也只比单核快不到 2 倍。再拖到 ,天花板才抬到 20 倍。这条曲线就是本章学习目标里那道自测题的答案来源(16 核、 算出来约 6.4 倍,远不是 16 倍)。
上手玩一玩这三张图
本章三张图各管一件事,配合 §6 代码一起玩比读十遍管用:
- 伪共享乒乓 ``(主 Demo):单步看「a、b 同行 → core0 写 a 抢行 → core1 写 b 抢回(core0 失效)→ core0 又写 a 抢回(core1 失效)→ 来回弹、性能暴跌 → alignas 拆成两行 → 乒乓消失」。盯住「两核各写各的,整行却被反复抢」这一点,理解伪共享为什么「明明没共享却互相拖累」。
- Amdahl 加速比曲线
<AmdahlCurveExplorer />:拖「可并行比例 」滑块,看曲线天花板随串行部分升降。重点体会 时曲线被压在 2 倍附近、加核几乎无效——串行部分才是真正的瓶颈。 - 任务划分三策略
<TaskDivisionDiagram />:并排对照数据划分 / 递归划分 / 流水线各自的示意与适用场景,做 §8 选型题前先把三者的「长相」记牢。
代码对照:伪共享翻车版 vs 修复版
先看容易发生伪共享的布局:两个计数器相邻,常会落在同一缓存行,但最终布局取决于对象地址、实现对齐与目标缓存结构,必须用性能计数器或地址验证。
#include <atomic>
#include <cstddef>
// 翻车版:两个计数器紧挨着 → 大概率同在一条 64 字节缓存行
struct Counters {
std::atomic<long> a{0}; // core0 反复 ++a
std::atomic<long> b{0}; // core1 反复 ++b
// a 和 b 相邻,常会共享同一缓存行;需在目标机器验证
};
void worker_a(Counters& c) {
for (long i = 0; i < 100000000; ++i) {
c.a.fetch_add(1, std::memory_order_relaxed); // 写 a → 整行被抢、core1 失效
}
}
void worker_b(Counters& c) {
for (long i = 0; i < 100000000; ++i) {
c.b.fetch_add(1, std::memory_order_relaxed); // 写 b → 整行被抢回、core0 失效
}
}若 a、b 实际共享同一缓存行,两核持续写会触发所有权来回迁移。若运行时布局恰好跨行,则不会出现这个特定问题;示例应与顺序版、单线程原子版和分行版一起基准测试。
修复只需一个改动:用 alignas 把每个计数器对齐到独立的缓存行。下面是修复版,差异全在 alignas 那两行:
#include <atomic>
#include <new> // std::hardware_destructive_interference_size
#ifdef __cpp_lib_hardware_interference_size
constexpr std::size_t kCacheLine = std::hardware_destructive_interference_size;
#else
constexpr std::size_t kCacheLine = 64; // 常见缓存行大小,退化默认
#endif
// 修复版:每个计数器各占一条独立缓存行 → 两核各持一行,不再乒乓
struct alignas(kCacheLine) PaddedCounter {
std::atomic<long> v{0};
};
struct Counters {
alignas(kCacheLine) PaddedCounter a; // ← 差异行:对齐到独立缓存行
alignas(kCacheLine) PaddedCounter b; // ← 差异行:对齐到独立缓存行
};代码对照:数据划分的 per-thread 累加
数据并行里「让每个线程在自己的数据上独立干活、最后再合并」的典型写法,是给每个线程一个私有的局部累加器,全程不碰共享状态,循环跑完才汇总一次:
#include <algorithm>
#include <numeric>
#include <thread>
#include <vector>
// 数据划分:把 [first,last) 切成 nthreads 段,每段一个线程算私有局部和,最后合并
long parallel_sum(const std::vector<long>& data) {
const std::size_t n = data.size();
if (n == 0) return 0;
constexpr std::size_t min_per_thread = 25000;
const unsigned hint = std::thread::hardware_concurrency();
const unsigned budget = hint ? hint : 2; // 只是本次示例的上限提示
const std::size_t max_by_work = 1 + (n - 1) / min_per_thread;
const unsigned nthreads = static_cast<unsigned>(
std::min<std::size_t>(budget, max_by_work));
std::vector<long> partials(nthreads, 0); // 每线程一个私有累加器槽
std::vector<std::thread> pool;
const std::size_t chunk = n / nthreads;
for (unsigned t = 0; t < nthreads; ++t) {
std::size_t b = t * chunk;
std::size_t e = (t == nthreads - 1) ? n : b + chunk;
// 每个线程只写自己那个 partials[t]——但小心:相邻槽可能 false sharing!
pool.emplace_back([&, b, e, t] {
partials[t] = std::accumulate(data.begin() + b, data.begin() + e, 0L);
});
}
for (auto& th : pool) th.join();
return std::accumulate(partials.begin(), partials.end(), 0L); // 合并
}这里同时受硬件提示和最小粒度约束;真实线程池还要扣除调用者、其他任务与进程负载。每个线程先在 std::accumulate 内部局部状态计算,结束时只写一次 partials[t],因此共享槽不是热循环写入。
容易踩的坑
小结
- 任务划分三策略:数据并行(同一份处理切成 N 份各算各的)、递归划分(分治再并行,如并行快排)、流水线(按工序分阶段、数据流过)——没有最优,只有最配场景的那一种
- false sharing(伪共享):跨核热写变量若同处缓存行会触发乒乓;分行可能增大工作集,必须在目标布局与基准中验证
- 超额订阅:CPU-bound runnable 线程过多通常增加调度与缓存竞争;
hardware_concurrency()只是结合粒度、阻塞和共存负载的预算提示 - Amdahl 定律给出固定规模且忽略并行开销的理想上限;实际时间还包含调度、同步、通信和缓存项
- 并发设计总则:控制粒度与背压、减少共享,并在异常路径上取消同伴、关闭队列、等待全部线程后再传播错误
练习
问题 1(场景选型题) 下面三个任务,各该用哪种任务划分策略(数据并行 / 递归划分 / 流水线)?说明理由。 (a) 给一张 4000×4000 的图片整体调亮:每个像素独立地乘上一个系数。 (b) 一个实时摄像头流:每帧要依次经过「去噪 → 目标检测 → 画框叠加」三道工序,帧源源不断到来。 (c) 对一个一千万元素的乱序数组做排序,且希望充分利用多核。
问题 2(Amdahl 计算题) 某任务 90% 的工作可并行、10% 必须串行(即 )。 (a) 用 16 个核,加速比约是多少? (b) 核数无限多时,加速比的上限是多少? (c) 若想把加速上限提到 20 倍,串行部分最多只能占多少?
问题 3(设计评审题) 一个三阶段视频流水线使用无界队列;检测阶段最慢。任一 worker 抛异常时主线程立刻重抛,不再等待其他 worker。请指出吞吐、内存和生命周期问题,并给出可关闭的设计协议。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 任务划分
把一份工作拆开、分给多个线程去做的方式。怎么拆,决定了能不能并行、并行得好不好——拆错了,线程之间反复等待、反复传数据,人再多也快不起来。常见三条路:数据划分(同一份数据切成 N 份各算各的)、递归划分(分治,拆成子问题再并行)、流水线(按工序分阶段,数据流过)。像后厨分活:是按菜量分给几个厨师,还是按工序(洗、切、炒)分给不同人。详见本章「任务划分三策略」一节。
- 数据并行(数据划分)
把同一份工作的数据切成 N 份,每个线程独立处理其中一份——做的是同样的事,只是数据不同。适合数据量大、各份处理彼此不依赖的场景,比如把大数组分段求和、把图片分块逐像素处理。像把「同一道菜做二十份」分给四个厨师,每人做五份。与任务并行(各线程做不同的事)相对。详见本章「任务划分三策略」一节。
- 流水线(任务并行)
把不同的工作(不同的工序/任务)分给不同线程,各线程做的事不一样。流水线是典型:按工序拆成若干阶段,数据依次流过,每个阶段一个线程专做一种工序(像工厂流水线:第一个工位干完交给第二个工位)。适合数据连续到达、工序固定的场景,比如视频的解码→滤镜→编码。与数据并行(各线程做同样的事、只是数据不同)相对。详见本章「任务划分三策略」一节。
- 任务并行
把不同任务或工序分给不同线程。流水线是常见形式,但任务并行也包括彼此独立的异构工作;它与各线程执行同一算法处理不同数据的数据并行相对。
- false sharing(伪共享)
两个线程各自访问不同的变量,本不该互相影响;可这两个变量恰好落在同一条缓存行上,于是任一线程写自己那个变量,都会让整条缓存行在两个核之间来回失效、传输,两个线程互相拖慢——明明没有真正共享数据,却付出了共享的代价,所以叫「伪」共享。像两个厨师各写各的便签,但两张便签写在同一张必须来回传的纸上,纸被反复抢来抢去。用
alignas/ 填充把变量对齐到不同缓存行就能根治。详见本章「影响并发性能的因素」一节。- 缓存行(cache line)
CPU 在缓存与内存之间、以及核与核之间搬运数据的最小单位,通常 64 字节。读写任何一个变量,都会把它所在的整条缓存行一起搬动。所以两个变量若挤在同一条缓存行里,写其中一个就会牵动另一个——这正是 false sharing 的物理根源。详见本章「影响并发性能的因素」一节。
- 缓存乒乓(cache ping-pong)
同一条缓存行被多个核反复争抢、来回传输失效的现象。每次一个核写了这条行,其他核手里的副本就作废、得重新去取,于是缓存行像乒乓球一样在核间弹来弹去,开销巨大。false sharing 是它最常见的诱因——两个线程各写各的变量,却因共行触发乒乓。详见本章「影响并发性能的因素」一节。
- 数据接近度
一个线程要用到的数据,是否在内存里挨得近、能否被缓存一次取出。挨得近则缓存命中率高、快;散落各处则频繁缓存未命中、慢。并发性能调优常做两件事:把一个线程自己要用的数据安排得彼此靠近(提高命中率),同时又和别的线程的数据分开(避免 false sharing)。详见本章「影响并发性能的因素」一节。
- 超额订阅(oversubscription)
持续可运行的线程数超过硬件执行上下文,增加调度、上下文切换和缓存工作集竞争。阻塞型任务可能合理使用更多线程,所以应结合粒度、阻塞比例和共存负载基准测试;
hardware_concurrency()只是提示。- 可伸缩性
往程序里加更多处理器(核)时,性能能跟着提升多少。理想是加一倍核就快一倍(线性可伸缩);现实里因为总有必须串行的部分,加速比会越来越逼近一个上限、加核的收益递减。一段代码「可伸缩性好不好」,问的就是它多给核能不能多换来速度。Amdahl 定律就是量化这个上限的公式。详见本章「可伸缩性与 Amdahl 定律」一节。
- Amdahl 定律
估算并行加速上限的公式。设可并行部分占比为 、处理器数为 ,则加速比 。核心结论是:当核数趋于无穷大,加速比的上限是 ——只由串行部分 决定。串行部分越大,再多核也快不过这个天花板(串行占 10% 上限就 10 倍、占 50% 就只有 2 倍)。像一桌菜里总有几道必须一个人顺序做,这几道的总时长,再多厨师也省不掉。详见本章「可伸缩性与 Amdahl 定律」一节。