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)。代价是——你的操作必须能容忍“交叉执行”。什么意思?就是两个不同元素的操作可能在同一线程里交错执行,甚至部分执行。我见过有人在这里翻车,后面细说。

核心原则:使用 parpar_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 万个元素的数组,parseq 快 3-4 倍(8 核机器)。par_unseq 还能再快 10-20%,前提是你的操作足够简单,能触发自动向量化。

我的经验:别一上来就用 par_unseq。先跑 par,确认正确性,再考虑向量化。我曾经在图像处理管线里用 par_unseq,结果因为像素操作有隐式依赖,跑出来的图全是条纹。排查了两天才发现是向量化乱序导致的。

9.3 std::reduce:并行版累加

std::reducestd::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 知识体系总览

下面这张图帮你理清并行算法的核心脉络:

C++17 并行算法知识体系 执行策略 seq / par / par_unseq 核心算法 std::for_each (并行版) std::reduce std::transform_reduce 注意事项 操作必须无数据竞争 需满足结合律/交换律 浮点精度可能不一致 一句话总结 并行算法 = 执行策略 + 标准算法 + 线程安全操作

9.6 实战避坑指南

说了这么多,来点干货。我总结了几条实战中容易踩的坑:

  • 迭代器失效:并行算法执行期间,不要修改容器大小。我见过有人用 par 策略的 for_each 里 push_back,程序直接崩。
  • 共享状态:lambda 里别捕获引用去修改共享变量。用 std::atomic 或干脆避免。你想想看,多个线程同时写一个 int,结果不可预测。
  • 性能测试:数据量小的时候,并行版本可能比顺序版本还慢。因为线程创建和任务拆分的开销超过了收益。我一般建议数据量至少上万再考虑并行。
  • 编译器支持:不是所有编译器都完整实现了并行 STL。GCC 需要 TBB 库,MSVC 从 VS 2019 开始支持。记得检查你的工具链。

我的习惯:写并行算法时,先用 seq 策略跑一遍,确保逻辑正确。然后换成 par,用 AddressSanitizerThreadSanitizer 检查数据竞争。最后再考虑 par_unseq 优化。三步走,稳得很。

9.7 小结

并行 STL 是 C++17 给并发编程新手最好的礼物。你不需要成为线程专家,就能写出利用多核的程序。但记住:并行不是银弹。它要求你的操作是线程安全的,数据是无依赖的。用对了,性能翻倍;用错了,调试到怀疑人生。

我个人建议,先从 std::for_eachstd::reduce 入手,这两个最直观。等熟练了再挑战 transform_reducepar_unseq。嗯,今天就到这里,代码写起来吧。


公众号:蓝海资料掘金营,微信deep3321