本章主要介绍C++17标准的并行算法函数。
目录
2.2std::execution::sequenced_policy
2.3std::execution::parallel_policy
2.4std::execution::parallel_unsequenced_policy
1.并行化的标准库算法函数
C++17向标准库加入了并行算法函数,它们是多个函数的重载,如“std::fiind()、std::transform()和std::reduce()”,其操作的目标都是容器区间,相比对应的单线程版本,并行版本具有相同的函数签名,只是新增了一个参数,该参数排在参数列表的第一位,用于设定执行策略:
std::vector<int> my_data;
std::sort(std::execution::par, my_data.begin(), my_data.end());
通过执行策略std::execution::par,准许该调用采用多线程,按并行算法执行。标准库可以自行决定调用方式。
2.执行策略
C++17标准制定了3种执行策略,由头文件<execution>定义:
std::execution::sequenced_policy
std::execution::parallel_policy
std::execution::parallel_unsequenced_policy
该头文件还定义了三个对应的策略对象,作为参数向算法函数传递:
std::execution::seq
std::execution::par
std::execution::par_unseq
根据C++标准,任何程序库的实现都可以提供额外的执行策略,并自行决定该执行策略的语义。
2.1执行策略普遍产生的作用
向标准库算法函数传入执行策略参数,函数行为受控于该策略的影响:
算法复杂度、执行异常的行为、算法函数步骤的执行方式、对复杂度产生的作用
因为并行执行需要进行额外的调度管理、执行更多核心操作(内部数据互换、执行比较操作或运行函数对象),意在减少总运行时间。
复杂度变化的精确细节因不同算法函数而异,假设某算法一个行为的重复数为SE,若采用指定的执行策略,其复杂度要求则放宽到O(SE),也就是前者操作量的数倍,增长的倍数取决于标准库的内部实现和系统平台的底层实现。
1.异常行为
假设调用某算法函数期间有异常抛出,则后果取决于所选用的执行策略。若异常未被捕获,在执行C++标准的三种执行策略的情况下,会调用std::terminate()令整个程序终止;若按标准的执行策略执行,则抛出的异常只有std::bad_alloc异常(无法分配足够的内存资源)。
2.算法中间步骤的执行主体和执行时机
执行策略的基本要素,也是不同策略之间的不同点。执行策略指定了算法函数中间步骤的执行主体,可能是CPU线程、定向流、GPU线程或其他运算单元。也指定了算法中间步骤的内存次序约束:这些独立的步骤是否可交错执行或并行执行等。
2.2std::execution::sequenced_policy
顺序策略,令算法函数在调用的线程上执行,因而不会发生并行,也不存在交错执行。若某算法函数的重载按指定执行策略执行,与没有指定执行策略相比,两者服从的内存次序可能有差异(没有指定执行策略则按顺序执行)。
std::execution::sequenced_policy几乎没有施加内存次序限制,可自由选择同步机制。
2.3std::execution::parallel_policy
并行策略,给出了多个线程并行的基本模式。函数的内部操作可以在发起调用的线程上执行,也可以由程序库另外创建线程执行。
并行策略对内存次序施加了更多限制:决不能引发数据竞争,不能假设任何操作由同一线程执行,也不得假设其他任何操作由别的线程执行。
在绝大多数情况下,都可以采用并行策略,只有下述情况会引发问题:
std::vector<int> v;
int count = 0;
std::for_each(std::execution::par, v.begin(), v.end(), [&](int& x) {x = ++count; });
上述函数迭代更新容器v中的元素,lambda函数每次更新都调用count,若在多个线程上同时执行该函数,会导致数据竞争,因为前面发生的函数调用会影响其后调用的运行,诱发未定义行为。
并行策略允许函数调用间的同步操作,我们可以将count原子化为std::atomic<int>,或采用互斥保护,虽然违背并行策略初衷,但能让多个调用同步访问共享数据。
2.4std::execution::parallel_unsequenced_policy
非顺序并行策略,就其内存次序施加了最严格的限制,以便标准库最大限度地发挥算法并行化的潜力。采用该执行策略的算法函数会在单个或多个线程上按乱序执行算法步骤,线程间的操作将不服从代码流程的先后顺序。
例如:第二项操作在第一项操作之前执行;某操作在第一个线程上启动,第二个线程上执行一部分,在第三个线程上收尾。
在不同迭代器、值或可调用对象上的操作不可以任何形式同步,也不能与别的函数或代码同步,即内部操作不能更改线程之间的共享数据或元素之间的共享状态。
3.C++标准库的并行算法函数
算法函数由头文件<algorithm>和<numeric>给出。
需要注意:若函数内部操作不满足结合律和交换律,由于操作次序不明确,可能导致结果不稳定。
是否支持执行策略,不仅令函数签名产生区别,其参数类型也有影响:若普通函数接收输入、输出迭代器,则其接收执行策略的重载只接受前向迭代器。因为输入、输出迭代器本质上是单通(single-pass)迭代器:只能通过它访问当前位置的元素,而不能将它逆转到或访问以往位置的元素。
C++迭代器类别
迭代器具有继承关系(单通为基类)
单通迭代器:
1.输入迭代器:
用于读取容器中元素的值,不能修改值。
单向,可以使用++运算符进行递增操作,不能倒退。
2.输出迭代器:
用于修改容器中元素的值,不能读取值。
单向,可以使用++运算符进行递增操作,不能倒退。
多通迭代器:
3.前向迭代器:
既可以读写数据,也可以只读数据。
支持++p,p++,*p,=,==,!=。
递增后,仍可以对前面的迭代器(已经保存的副本)值进行解引用操作。
对应类型:forward_list,unordered_map,unordered_set
4.双向迭代器:
包含所有正向迭代器的特性,并支持递减运算符(a--和--a)。
对应类型:list、map、set
5.随机迭代器:
包含了所有双向迭代器的特性,能够进行随机访问。
支持,<,<=等比较运算符,p1<p2:p1经过至少一次++等于p2;支持p2-p1。
对应类型:vector、queue
以std::copy()函数为例:
//普通版本函数签名
template<class InputIterator, class OutputIterator>
OutputIterator copy(InputIterator first, InputIterator last, OutputIterator result);
//执行策略版本函数签名
template<class ExecutionPolicy, class ForwardIterator1, class ForwardIterator2>
ForwardIterator copy(ExecutionPolicy&& policy, ForwardIterator1 first, ForwardIterator1 last, ForwardIterator2 result);
前向迭代器(返回真正引用)可以随意复制,副本可以等效使用,递增操作不会令副本失效,因而支持并行处理。若执行策略的重载支持输入迭代器(允许返回代理类型引用,能够转化为元素类型),则强迫每个线程共同使用唯一一个迭代器,从数据源序列读取值,这会令访问串行化。
3.1并行算法函数使用范例
最简单的范例是并行循环:对容器内的每个元素执行都某项操作,每个元素相互独立:
std::for_each(std::execution::par, v.begin(), v.end(), do);
一般情况下,我们会使用std::execution::par策略,在不需要同步的情况下,也可以选择std::execution::par_unseq策略,提高代码交错执行任务的可能性,也许不会产生更好的效果,因为策略本身只是一种许可。
以下例子展示具有内部同步功能的类:
class X
{
mutable std::mutex m;
int data;
public:
X():data(0){}
int get_value()const
{
std::lock_guard guard(m);
return data;
}
void increment()
{
std::lock_guard guard(m);
++data;
}
};
void increment_all(std::vector<X>& v)
{
std::for_each(std::execution::par, v.begin(), v.end(),
[](X& x) {x.increment(); });
}
因为每个X数据都有自己的互斥,无法使用std::execution::par_unseq策略,我们可以改为用互斥保护整个容器以适用该策略:
class X
{
int data;
public:
X():data(0){}
int get_value()const
{
return data;
}
void increment()
{
++data;
}
};
class ProtectedX
{
std::mutex m;
std::vector<X> v;
public:
void lock()
{
m.lock();
}
void unlock()
{
m.unlock();
}
std::vector<X>& get_vec()
{
return v;
}
};
void increment_all(ProtectedX& data)
{
std::lock_guard guard(data);//调用data.lock()
auto& v = data.get_vec();
std::for_each(std::execution::par, v.begin(), v.end(),
[](X& x) {x.increment(); });
}
缺点是锁粒度太大,若恰好有其它线程需要并发访问容器,则被迫等待。
3.2访问计数
假定我们需要处理某个网站的日志数据:每个页面的访问次数、访问的来源、用户所用浏览器等。处理分为两部分:逐行处理日志提炼相关信息,以及聚合信息结果。该场景适合使用并行算法函数,因为每行日志的处理完全独立,按行提炼出的结果逐步累计。
transform_reduce()正是为了这种类型的任务设计的,以统计访问每个页面的次数为例:
#include<vector>
#include<string>
#include<unordered_map>
#include<numeric>
struct log_info {
std::string page;
time_t visit_time;
std::string browser;
};
//外部声明 假设别的代码写出了该函数,用于从一项目日志中提炼出相关信息
extern log_info parse_log_line(const std::string& line);
//页面网址与访问次数的映射
using visit_map_type = std::unordered_map<std::string, unsigned long long>;
visit_map_type count_visits_per_page(const std::vector<std::string>& log_lines)
{
struct combine_visits {
visit_map_type operator()(visit_map_type lhs, visit_map_type rhs)const
{
if (lhs.size() < rhs.size())
std::swap(lhs, rhs);
for (const auto& entry : rhs)
{
lhs[entry.first] += entry.second;
}
return lhs;
}
visit_map_type operator()(log_info log, visit_map_type map)const
{
++map[log.page];
return map;
}
visit_map_type operator()(visit_map_type map, log_info log)const
{
++map[log.page];
return map;
}
visit_map_type operator()(log_info log1, log_info log2)const
{
visit_map_type map;
++map[log1.page];
++map[log2.page];
return map;
}
};
//(执行策略、迭代器头尾、初始值、执行的函数、产生中间结果的函数)
return std::transform_reduce(
std::execution::par, log_lines.begin(), log_lines.end(),
visit_map_type(), combine_visits(), parse_log_line
);
}
std::transform_reduce对容器中的每个元素执行parse_log_line()函数,得到中间结果,类型为log_info结构体,然后对相邻的log_info执行combine_visits()聚合,因为相邻的数据可能具有不同类型,我们需要为不同类型数据的汇合设置重载函数(2个log,2个map,1个log1个map)。
设计并行计算本是困难的工作,但上述模式准许我们移交给标准库实现,我们只需关注如何求出结果。