并行算法: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());

并行算法的限制与陷阱

并行算法不是银弹。有几个地方你得特别小心:

  1. 迭代器必须支持随机访问。std::list 的迭代器就不行,因为没法高效切分。
  2. 元素访问不能有数据竞争。 多个线程可能同时读取同一个元素,但写操作必须互斥。
  3. 异常处理很麻烦。 如果某个线程抛出异常,标准库会调用 std::terminate。所以最好保证你的 lambda 不抛异常。
  4. 执行策略是“提示”而非“命令”。 编译器可能忽略 par,在单线程上执行。这取决于实现。
避坑指南: 我曾经在并行 for_each 里修改了一个全局 map,结果死锁了。后来改成每个线程维护局部结果,最后再合并。记住:共享可变状态是并行的大敌。

知识体系总览

下面这张图,我把并行算法的核心逻辑画出来了。你可以看到从执行策略到具体算法的映射关系,以及它们背后的线程模型。

C++17 并行算法知识体系 执行策略(Execution Policies) seq(顺序) | par(并行) | par_unseq(并行+向量化) 并行算法(Parallel Algorithms) std::sort | std::for_each | std::transform | std::reduce std::find | std::count | std::copy | std::generate (约 60+ 个算法支持并行重载) 底层机制(Underlying Mechanisms) 线程池(Thread Pool) | 任务窃取(Work Stealing) SIMD 向量化 | 缓存友好分区 图:C++17 并行算法三层架构:策略 → 算法 → 底层机制

实际项目中的选择建议

最后,我分享一下自己在项目里怎么选:

  • 数据量大(>10万)且操作简单 → 用 par,比如排序、求和
  • 数据量大但操作复杂 → 考虑手动分区,因为标准库的切分策略不一定最优
  • 数据量小 → 别用并行,顺序执行更快
  • 需要向量化 → 用 par_unseq,但确保无副作用
  • 调试阶段 → 先用 seq 跑通逻辑,再切到 par

记住一句话:并行算法是工具,不是目的。用对了地方,事半功倍;用错了,事倍功半。


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