在多核处理器普及的今天,如何高效利用硬件资源成为提升软件性能的关键。C++17 引入的并行算法库(Parallel Algorithms)为开发者提供了一套标准化的并行编程接口,通过简单的策略切换即可将顺序算法转换为并行执行。本文将深入探讨 C++17 并行算法中最核心的执行策略 std::execution::par
,从基础概念到高级应用,全面解析其原理、用法及最佳实践。
传统 C++ 算法库(如
)提供的是顺序执行的算法,在多核环境下无法充分发挥硬件潜力。为解决这一问题,C++17 标准库引入了并行算法,主要基于以下几个方面的考虑:
C++17 并行算法主要由三部分组成:
执行策略(Execution Policies):定义算法的执行方式
std::execution::seq
:顺序执行std::execution::par
:并行执行std::execution::par_unseq
:并行且向量化执行std::execution::unseq
:C++20 新增,允许完全无序执行并行算法:标准库算法的并行版本,覆盖常见操作(如 for_each
、sort
、reduce
等)
异常处理机制:定义并行执行过程中异常的传播和处理方式
不同执行策略适用于不同场景:
执行策略 | 适用场景 |
---|---|
std::execution::seq |
调试阶段、需要顺序执行保证的场景、性能开销敏感的小型数据集 |
std::execution::par |
计算密集型任务,数据间无依赖,可并行化处理 |
std::execution::par_unseq |
计算密集型且可向量化的任务,如科学计算、图像处理 |
std::execution::unseq |
C++20 中进一步优化的无序执行,适用于高度并行化的计算 |
本文将重点讨论 std::execution::par
执行策略,它是最常用且最具代表性的并行执行策略。
std::execution::par
执行策略的核心思想是将算法的工作负载分配到多个线程上并行执行。当使用该策略调用算法时,标准库会自动:
标准库实现 std::execution::par
通常会维护一个线程池,避免频繁创建和销毁线程带来的开销。线程池的大小一般基于以下因素确定:
例如,在一个 8 核 CPU 上,线程池大小可能默认为 8,但实际使用时会根据任务特性和系统负载动态调整。
并行算法的性能很大程度上取决于数据划分的合理性。常见的数据划分策略包括:
标准库实现通常会根据算法类型和数据规模选择合适的划分策略,但在某些情况下,开发者也可以通过自定义执行器来调整划分方式。
下面是一个使用 std::execution::par
的简单示例,展示如何并行处理数据:
#include
#include
#include
#include
void process(int& value) {
// 模拟耗时操作
value = value * value;
}
int main() {
std::vector<int> data(1000000);
// 初始化数据
std::iota(data.begin(), data.end(), 1);
// 使用并行执行策略
std::for_each(std::execution::par, data.begin(), data.end(), process);
std::cout << "处理完成" << std::endl;
return 0;
}
在这个示例中,std::for_each
会将 process
函数应用到 data
容器的每个元素上。由于使用了 std::execution::par
策略,标准库会自动将数据划分为多个块,分配给不同线程并行处理。
数据局部性优化:尽量减少线程间的数据共享和通信,避免缓存失效
// 不良实践:多个线程频繁访问同一全局变量
std::atomic<int> counter = 0;
std::for_each(std::execution::par, data.begin(), data.end(), [](int value) {
if (value % 2 == 0) {
counter++; // 原子操作导致线程竞争
}
});
// 优化方案:使用并行 reduce 聚合结果
auto count = std::count_if(std::execution::par, data.begin(), data.end(),
[](int value) { return value % 2 == 0; });
任务粒度控制:避免任务过小导致调度开销占比过大,也不要过大导致负载不均
// 不良实践:任务粒度过小
std::for_each(std::execution::par, data.begin(), data.end(), [](int& value) {
value += 1; // 操作过于简单,调度开销占比大
});
// 优化方案:增大任务粒度
std::for_each(std::execution::par, data.begin(), data.end(), [](int& value) {
// 合并多个操作,增大任务粒度
value = value * value + value;
});
避免伪共享(False Sharing):确保线程处理的数据位于不同的缓存行
// 不良实践:多个线程修改同一数组相邻元素,导致缓存行争用
struct Data {
int value;
// 可添加填充避免伪共享
char padding[64]; // 确保每个对象占满一个缓存行(通常64字节)
};
std::vector<Data> data(1000);
std::execution::par
最适合以下场景:
不适合的场景:
在使用 std::execution::par
时,异常处理需要特别注意:
当任一线程抛出异常时,标准库会:
如果多个线程同时抛出异常,只有第一个异常会被传播,其他异常会被忽略
为确保资源安全,建议使用 RAII 技术管理资源
#include
#include
#include
#include
struct ResourceGuard {
ResourceGuard() { std::cout << "获取资源" << std::endl; }
~ResourceGuard() { std::cout << "释放资源" << std::endl; }
};
void process(int value) {
ResourceGuard guard; // 使用RAII确保资源释放
if (value == 500) {
throw std::runtime_error("处理错误");
}
// 处理逻辑
}
int main() {
std::vector<int> data(1000);
std::iota(data.begin(), data.end(), 1);
try {
std::for_each(std::execution::par, data.begin(), data.end(), process);
} catch (const std::exception& e) {
std::cout << "捕获异常: " << e.what() << std::endl;
}
return 0;
}
std::sort
是最受益于并行化的算法之一:
#include
#include
#include
#include
#include
int main() {
std::vector<int> data(1000000);
// 填充随机数据
std::generate(data.begin(), data.end(), []() {
return rand() % 1000000;
});
// 复制数据用于对比
auto data_seq = data;
auto data_par = data;
// 测试顺序排序
auto start_seq = std::chrono::high_resolution_clock::now();
std::sort(data_seq.begin(), data_seq.end());
auto end_seq = std::chrono::high_resolution_clock::now();
// 测试并行排序
auto start_par = std::chrono::high_resolution_clock::now();
std::sort(std::execution::par, data_par.begin(), data_par.end());
auto end_par = std::chrono::high_resolution_clock::now();
// 输出结果
std::cout << "顺序排序耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end_seq - start_seq).count()
<< " ms" << std::endl;
std::cout << "并行排序耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end_par - start_par).count()
<< " ms" << std::endl;
return 0;
}
在多核处理器上,并行排序通常能获得接近线性的加速比。
std::find_if
等查找算法也可以并行化:
#include
#include
#include
#include
bool is_prime(int n) {
if (n <= 1) return false;
for (int i = 2; i * i <= n; ++i) {
if (n % i == 0) return false;
}
return true;
}
int main() {
std::vector<int> data(1000000);
std::iota(data.begin(), data.end(), 1);
// 并行查找第一个素数
auto it = std::find_if(std::execution::par, data.begin(), data.end(), is_prime);
if (it != data.end()) {
std::cout << "找到第一个素数: " << *it << std::endl;
} else {
std::cout << "未找到素数" << std::endl;
}
return 0;
}
C++17 引入了 std::reduce
作为并行求和的推荐方式:
#include
#include
#include
#include
int main() {
std::vector<int> data(1000000);
std::iota(data.begin(), data.end(), 1);
// 并行求和
auto sum = std::reduce(std::execution::par, data.begin(), data.end(), 0);
std::cout << "总和: " << sum << std::endl;
return 0;
}
与 std::accumulate
相比,std::reduce
有以下优势:
std::transform
可以并行应用函数到每个元素:
#include
#include
#include
#include
int square(int x) {
return x * x;
}
int main() {
std::vector<int> data(1000000);
std::iota(data.begin(), data.end(), 1);
std::vector<int> result(data.size());
// 并行转换
std::transform(std::execution::par,
data.begin(), data.end(),
result.begin(),
square);
std::cout << "转换完成,结果大小: " << result.size() << std::endl;
return 0;
}
除了标准执行策略,C++17 还允许开发者自定义执行器,以实现更精细的控制:
#include
#include
#include
#include
#include
#include
// 自定义执行器,限制最大线程数
class BoundedExecutor {
private:
std::size_t max_threads;
public:
explicit BoundedExecutor(std::size_t max) : max_threads(max) {}
// 实现执行器接口
template<typename Function, typename... Args>
void execute(Function&& f, Args&&... args) const {
static std::atomic<std::size_t> active_threads(0);
// 如果活跃线程数超过限制,则在当前线程执行
if (active_threads >= max_threads) {
std::invoke(std::forward<Function>(f), std::forward<Args>(args)...);
} else {
// 否则创建新线程执行
active_threads++;
std::thread([&]() {
try {
std::invoke(std::forward<Function>(f), std::forward<Args>(args)...);
} catch (...) {
// 处理异常
}
active_threads--;
}).detach();
}
}
};
// 自定义执行策略
struct BoundedPolicy {};
// 为自定义策略提供执行器
template<>
struct std::execution::is_execution_policy<BoundedPolicy> : std::true_type {};
template<>
inline auto std::execution::require(BoundedPolicy, std::execution::parallel_policy) {
return BoundedExecutor(std::thread::hardware_concurrency());
}
int main() {
std::vector<int> data(1000000);
std::iota(data.begin(), data.end(), 1);
// 使用自定义执行策略
BoundedPolicy bounded_policy;
std::for_each(bounded_policy, data.begin(), data.end(), [](int& value) {
value = value * value;
});
std::cout << "处理完成" << std::endl;
return 0;
}
对于有依赖关系的任务,可以使用 std::experimental::parallel::task_group
(C++20 正式纳入):
#include
#include
#include
#include
namespace execution = std::experimental::execution;
namespace this_thread = std::this_thread;
int main() {
std::vector<int> data(1000);
std::iota(data.begin(), data.end(), 1);
execution::parallel_policy policy;
std::experimental::static_thread_pool pool(4);
std::experimental::task_group tasks(pool.executor());
// 任务1:初始化数据
auto task1 = tasks.run([&]() {
std::cout << "任务1: 初始化数据" << std::endl;
// 初始化数据...
});
// 任务2:处理数据,依赖任务1
auto task2 = tasks.run([&]() {
task1.wait(); // 等待任务1完成
std::cout << "任务2: 处理数据" << std::endl;
std::for_each(policy, data.begin(), data.end(), [](int& value) {
value = value * value;
});
});
// 任务3:汇总结果,依赖任务2
auto task3 = tasks.run([&]() {
task2.wait(); // 等待任务2完成
std::cout << "任务3: 汇总结果" << std::endl;
auto sum = std::reduce(policy, data.begin(), data.end(), 0);
std::cout << "总和: " << sum << std::endl;
});
// 等待所有任务完成
tasks.wait();
return 0;
}
不同编译器对 C++17 并行算法的支持程度不同:
编译器 | 支持情况 | 备注 |
---|---|---|
GCC | 自 GCC 7.0 起支持,需链接 -ltbb |
需要安装 TBB(Threading Building Blocks)库,提供线程池实现 |
Clang | 自 Clang 5.0 起支持,需链接 -ltbb |
同样依赖 TBB 库 |
MSVC (Visual Studio) | 自 VS 2017 起支持 | 无需额外库,使用 Windows 平台线程池 API |
Apple Clang | 部分支持,需链接 -ltbb |
macOS 平台需手动安装 TBB 库 |
不同实现的性能可能有所差异,特别是在以下方面:
par_unseq
策略的向量化程度差异为确保代码跨平台兼容,建议:
// 平台差异处理示例
#ifdef _WIN32
// Windows 特定代码
#define USE_WINDOWS_THREAD_POOL 1
#else
// Linux/macOS 代码
#include
#define USE_TBB_THREAD_POOL 1
#endif
int main() {
#ifdef USE_TBB_THREAD_POOL
tbb::task_scheduler_init init; // 初始化 TBB 线程池
#endif
// 并行算法代码...
return 0;
}
调试并行算法时需要注意:
#include
#include
#include
#include
#include
void profile_parallel_algorithm() {
std::vector<int> data(1000000);
std::iota(data.begin(), data.end(), 1);
// 预热
std::for_each(data.begin(), data.end(), [](int& value) {
value = value * value;
});
// 顺序执行
auto data_seq = data;
auto start_seq = std::chrono::high_resolution_clock::now();
std::for_each(data_seq.begin(), data_seq.end(), [](int& value) {
value = value * value;
});
auto end_seq = std::chrono::high_resolution_clock::now();
// 并行执行
auto data_par = data;
auto start_par = std::chrono::high_resolution_clock::now();
std::for_each(std::execution::par, data_par.begin(), data_par.end(), [](int& value) {
value = value * value;
});
auto end_par = std::chrono::high_resolution_clock::now();
// 输出性能数据
std::cout << "顺序执行时间: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end_seq - start_seq).count()
<< " ms" << std::endl;
std::cout << "并行执行时间: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end_par - start_par).count()
<< " ms" << std::endl;
std::cout << "加速比: "
<< static_cast<double>(std::chrono::duration_cast<std::chrono::microseconds>(end_seq - start_seq).count()) /
std::chrono::duration_cast<std::chrono::microseconds>(end_par - start_par).count()
<< std::endl;
}
std::execution::par
最适合以下场景:
C++17 的并行算法为开发者提供了强大而便捷的并行编程工具,std::execution::par
作为最常用的执行策略,能够在多数场景下显著提升程序性能。通过合理设计和优化,开发者可以充分利用多核处理器的潜力,同时保持代码的简洁性和可维护性。