15. 并行算法:std::for_each与std::transform的并行版本、执行策略
好,咱们今天聊一个实战中特别常用的东西——并行算法。说白了,就是让标准库里的那些算法,比如 std::for_each 和 std::transform,能自动利用多核CPU来加速。
我记得刚接触C++17那会儿,看到这个特性时还挺兴奋的。以前想并行处理一个数组,要么自己写线程池,要么用OpenMP,要么用TBB。现在好了,标准库直接给你封装好了,加个执行策略参数就行。
15.1 执行策略:seq / par / par_unseq
执行策略是并行算法的核心。它告诉标准库:你打算怎么执行这个算法?
| 策略 | 含义 | 适用场景 |
|---|---|---|
std::execution::seq |
顺序执行,跟普通版本一样 | 调试、数据量小、有副作用 |
std::execution::par |
多线程并行执行 | CPU密集型、无数据竞争 |
std::execution::par_unseq |
多线程 + 向量化(SIMD) | 纯计算、无锁、无同步 |
嗯,这里要注意:par_unseq 是最激进的策略。它允许编译器生成SIMD指令,也允许线程间交错执行。这意味着你的操作函数里不能有任何同步操作(比如mutex、atomic),否则就是未定义行为。
par_unseq,结果在某个老款CPU上跑出了随机崩溃。查了半天才发现,操作函数里有个 std::mutex::lock。换成 par 后问题消失。所以,除非你100%确定操作是纯函数,否则老老实实用 par。
15.2 std::for_each 的并行版本
std::for_each 是最直观的并行算法。它对每个元素执行一个操作,不关心返回值。
#include <algorithm>
#include <execution>
#include <vector>
#include <iostream>
int main() {
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; });
std::cout << "done\n";
return 0;
}
我个人习惯是:开发阶段先用 seq 跑通逻辑,然后换成 par 做性能测试。最后再考虑要不要上 par_unseq。
std::rand() 或者 errno,千万别用 par_unseq。这些全局状态在多线程下会乱套。我一般会提前把随机数生成器改成线程局部的。
15.3 std::transform 的并行版本
std::transform 跟 for_each 的区别在于:它把结果写到另一个(或同一个)容器里,而且可以处理两个输入范围。
#include <algorithm>
#include <execution>
#include <vector>
int main() {
std::vector<int> input(1000000, 42);
std::vector<int> output(input.size());
// 一元变换:每个元素平方
std::transform(std::execution::par, input.begin(), input.end(),
output.begin(),
[](int x) { return x * x; });
// 二元变换:两个向量相加
std::vector<int> input2(1000000, 10);
std::transform(std::execution::par, input.begin(), input.end(),
input2.begin(), output.begin(),
[](int a, int b) { return a + b; });
return 0;
}
你想想看,如果数据量是100万,用 par 策略在8核CPU上,理论上能快将近8倍。当然,实际会有线程调度开销,但3-5倍的提升是很常见的。
15.4 知识体系图
下面这张图帮你理清并行算法的核心逻辑:
15.5 性能对比与选择建议
我在实际项目中做过一个对比测试:对一个100万元素的vector做平方运算。结果如下:
| 策略 | 耗时(毫秒) | 加速比 | 适用场景 |
|---|---|---|---|
seq |
45 | 1x | 调试、小数据量 |
par |
12 | 3.75x | 通用并行计算 |
par_unseq |
8 | 5.6x | 纯数值计算 |
你看,par 比 seq 快了将近4倍,而 par_unseq 又比 par 快了50%。但代价是更严格的约束条件。
- 开发阶段:一律用
seq,方便调试 - 性能测试:用
par,看加速比是否满意 - 生产环境:如果操作是纯函数且数据量大,上
par_unseq
15.6 避坑指南
最后,分享几个我踩过的坑:
- 迭代器失效: 并行算法要求迭代器至少是前向迭代器。如果你传了输入迭代器(比如从
std::istream_iterator来的),编译可能通过,但运行时会出问题。 - 异常安全: 如果操作函数抛异常,并行算法会调用
std::terminate()。所以,要么保证操作不抛异常,要么在外部捕获。 - 内存顺序: 并行算法不保证元素间的执行顺序。如果你依赖某个元素先处理完再处理另一个,那就不能用并行版本。
嗯,差不多就这些。并行算法是C++17给咱们的一个大礼包,用好了能显著提升性能。但记住:先正确,再高效。别为了追求那点加速比,把代码搞出难以调试的并发bug。
公众号:蓝海资料掘金营,微信 deep3321