一、并行算法:C++17 带来的性能革命

说实话,我第一次在项目中用上 C++17 的并行算法时,有种「终于等到你」的感觉。以前处理百万级数据排序,要么自己手写多线程,要么依赖第三方库,又累又容易出 bug。现在标准库直接给了你一套方案,而且用起来出奇地简单。

并行算法的核心思想就一句话:让算法自动利用多核 CPU。你不需要自己创建线程、管理锁、处理竞争条件——标准库帮你干了这些脏活累活。

1.1 执行策略:三种模式怎么选?

C++17 引入了三个执行策略,都在 <execution> 头文件里:

策略 含义 适用场景
std::execution::seq 顺序执行(单线程) 调试、数据量小、有副作用
std::execution::par 并行执行(多线程) CPU 密集型、无数据竞争
std::execution::par_unseq 并行 + 向量化 允许乱序、适合 SIMD

我个人习惯是:先用 par 试试水。如果数据量不大(比如几千个元素),seq 反而更快,因为线程创建销毁的开销比计算本身还大。你想想看,开 8 个线程去排序 100 个整数,这不是杀鸡用牛刀吗?

核心原则:并行不是银弹。数据量至少上万,才值得用 par

1.2 并行 sort:让排序飞起来

先看一个最直观的例子——并行排序:

#include <algorithm>
#include <execution>
#include <vector>
#include <chrono>
#include <iostream>

int main() {
    std::vector<int> data(10'000'000);
    // 生成随机数据
    std::generate(data.begin(), data.end(), std::rand);
    
    auto data_copy = data;
    
    // 顺序排序
    auto start = std::chrono::steady_clock::now();
    std::sort(std::execution::seq, data.begin(), data.end());
    auto end = std::chrono::steady_clock::now();
    std::cout << "seq: " 
              << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
              << "ms\n";
    
    // 并行排序
    start = std::chrono::steady_clock::now();
    std::sort(std::execution::par, data_copy.begin(), data_copy.end());
    end = std::chrono::steady_clock::now();
    std::cout << "par: " 
              << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
              << "ms\n";
    
    return 0;
}

在我的 8 核机器上,parseq 快了将近 5 倍。注意,不是 8 倍——因为还有内存带宽、缓存争用这些开销。嗯,这里要注意:并行算法的加速比不是线性的

1.3 并行 reduce:比 accumulate 快多少?

求和这种操作,天然适合并行。C++17 提供了 std::reduce

#include <numeric>
#include <execution>
#include <vector>

std::vector<double> values(1'000'000);
// 填充数据...

// 传统方式
double sum1 = std::accumulate(values.begin(), values.end(), 0.0);

// 并行方式(注意:顺序不保证!)
double sum2 = std::reduce(std::execution::par, 
                          values.begin(), values.end(), 0.0);

// 如果你需要结合律保证,用这个:
double sum3 = std::reduce(std::execution::par_unseq,
                          values.begin(), values.end(), 0.0,
                          std::plus<>{});

我曾经踩过的坑:std::reduce 不保证元素求值顺序!如果你用浮点数,结果可能和 accumulate 有微小差异。因为浮点加法不满足结合律,(a+b)+c 和 a+(b+c) 结果可能不同。所以,对精度敏感的场景,慎用 reduce

1.4 线程安全注意事项

并行算法虽然方便,但有个大前提:你的操作必须是线程安全的。具体来说:

  • 不能有数据竞争:多个线程不能同时写同一个内存位置
  • 迭代器必须安全:随机访问迭代器最稳妥,前向迭代器可能有问题
  • 函数对象必须无状态或只读:别在 lambda 里修改捕获的变量

看个反面教材:

// ❌ 错误示例:数据竞争!
std::vector<int> data(1000);
int counter = 0;
std::for_each(std::execution::par, data.begin(), data.end(),
    [&counter](int& x) {
        x = counter++;  // 多个线程同时写 counter!
    });

// ✅ 正确做法:用原子变量
std::atomic<int> counter{0};
std::for_each(std::execution::par, data.begin(), data.end(),
    [&counter](int& x) {
        x = counter.fetch_add(1);
    });

小技巧:如果你不确定代码是否线程安全,先用 seq 跑一遍,再用 par。如果结果不一致,说明有数据竞争。我经常用这个办法来排查问题。

1.5 实战:并行图像处理

终于到了我最喜欢的部分。图像处理是并行算法的绝佳场景——每个像素的计算相互独立,天然适合并行。

下面是一个简单的灰度化 + 高斯模糊的例子:

#include <execution>
#include <vector>
#include <cmath>

struct Pixel {
    unsigned char r, g, b, a;
};

// 将彩色图像转为灰度
void grayscale_parallel(std::vector<Pixel>& image) {
    std::for_each(std::execution::par, image.begin(), image.end(),
        [](Pixel& p) {
            // 标准灰度转换公式
            unsigned char gray = static_cast<unsigned char>(
                0.299f * p.r + 0.587f * p.g + 0.114f * p.b
            );
            p.r = p.g = p.b = gray;
        });
}

// 简单的 3x3 高斯模糊(仅演示并行思路)
void gaussian_blur_parallel(std::vector<Pixel>& image, int width, int height) {
    std::vector<Pixel> output(image.size());
    
    // 3x3 高斯核
    const float kernel[3][3] = {
        {1/16.0f, 2/16.0f, 1/16.0f},
        {2/16.0f, 4/16.0f, 2/16.0f},
        {1/16.0f, 2/16.0f, 1/16.0f}
    };
    
    // 对每个像素并行处理
    std::for_each(std::execution::par, 
                  image.begin(), image.end(),
        [&](Pixel& p) {
            // 计算像素索引
            size_t idx = &p - &image[0];
            int x = idx % width;
            int y = idx / width;
            
            // 边界检查(简化处理)
            if (x < 1 || x >= width-1 || y < 1 || y >= height-1) {
                output[idx] = p;
                return;
            }
            
            float sum_r = 0, sum_g = 0, sum_b = 0;
            for (int ky = -1; ky <= 1; ++ky) {
                for (int kx = -1; kx <= 1; ++kx) {
                    size_t neighbor_idx = (y + ky) * width + (x + kx);
                    const auto& np = image[neighbor_idx];
                    float w = kernel[ky+1][kx+1];
                    sum_r += np.r * w;
                    sum_g += np.g * w;
                    sum_b += np.b * w;
                }
            }
            
            output[idx] = {
                static_cast<unsigned char>(sum_r),
                static_cast<unsigned char>(sum_g),
                static_cast<unsigned char>(sum_b),
                p.a
            };
        });
    
    image = std::move(output);
}

这段代码在我的机器上,处理一张 4000x3000 的图片(1200 万像素),灰度化只要 15ms,模糊也只要 80ms 左右。如果用单线程,至少慢 4-5 倍。

实战经验:图像处理用 par_unseq 效果最好。因为像素计算没有依赖关系,编译器还能自动生成 SIMD 指令。我试过,比纯 par 再快 20-30%。

知识体系总览

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

C++17 并行算法知识体系 执行策略 std::execution::seq std::execution::par std::execution::par_unseq 单线程 · 调试用 多线程 · CPU密集 多线程 + SIMD 常用并行算法 std::sort std::reduce std::for_each std::transform 注意:所有并行算法要求迭代器、函数对象线程安全

总结一下

并行算法是 C++17 最实用的特性之一。你不需要成为多线程专家,就能写出充分利用多核 CPU 的代码。记住三点:

  • 数据量大才用并行,小数据反而更慢
  • 保证线程安全,别在 lambda 里写共享变量
  • par 开始,需要向量化再上 par_unseq

我在项目中用并行算法重构过好几个模块,最夸张的一次,图像处理管线从 2 秒降到了 0.4 秒。改动量?就加了几个 std::execution::par 参数。这种性价比,你说值不值?


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