并行算法:C++17 带来的真正“开箱即用”
说实话,在 C++17 之前,我们写多线程代码,基本就是手动创建 std::thread、加锁、搞条件变量。每次写个并行归并排序,都得折腾半天。我自己就干过这种事——为了一个简单的求和,手写线程池,结果 bug 修了两天。后来 C++17 的并行算法库出来了,我第一反应是:终于不用自己造轮子了。
这一章,我们就聊聊 std::execution::par 和它背后的并行算法库。说白了,就是 STL 算法现在可以“一键并行”了。
并行算法库的核心:执行策略
C++17 在 <algorithm> 和 <numeric> 中新增了重载版本,接受一个“执行策略”参数。这个参数告诉编译器:你想怎么跑这个算法。
目前标准定义了三种策略:
| 策略 | 含义 | 典型场景 |
|---|---|---|
std::execution::seq |
顺序执行,和传统版本一样 | 调试、依赖顺序的场景 |
std::execution::par |
并行执行,允许线程并行 | 数据量大、无数据竞争 |
std::execution::par_unseq |
并行 + 向量化,允许乱序 | CPU 密集型、可向量化操作 |
嗯,这里要注意:par_unseq 是最激进的。它允许编译器把循环拆成 SIMD 指令,甚至跨线程乱序执行。代价是——你的代码必须满足“无数据竞争”且“无副作用”。
par_unseq 对一个全局计数器做累加,结果每次跑出来的结果都不一样。后来才发现,par_unseq 要求迭代器访问不能有副作用,而我那个计数器本质上是个共享变量。
std::execution::par 到底怎么用?
用法其实特别简单。以前你写 std::sort(v.begin(), v.end()),现在改成:
#include <algorithm>
#include <execution>
#include <vector>
std::vector<int> data = {9, 3, 7, 1, 5, 8, 2, 6, 4};
// 顺序排序
std::sort(std::execution::seq, data.begin(), data.end());
// 并行排序
std::sort(std::execution::par, data.begin(), data.end());
// 并行 + 向量化排序
std::sort(std::execution::par_unseq, data.begin(), data.end());
就这么简单。你不需要管线程怎么创建、任务怎么分配。标准库帮你搞定。
我个人习惯是:数据量小于 1000 时用 seq,大于 10000 时用 par。中间那一段,得看具体场景。你想想看,如果数据只有几十个元素,创建线程的开销比排序本身还大,那就得不偿失了。
并行归约:std::reduce 与 std::accumulate 的区别
归约操作,说白了就是把一堆数合并成一个。比如求和、求积、找最大值。
传统上我们用 std::accumulate,但它有个问题:它是顺序执行的。你没法并行,因为每一步都依赖上一步的结果。
C++17 引入了 std::reduce,它允许乱序执行。什么意思?就是你可以把数组拆成几块,每块独立求和,最后再把部分和加起来。
#include <numeric>
#include <execution>
#include <vector>
std::vector<double> values(1000000, 1.0);
// 顺序求和
double sum_seq = std::accumulate(values.begin(), values.end(), 0.0);
// 并行求和
double sum_par = std::reduce(std::execution::par,
values.begin(), values.end(), 0.0);
这里有个关键点:std::reduce 要求操作是可交换且可结合的。加法满足这个条件,但减法就不行。我在项目中遇到过有人用 reduce 做字符串拼接,结果每次顺序都不一样,拼接出来的字符串乱掉了。
reduce,老老实实用 accumulate。
并行排序的性能真相
很多人以为加了 par 就一定快。其实不一定。我做过一个测试:对 100 万个随机整数排序,seq 用了 120ms,par 用了 45ms。看起来不错,对吧?但换成 1000 个元素,seq 用了 0.3ms,par 用了 2.1ms——反而更慢了。
为什么会这样?因为并行有开销:
- 线程创建/唤醒的延迟
- 任务切分的计算成本
- 缓存一致性维护
所以我的建议是:先测一下,别盲目并行。你可以在代码里加个简单的阈值判断:
auto sort_policy = [](size_t n) {
return n > 10000 ? std::execution::par : std::execution::seq;
};
std::sort(sort_policy(data.size()), data.begin(), data.end());
并行算法的限制与陷阱
并行算法不是银弹。有几个地方你得特别小心:
- 迭代器必须支持随机访问。 像
std::list的迭代器就不行,因为没法高效切分。 - 元素访问不能有数据竞争。 多个线程可能同时读取同一个元素,但写操作必须互斥。
- 异常处理很麻烦。 如果某个线程抛出异常,标准库会调用
std::terminate。所以最好保证你的 lambda 不抛异常。 - 执行策略是“提示”而非“命令”。 编译器可能忽略
par,在单线程上执行。这取决于实现。
for_each 里修改了一个全局 map,结果死锁了。后来改成每个线程维护局部结果,最后再合并。记住:共享可变状态是并行的大敌。
知识体系总览
下面这张图,我把并行算法的核心逻辑画出来了。你可以看到从执行策略到具体算法的映射关系,以及它们背后的线程模型。
实际项目中的选择建议
最后,我分享一下自己在项目里怎么选:
- 数据量大(>10万)且操作简单 → 用
par,比如排序、求和 - 数据量大但操作复杂 → 考虑手动分区,因为标准库的切分策略不一定最优
- 数据量小 → 别用并行,顺序执行更快
- 需要向量化 → 用
par_unseq,但确保无副作用 - 调试阶段 → 先用
seq跑通逻辑,再切到par
记住一句话:并行算法是工具,不是目的。用对了地方,事半功倍;用错了,事倍功半。