C++ 并发编程实战 第十章 并行算法函数

目录

10.1 并行化的标准库算法函数

10.2 执行策略

10.2.1 使用执行策略的影响

10.2.2 std::execution::sequenced_policy

10.2.3 std::execution::parallel_policy

10.2.4 std::execution::parallel_unsequenced_policy

10.3 C++标准库中的并行算法

10.3.1 并行算法函数的使用范例

10.3.2 计数访问


参考《C++ 并发编程实战》 著:Anthony Williams 译: 吴天明

10.1 并行化的标准库算法函数

C++17为标准库添加并行算法。都是对之前已存在的一些标准算法的重载,例
如:std::find,std::transform
和std::reduce。并行版本的签名除了和单线程版本的函数签名相同之外,在参
数列表的中新添加了一个参数(第一个参数)——指定要使用的执行策略。例如:

1.std::vector<int> my_data;
2.std::sort(std::execution::par,my_data.begin(),my_data.end());

std::execution::par
的执行策略,表示允许使用多个线程调用此并行算法。这是一种权限,而不是一个申请——如果
需要,这个算法依旧可以以串行的方式运行。

10.2 执行策略

三个标注执行策略:
std::execution::sequenced_policy
std::execution::parallel_policy
std::execution::parallel_unsequenced_policy
这些类都定义在<execution>头文件中。这个头文件中也定义了三个相关的策略对象可以传递到算法中:
std::execution::seq
std::execution::par
std::execution::par_unseq
除了复制这三种类型的对象外,不能以自己的方式对执行策略对象进行构造,因为它们可能有一些特殊的初始化要
求。实现还会定义一些其他的执行策略,但开发者不能自定义执行策略。

10.2.1 使用执行策略的影响

将执行策略传递给标准算法库中的算法,那么算法的行为就由执行策略控制。这会有几方面的影响:
算法复杂化
抛出异常时的行为
算法执行的位置、方式和时间

向算法提供执行策略时,算法的复杂度就会发生变化:除了对并行的管理调度开销外,并行算法的核心操作将会被多次执行(交换,比较,以及提供的函数对象),目的是在总运行时间方面提供性能的整体改进。

10.2.2 std::execution::sequenced_policy

顺序策略并不是并行策略:它使用强制的方式实现,在执行线程上函数的所有操作。但它仍然是一个执行策略,因此对算法的复杂性和异常影响与其他标准执行策略相同。
这不仅需要在同一线程上执行所有操作,而且必须按照一定的顺序进行执行,这样步骤间就不会有交错。具体的顺序是未指定的,并且对函数的不同调用也是不存在的。尤其是在没有执行策略的情况下,不能保证函数的执行顺序与相应的重载执行顺序相同。例如:下面对std::for_each
的调用,是将1~1000填充到vector中,这里没有指定填充的顺序。这就与没有执行策略的重载不同,执行策略就要按顺序对数字进行存储:

1.std::vector<int> v(1000);
2.int count=0;
3.std::for_each(std::execution::seq,v.begin(),v.end(),
4.
[&](int& x){ x=++count; });

10.2.3 std::execution::parallel_policy

并行策略提供了在多个线程下运行的算法版本。操作可以在调用算法的线程上执行,也可以在库创建的线程上执行。在给定线程上执行需要按照一定的顺序,不能交错执行,但十分具体的顺序是不指定的;并且在不同的调用间,指定的顺序可能会不同。给定的操作将在整个持续时间内,在固定线程上执行。这就对算法所使用的迭代器、相关值和可调用对象有了额外的要求:想要并行调用,他们间就不能有数据竞争,也不能依赖于线程上运行的其他操作,或者依赖的操作不能在同一线程上。大多数情况下,可以使用并行执行策略,这样可能会使用到没有执行策略的标准库算法。只有在所需要的元素间有特定的顺序,或者对共享数据有非同步访问时,才会出现问题。将vector中的所有数都加上同一个值,就可以并行进行: 

std::for_each(std::execution::par,v.begin(),v.end(),[](auto& x){++x;});

若使用并行策略填充一个vector中,那这个例子肯定有问题;具体的讲,这样会出现未定义行为:


std::for_each(std::execution::par,v.begin(),v.end(),
[&](int& x){ x=++count; });

每次调用Lambda表达式时,都会对计数器进行修改,如果有多个线程在执行Lambda表达式,那么这里就会出现数据竞争,从而导致未定义行为。std::execution::parallel_ policy要求优先考虑这一点:即使库没有使用多线程,之前的调用依旧会产生未定义行为。对象是否出现未定义是调用的静态属性,而不是依赖库实现的细节。不过,这里允许在函数调用间进行同步,因此可以通过将count设置为std::atomic<int>的方式,而不是仅用简单int来表示,或是使用互斥量。这种情况下,可能会破坏使用并行执行策略的代码点,因此这里将对所有操作进行序列化调用。不过,通常情况下,会允许对共享状态的同步访问。


10.2.4 std::execution::parallel_unsequenced_policy

并行不排序策略提供了最大程度的并行化算法,用以得到对算法使用的迭代器、相关值和可调用对象的严格要求。
使用并行不排序策略调用的算法,可以在任意线程上执行,这些线程彼此间没有顺序。也就是,在单线程上也可以交叉运行,这样在第一个线程完成前,第二个操作会在同一个线程上启动,并且操作可以在线程间迁移,因此给定的操作可以在一个线程上启动,在另一个线程上运行,并在第三个线程上完成。使用并行不排序策略时,算法使用的迭代器、相关值和可调用对象不能使用任何形式的同步,也不能调用任何需要同步的函数。
也就是,必须对相关的元素或基于该元素可以访问的数据进行操作,并且不能修改线程之间或元素之前共享的状态。

10.3 C++标准库中的并行算法

 标准库中的大多数被执行策略重载的算法都在
<algorithm>和<numeric>头文件中。包括有:all_of,any_of,
none_of,for_each,for_each_n,find,find_if,find_end,find_first_of,adjacent_find,
count,count_if,mismatch,equal,search,search_n,copy,copy_n,copy_if,move,
swap_ranges,transform,replace,replace_if,replace_copy,replace_copy_if,fill,
fill_n,generate,generate_n,remove,remove_if,remove_copy,remove_copy_if,unique,
unique_copy,reverse,reverse_copy,rotate,rotate_copy,is_partitioned,partition,
stable_partition,partition_copy,sort,stable_sort,partial_sort,partial_sort_copy,
is_sorted,is_sorted_until,nth_element,merge,inplace_merge,includes,set_union,
set_intersection,set_difference,set_symmetric_difference,is_heap,is_heap_until,
min_element,max_element,minmax_element,lexicographical_compare,reduce,
transform_reduce,exclusive_scan,inclusive_scan,transform_exclusive_scan,
transform_inclusive_scan和adjacent_difference 。

对于列表中的每一个算法,每个”普通”算法的重载都有一个新的参数(第一个参数),这个参数将传入执行策略——“普通”重载的相应参数在此执行策略参数之后。例如,std::sort有两个没有执行策略的“普通”重载:

1.template<class RandomAccessIterator>
2.void sort(RandomAccessIterator first, RandomAccessIterator last);
3.
4.template<class RandomAccessIterator, class Compare>
5.void sort(
6.
RandomAccessIterator first, RandomAccessIterator last, Compare comp);

因此,它还具有两个有执行策略的重载:

1.template<class ExecutionPolicy, class RandomAccessIterator>
2.void sort(
3.ExecutionPolicy&& exec,
4.RandomAccessIterator first, RandomAccessIterator last);
5.
6.template<class ExecutionPolicy, class RandomAccessIterator, class Compare>
7.void sort(
8.ExecutionPolicy&& exec,
9.RandomAccessIterator first, RandomAccessIterator last, Compare comp);

有执行策略和没有执行策略的函数列表间有一个重要的区别,这只会影响到一些算法:如果“普通”算法允许输入迭代器或输出迭代器,那执行策略的重载则需要前向迭代器。因为输入迭代器是单向迭代的:只能访问当前元素,并且不能将迭代器存储到以前的元素。输出迭代器只允许写入当前元素:不能在写入后面的元素后,后退再写入前面的元素。

虽然,模板参数的命名没有从编译器的角度带来任何影响,但从C++标准的角度来看:标准库算法模板参数的名称表示语义约束的类型,并且算法的操作将依赖于这些约束,且具有特定的语义。对于输入迭代器与前向迭代器,前者对迭代器的引用返回允许取消代理类型,代理类型可转换为迭代器的值类型,而后者对迭代器的引用返回要求取消对值的实际引用,并且所有相同的迭代器都返回对相一值的引用。
这对于并行性很重要:这意味着迭代器可以自由地复制,并等价地使用。此外,增加正向迭代器不会使其他副本失效也很重要,因为这意味着单线程可以在迭代器的副本上操作,需要时增加副本,而不必担心使其他线程的迭代器失效。如果带有执行策略的重载允许使用输入迭代器,将强制线程序列化,对用于从源序列读取唯一迭代器的访问,显然限制了并行的可能性。

10.3.1 并行算法函数的使用范例

执行策略的选择
std::execution::par是最常使用的策略,除非实现提供了更适合的非标准策略。如果代码适合并行化,那应该与std::execution::par一起工作。某些情况下,可以使用std::execution::par_unseq进行代替。这可能根本没什么用(没有任何标准的执行策略可以保证能达到并行性的级别),但它可以给库额外的空间,通过重新排序和交错任务执行来提高代码的性能,以换取对代码更严格的要求。更严格的要求中最值得注意的是,访问元素或对元素执行操作时不使用同步。这意味着不能使用互斥量或原子变量,或前面章节中描述的任何其他同步机制,以确保多线程的访问是安全的;相反,必须依赖于算法本身,而不是使用多个线程访问同一个元素,并在调用并行算法之外使用外部
同步,从而避免其他线程访问数据。
清单10.1中的示例显示的代码中,可以使用std::execution::par,但不能使用std::execution::par_unseq。使用内部互斥量同步意味着使用std::execution::par_unseq将会导致未定义的行为。

清单10.1 具有内部同步并行算法的类

1.
class X{
2.mutable std::mutex m;
3.int data;
4.
public:
5.X():data(0){}
6.int get_value() const{
7.std::lock_guard guard(m);
8.return data;
9.}
10.void increment(){
11.std::lock_guard guard(m);
12.++data;
}
13.
14.};
15.void increment_all(std::vector<X>& v){
std::for_each(std::execution::par,v.begin(),v.end(),
16.
17.[](X& x){
18.x.increment();
});
19.
20.
}

10.3.2 计数访问

假设有一个运作繁忙的网站,日志有数百万条条目,你希望对这些日志进行处理以便查看相关数据:每页访问多少次、访问来自何处、使用的是哪个浏览器,等等。分析日志有两个部分:处理每一行以提取相关信息,将结果聚合在一起。对于使用并行算法来说,这是一个理想的场景,因为处理每一条单独的行完全独立于其他所有行,并且如果最终的合计是正确的,可以逐个汇总结果。
这种类型的任务适合transform_reduce,下面的代码展示了如何将其用于该任务。
清单10.3 使用transform_reduce来记录网站的页面被访问的次数

1.#include <vector>
2.#include <string>
3.#include <unordered_map>
4.#include <numeric>
5.
6.
struct log_info {
7.std::string page;
8.time_t visit_time;
9.std::string browser;
// any other fields
10.
11.
};
12.
13.extern log_info parse_log_line(std::string const &line); // 1
14.using visit_map_type= std::unordered_map<std::string, unsigned long long>;
15.visit_map_type
16.count_visits_per_page(std::vector<std::string> const &log_lines) {
struct combine_visits {
17.
18.visit_map_type
19.operator()(visit_map_type lhs, visit_map_type rhs) const { // 3
if(lhs.size() < rhs.size())
20.
std::swap(lhs, rhs);
21.
for(auto const &entry : rhs) {
22.
lhs[entry.first]+= entry.second;
23.
}
24.
return lhs;
25.
26.}
27.visit_map_type operator()(log_info log,visit_map_type map) const{ // 4
28.++map[log.page];
29.return map;
30.}
31.visit_map_type operator()(visit_map_type map,log_info log) const{ // 5
++map[log.page];
32.
return map;
33.
34.}
35.visit_map_type operator()(log_info log1,log_info log2) const{ // 6
36.visit_map_type map;
37.++map[log1.page];
38.++map[log2.page];
39.return map;
}
40.
41.};
42.return std::transform_reduce( // 2
43.std::execution::par, log_lines.begin(), log_lines.end(),
44.visit_map_type(), combine_visits(), parse_log_line);
45.
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 《并发编程实战》是一本经典的并发编程书籍,其中包含了丰富的代码示例。这本书的代码示例非常有实战意义,可以帮助开发者在处理并发编程中的各种问题时提供参考。其中的代码示例主要涉及线程池、CAS、原子操作、锁、并发容器、BlockingQueue、CyclicBarrier和Semaphore等相关知识点。 本书的代码示例分布在各个章节中,开发者可以根据需要选择不同的示例进行学习和实践。例如,在线程池相关章节,作者提供了诸如ThreadPoolExecutor、ExecutorCompletionService等类的实现,并且提供了基于可扩展的ThreadPoolExecutor来实现动态调节线程池大小的代码示例。这些示例可以帮助开发者深入了解线程池的实现方式,以及如何进行线程池的调优。 在锁相关章节,作者提供了诸如ReentrantLock和读写锁ReentrantReadWriteLock等类的实现,并且提供了一些实际应用场景下的代码示例,例如票务系统和登录系统。这些示例可以帮助开发者了解锁的原理及其使用方法。 本书同时也介绍了一些常用的并发容器,例如ConcurrentHashMap、ConcurrentLinkedQueue等,在使用这些容器时需要注意线程安全的问题。作者为这些容器提供了详细的使用方法和代码示例,帮助开发者了解如何高效地使用这些容器。总之,《并发编程实战》的代码示例非常有价值,具有一定参考和借鉴意义,可以帮助开发者更好地掌握并发编程知识。 ### 回答2: 《Java并发编程实战》一书的源码是该书的大部分内容的实现代码。这些代码的使用可以帮助读者更好地理解并发编程的实现方式,同时也可以成为读者自己学习并发编程的参考资料。 该书的源码包括一些经典的并发编程实现,例如线程池、锁、原子变量、阻塞队列等。这些实现具有实用性和普遍性,可以帮助读者在自己的开发中解决并发编程问题。同时,该书的源码还包括一些基于实际场景的例子,让读者可以更好地理解并发编程在实际项目开发中的应用。 在使用该书源码时,读者需要关注一些细节问题,例如多线程环境下的原子性、可见性和有序性等。同时,读者还需要学会如何调试和排查多线程程序的问题,以保证程序的正确性和稳定性。 总之,该书的源码是学习并发编程的重要工具之一,读者需要认真学习源码并结合实际项目开发进行练习。只有这样,才能真正掌握并发编程的技巧和应用。 ### 回答3: 《Java并发编程实战》是一本著名的并发编程领域的经典著作,其中的源代码涵盖了Java并发编程的多个方面,非常有学习和参考的价值。 该书中的源代码主要包括了多线程并发、线程池、ThreadLocal、锁、信号量、条件等一系列并发编程相关的实例和案例,涵盖了从最基础的并发操作到应用场景的实践。 通过学习并实践这些源代码,我们可以更好地理解并发编程的思路和原理,掌握并发编程的技能和方法,提高代码质量和性能。同时,还可以培养我们的编码思维和能力,为我们今后的编程工作和研究打下坚实的基础。 总之,《Java并发编程实战》的源代码是具有非常实用和价值的,并发编程相关领域学习者和从业者都可以将其作为一个良好的学习和实践资源,不断探索和尝试。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值