9、并行算法:std::for_each并行版本、std::reduce与std::transform_reduce、执行策略
好,咱们今天聊点实在的。C++17 带来的并行 STL,说白了就是让标准库算法自己学会“多线程干活”。以前我们写 std::for_each,它老老实实一个个处理元素。现在加个执行策略参数,它就能自动把任务拆成多份,扔给不同线程去跑。
我个人习惯把并行 STL 看作“零成本入门并发”的捷径。你不需要手写 std::thread,不需要操心线程池,甚至不用管负载均衡——标准库帮你搞定了。但别高兴太早,坑也不少。我踩过,今天一并告诉你。
9.1 执行策略:seq / par / par_unseq
执行策略是并行算法的“开关”。你传不同的策略,算法就表现出不同的行为。C++17 定义了三种:
| 策略 | 含义 | 典型场景 |
|---|---|---|
std::execution::seq |
顺序执行,和普通版本一样 | 调试、数据依赖强时 |
std::execution::par |
多线程并行 | CPU 密集型、无数据竞争 |
std::execution::par_unseq |
并行 + 向量化 | 允许乱序、SIMD 友好 |
嗯,这里要注意:par_unseq 是最激进的。它不光开多线程,还允许编译器生成向量化指令(比如 AVX)。代价是——你的操作必须能容忍“交叉执行”。什么意思?就是两个不同元素的操作可能在同一线程里交错执行,甚至部分执行。我见过有人在这里翻车,后面细说。
核心原则:使用 par 或 par_unseq 时,你传入的可调用对象必须不能有数据竞争,也不能依赖执行顺序。
9.2 std::for_each 的并行版本
先看个最简单的例子。假设你有一个 std::vector<int>,想把每个元素翻倍:
#include <algorithm>
#include <execution>
#include <vector>
std::vector<int> data(1000000, 1);
// 顺序版本
std::for_each(std::execution::seq, data.begin(), data.end(),
[](int& x) { x *= 2; });
// 并行版本
std::for_each(std::execution::par, data.begin(), data.end(),
[](int& x) { x *= 2; });
// 并行 + 向量化
std::for_each(std::execution::par_unseq, data.begin(), data.end(),
[](int& x) { x *= 2; });
代码改动就一行——加个策略参数。但性能差距可能很大。我在项目中测试过,处理 1000 万个元素的数组,par 比 seq 快 3-4 倍(8 核机器)。par_unseq 还能再快 10-20%,前提是你的操作足够简单,能触发自动向量化。
我的经验:别一上来就用 par_unseq。先跑 par,确认正确性,再考虑向量化。我曾经在图像处理管线里用 par_unseq,结果因为像素操作有隐式依赖,跑出来的图全是条纹。排查了两天才发现是向量化乱序导致的。
9.3 std::reduce:并行版累加
std::reduce 是 std::accumulate 的并行替代。区别在于:accumulate 要求顺序执行(从左到右),而 reduce 不保证顺序,所以可以并行。
#include <numeric>
#include <execution>
std::vector<int> nums = {1, 2, 3, 4, 5};
// 顺序累加:结果一定是 15
int sum1 = std::accumulate(nums.begin(), nums.end(), 0);
// 并行 reduce:结果也是 15,但内部可能乱序
int sum2 = std::reduce(std::execution::par,
nums.begin(), nums.end(), 0);
这里有个关键点:reduce 要求二元操作是结合律且交换律的。加法满足,但减法不满足。你想想看,(1-2)-3 和 (1-3)-2 结果不一样。所以别拿 reduce 做减法或除法。
我曾经踩过的坑:用 std::reduce 做浮点数累加。浮点加法不满足结合律(因为精度问题),并行后结果和顺序版本有微小差异。如果你的业务对精度敏感(比如金融计算),千万别用 par 策略做 reduce。
9.4 std::transform_reduce:映射 + 规约一步到位
这个算法更强大。它先对每个元素做变换(transform),再把变换结果规约(reduce)成一个值。典型的“map-reduce”模式。
#include <numeric>
#include <execution>
std::vector<double> values = {1.0, 2.0, 3.0, 4.0};
// 计算平方和:1^2 + 2^2 + 3^2 + 4^2 = 30
double sum_of_squares = std::transform_reduce(
std::execution::par,
values.begin(), values.end(),
0.0, // 初始值
std::plus<>(), // 规约操作
[](double x) { return x * x; } // 变换操作
);
这个模式太常见了。向量点积、矩阵乘法、统计计算……都能用 transform_reduce 优雅表达。我习惯把它当作“并行计算的瑞士军刀”。
参数顺序别搞混:先传变换函数,再传规约函数。嗯,我第一次用就写反了,编译报错一脸懵。
9.5 知识体系总览
下面这张图帮你理清并行算法的核心脉络:
9.6 实战避坑指南
说了这么多,来点干货。我总结了几条实战中容易踩的坑:
- 迭代器失效:并行算法执行期间,不要修改容器大小。我见过有人用
par策略的for_each里 push_back,程序直接崩。 - 共享状态:lambda 里别捕获引用去修改共享变量。用
std::atomic或干脆避免。你想想看,多个线程同时写一个int,结果不可预测。 - 性能测试:数据量小的时候,并行版本可能比顺序版本还慢。因为线程创建和任务拆分的开销超过了收益。我一般建议数据量至少上万再考虑并行。
- 编译器支持:不是所有编译器都完整实现了并行 STL。GCC 需要 TBB 库,MSVC 从 VS 2019 开始支持。记得检查你的工具链。
我的习惯:写并行算法时,先用 seq 策略跑一遍,确保逻辑正确。然后换成 par,用 AddressSanitizer 或 ThreadSanitizer 检查数据竞争。最后再考虑 par_unseq 优化。三步走,稳得很。
9.7 小结
并行 STL 是 C++17 给并发编程新手最好的礼物。你不需要成为线程专家,就能写出利用多核的程序。但记住:并行不是银弹。它要求你的操作是线程安全的,数据是无依赖的。用对了,性能翻倍;用错了,调试到怀疑人生。
我个人建议,先从 std::for_each 和 std::reduce 入手,这两个最直观。等熟练了再挑战 transform_reduce 和 par_unseq。嗯,今天就到这里,代码写起来吧。