并行算法与执行策略¶
| 标准 | 难度 | 状态 | 分级 | 证据 | 审阅 | 最后校验 |
|---|---|---|---|---|---|---|
| C++17 | 3/5 | final | A | 3 条(manual 3) | 单审(zyuhao7) | 2026-10-27 |
编译器支持:GCC 9+ · Clang 17+ · MSVC 19.14+。版本查询:cppreference 编译器支持。
主张与证据¶
| 主张 | 支持证据 |
|---|---|
| 用 std::execution::par 并行遍历文件列表,共享容器由互斥量保护 | ev-potree-parallel-policy-paths-001 |
| 把执行策略存进局部变量再传给 for_each,便于集中切换顺序与并行 | ev-potree-parallel-policy-scale-002 |
| 用 par_unseq 并行排序点云,比较器只读入参、不碰共享状态 | ev-potree-parallel-policy-unseq-sort-003 |
本站证据页:ev-potree-parallel-policy-paths-001 · ev-potree-parallel-policy-scale-002 · ev-potree-parallel-policy-unseq-sort-003
解决什么问题¶
标准库算法原本只有一条执行路径:在当前线程上按顺序处理整个区间。要在多核算力上摊开工作,过去只能引入 OpenMP 指令、TBB 的 parallel_for,或自己把区间切块再交给线程池。这些方案各有自己的任务调度和异常规则,换一个编译器就要重写一遍。
执行策略把“怎么执行”从算法实现里拆出来。std::sort、std::for_each、std::transform、std::reduce 等算法增加接受策略参数的重载,同一次调用可以在 std::execution::seq(顺序)、par(并行)、par_unseq(并行且向量化)之间切换。标准提案为 P0024R2,头文件是 <execution>。
基础用法¶
策略是算法调用的第一个参数,含义是允许实现自行决定并行粒度,而不是强制它开线程。
#include <algorithm>
#include <execution>
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values(10);
std::iota(values.begin(), values.end(), 1);
std::sort(std::execution::par, values.begin(), values.end(),
[](int lhs, int rhs) { return lhs > rhs; });
std::vector<int> doubled(values.size());
std::transform(std::execution::par, values.begin(), values.end(),
doubled.begin(), [](int value) { return value * 2; });
const int sum =
std::reduce(std::execution::par, values.begin(), values.end());
std::cout << values.front() << ' ' << values.back() << ' ' << sum << '\n';
std::cout << doubled.front() << ' ' << doubled.back() << '\n';
}
输出 10 1 55 与 20 2。示例用并行 sort 降序排列、并行 transform 逐元素变换,再用 reduce 求和;reduce 不保证按左结合顺序累加,因此它的初值和运算符必须满足结合律与交换律,需要固定顺序时仍用 std::accumulate。完整版本见 examples/cpp17/parallel_algorithms.cpp。
seq 明确要求顺序执行且不向量化;par 允许跨线程并行;par_unseq 进一步允许在同一线程内交错、向量化执行。不传策略的旧重载行为等价于 seq。
进阶用法¶
并行算法处理的是“互相独立的迭代”。用 par 遍历时,闭包会被多个线程同时调用,所有共享状态都要自己同步:计数器用原子量或互斥量,写入不同元素时确认下标互不重叠,容器扩容必须在进入算法之前完成。
par_unseq 的约束更严。它的调用可以交错执行甚至在同一条指令里成组执行,闭包中不能有互斥锁、不能分配可能阻塞的内存、不能调用非可重入的库函数。比较器、投影函数只读取自己的实参时才是安全的。
异常处理与顺序版本不同。元素访问函数抛出的异常会被捕获,实现随后调用 std::terminate,而不是把异常传播给调用者。需要逐元素报告失败时,应在闭包内记录错误,让算法正常返回后再统一处理。
迭代器要求也比顺序算法更严:并行重载通常要求前向迭代器以上,部分算法要求随机访问迭代器。链表容器不能直接套用 par 版本的 sort。
执行策略是“许可”而非“保证”。实现可以选择忽略策略、退回串行执行,标准允许这种降级,所以不能把并行当作正确性前提,例如依赖执行顺序或线程数。
真实项目证据¶
| 项目 | 位置 | 用法 | 为什么典型 |
|---|---|---|---|
| Potree | Converter/src/main.cpp#L206-L208 |
用 std::execution::par 并行读取多个输入文件,结果容器由 mutex 保护 |
文件之间天然独立,共享写入仍要串行化 |
| Potree | Converter/include/PotreeConverter.h#L213-L216 |
把策略存到局部变量 parallel,再用它遍历所有数据源 |
策略集中管理,调试点只需改一处即可退回顺序执行 |
| Potree | Converter/include/sampler_poisson.h#L183-L184 |
用 par_unseq 按到中心的距离排序点云,比较器只读两个 Point 实参 |
比较器无共享状态,符合 unsequenced 策略的可重入要求 |
三条证据均为 manual 级:作者复核过出处与行号,尚未引入第二名审核者。三处都是生产路径,其中两处把策略先存成变量,说明工程代码更愿意在同一位置统一开关并行。
陷阱与边界¶
并行不等于更快。 小数据集上调度开销可能超过计算收益。元素很少、每个元素工作量很轻时,seq 往往更快,是否启用应当实测。
共享状态必须自己同步。 算法只并行化迭代,不会替你加锁。多个线程同时 push_back 到同一容器、同时递增普通 int,都是数据竞争。
异常会终止进程。 并行重载捕获到异常后调用 std::terminate,不会沿调用栈抛出。需要错误处理时,在闭包里记录状态,返回后再检查。
par_unseq 的闭包要可重入。 加锁、分配、I/O 以及依赖线程局部状态的操作都不适合放在 par_unseq 的回调里。
迭代器类别有限制。 并行重载通常要求前向或随机访问迭代器;std::list、输入流迭代器不能使用 par。
实现可能串行执行。 标准只要求“允许”并行,不保证真的并行。依赖线程数、执行顺序或真正并发的测试会在不同标准库上失败。
标准库支持参差。 GCC 9 起提供并行算法,但底层使用自己的线程池;Clang 需要较新版本且依赖 TBB;部分平台上的 par 完全退化为串行。
库联动¶
- 标准算法族:
for_each、transform、reduce、sort、remove_if、count_if等都有执行策略重载,迁移成本低于换框架。 - Intel TBB:libstdc++ 与 libc++ 的并行后端通常基于 TBB 调度器,已引入 TBB 的项目可以直接受益。
- OpenMP:适合需要精细控制线程绑定、调度策略或设备卸载的场景;执行策略没有这些旋钮。
- 线程池与任务库:自带线程池的项目(如 stdexec、asio)通常更愿意直接用任务图,而不是标准并行算法。
横向对比¶
| 方案 | 侵入性 | 能否向量化 | 异常语义 | 主要代价 |
|---|---|---|---|---|
| 执行策略 | 低,改一个参数 | par_unseq 允许 |
抛异常即 terminate |
依赖标准库后端,调优空间小 |
| OpenMP | 中,需要编译指令 | 取决于编译器 | 循环内异常受限 | 编译器相关,库边界要单独处理 |
| Intel TBB | 高,需改写为任务或区间 | 部分 | 任务内异常可传播 | 引入第三方依赖与学习成本 |
| 手写线程池分片 | 高,需管理分片与汇合 | 否 | 完全自控 | 代码量大,容易写出数据竞争 |
跨标准演进:C++17 通过 P0024R2 把执行策略与并行算法纳入标准库;C++20 又为 std::ranges 补充了对应的约束式算法,但执行策略仍属于经典迭代器接口。