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)。

因为向量化要求指令级并行,锁会破坏这个。

并行算法库的知识体系

下面这张图帮你理清并行算法库的核心脉络:

C++17 并行算法库 执行策略 (Execution Policy) std::execution::seq 顺序执行(单线程) std::execution::par 并行执行(多线程) std::execution::par_unseq 并行+向量化 不修改序列 for_each / find / count 修改序列 copy / fill / transform 排序相关 sort / stable_sort 数值操作 reduce / scan ⚠ 注意:函数必须无数据竞争 | 任务粒度影响性能 | par_unseq 禁止同步操作 std::accumulate 不支持并行,请用 std::reduce 替代

编译器支持

目前主流编译器都支持并行算法库:

  • GCC 9+(需要链接tbb库)
  • Clang 10+(需要链接tbb或pstl)
  • MSVC 2019 16.7+(内置支持)

我建议你在CMake里这样配置:

find_package(TBB REQUIRED)
target_link_libraries(your_target TBB::tbb)
小技巧:如果你用的是GCC,记得加上编译选项 -ltbb。我第一次用的时候忘了链接,编译通过了但运行时报错,排查了半天才发现是链接问题。

总结

C++17的并行算法库,说白了就是用最小的代码改动,换取最大的性能提升

你不需要学复杂的线程库,不需要手写任务调度。

只需要记住三个执行策略,然后在合适的场景下加上std::execution::par

就这么简单。

但也要记住:并行不是银弹。数据竞争、任务粒度、编译器支持,这些都是你需要考虑的。

用好了,它就是你的性能加速器;用不好,它就是你的bug制造机。


专注资料整理