28、C++17 核心特性:并行算法库,一键让STL算法并行执行。
说实话,我第一次看到C++17的并行算法库时,心里是有点怀疑的。
“就加个执行策略参数,就能让std::sort自动跑多核?”
这也太美好了吧?
但后来我在一个数据清洗项目里试了一把,效果确实惊艳。今天我们就来聊聊这个“一键并行”的黑科技。
并行算法库解决了什么问题?
传统的STL算法,比如std::for_each、std::sort,都是单线程执行的。
你想想看,现在CPU动不动就8核16核,但你的排序算法只用了一个核在干活。
这不是暴殄天物吗?
以前要实现并行排序,你得自己写线程池、分治策略、任务调度……
代码量翻倍不说,还容易出bug。
C++17的并行算法库,说白了就是把这件事标准化了。
核心机制:执行策略
并行算法的核心就是执行策略(Execution Policy)。
你只需要在调用STL算法时,多传一个参数,告诉编译器:“嘿,这个算法可以并行跑。”
C++17定义了三种执行策略:
| 策略 | 头文件 | 含义 |
|---|---|---|
| std::execution::seq | <execution> | 顺序执行(单线程) |
| std::execution::par | <execution> | 并行执行(多线程) |
| std::execution::par_unseq | <execution> | 并行+向量化(多线程+SIMD) |
我个人习惯是:先用par,性能不够再上par_unseq。
为什么?因为par_unseq对数据竞争更敏感,搞不好就出诡异bug。
实战:一键并行排序
来看一个最直观的例子——并行排序。
#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
#include <chrono>
#include <random>
int main() {
// 生成1000万个随机数
std::vector<int> data(10'000'000);
std::mt19937 gen(42);
std::uniform_int_distribution<> dist(0, 1000000);
for (auto& x : data) {
x = dist(gen);
}
// 顺序排序
auto data1 = data;
auto start = std::chrono::steady_clock::now();
std::sort(data1.begin(), data1.end());
auto end = std::chrono::steady_clock::now();
std::cout << "顺序排序耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
// 并行排序
auto data2 = data;
start = std::chrono::steady_clock::now();
std::sort(std::execution::par, data2.begin(), data2.end());
end = std::chrono::steady_clock::now();
std::cout << "并行排序耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
return 0;
}
在我那台8核的机器上,顺序排序大概要2.3秒,并行排序只要0.4秒。
将近6倍的加速比。
你想想看,就加了一个参数,性能翻了几倍。
哪些算法支持并行?
不是所有STL算法都支持并行。C++17主要覆盖了以下几类:
- 不修改序列的操作:std::all_of, std::any_of, std::none_of, std::for_each, std::find, std::count 等
- 修改序列的操作:std::copy, std::move, std::fill, std::transform, std::generate 等
- 排序和相关操作:std::sort, std::stable_sort, std::partial_sort, std::nth_element 等
- 数值操作:std::reduce, std::exclusive_scan, std::inclusive_scan 等
嗯,这里要注意:std::accumulate不支持并行。
为什么?因为accumulate的累加顺序是确定的,并行会破坏这个顺序。
替代方案是std::reduce,它允许非确定性的累加顺序。
避坑指南:数据竞争
我曾经在一个图像处理项目里踩过一个大坑。
当时我用std::for_each配合par策略,对图像每个像素做处理。
代码看起来没问题,但跑起来结果时对时错。
排查了半天,发现是共享计数器的问题。
// 错误示例:数据竞争
std::vector<int> vec(1000000);
int counter = 0;
std::for_each(std::execution::par, vec.begin(), vec.end(),
[&counter](int& x) {
x = counter++; // 多个线程同时修改counter!
});
这个代码有数据竞争。多个线程同时读取和修改counter,结果完全不可预测。
正确的做法是:每个线程操作独立的数据区域,或者使用原子操作。
// 正确示例:使用原子操作
std::vector<int> vec(1000000);
std::atomic<int> counter{0};
std::for_each(std::execution::par, vec.begin(), vec.end(),
[&counter](int& x) {
x = counter.fetch_add(1); // 原子递增
});
性能陷阱:任务粒度
并行算法不是万能的。
如果每个元素的操作非常轻量(比如就做个加法),并行带来的线程调度开销可能反而拖慢性能。
我一般遵循这个经验法则:
- 数据量 < 1000:用seq,别折腾
- 数据量 1000 ~ 10000:用par,但要看操作复杂度
- 数据量 > 10000:放心用par,收益明显
另外,par_unseq虽然理论上更快,但它要求函数不能有同步操作(不能加锁、不能调用mutex)。
因为向量化要求指令级并行,锁会破坏这个。
并行算法库的知识体系
下面这张图帮你理清并行算法库的核心脉络:
编译器支持
目前主流编译器都支持并行算法库:
- GCC 9+(需要链接tbb库)
- Clang 10+(需要链接tbb或pstl)
- MSVC 2019 16.7+(内置支持)
我建议你在CMake里这样配置:
find_package(TBB REQUIRED)
target_link_libraries(your_target TBB::tbb)
-ltbb。我第一次用的时候忘了链接,编译通过了但运行时报错,排查了半天才发现是链接问题。
总结
C++17的并行算法库,说白了就是用最小的代码改动,换取最大的性能提升。
你不需要学复杂的线程库,不需要手写任务调度。
只需要记住三个执行策略,然后在合适的场景下加上std::execution::par。
就这么简单。
但也要记住:并行不是银弹。数据竞争、任务粒度、编译器支持,这些都是你需要考虑的。
用好了,它就是你的性能加速器;用不好,它就是你的bug制造机。