第10章 并行算法函数
10.1 并行化的标准库算法函数
- C++17 向标准库加入了并行算法函数。它们是新引入的多个函数重载
- 如std::find()
- std::transform()
- std::reduce()
- 其操作目标都是容器区间。相比各自对应的“普通的”单线程版本,这些并行版本具有相同的函数签名,只是新增了一个参数,用于设定执行策略,该参数排在参数列表最前面。
std::vector<int> my_data; std::sort(std::execution::par,my_data.begin(),my_data.end());
- 执行策略std::execution::par
- 准许该调用采用多线程,按并行算法的形式执行
- 并行执行方式会改变算法函数对复杂度的要求,比普通串行算法函数对复杂度的要求略为宽松
10.2 执行策略
- C++17标准制定了3种执行策略
std::execution::sequenced_polic
std::execution::parallel_policy
std::execution::parallel_unsequenced_policy
- 它们是3个类,由头文件
<execution>
定义 - 该头文件还定义了3个对应的策略对象,作为参数向算法函数传递
std::execution::seq
std::execution::pa
std::execution::par_unseq
10.2.1 因指定执行策略而普遍产生的作用
- 向标准库的算法函数传入执行策略的参数,则函数行为受控于该策略。
- 其行为在以下方面受到影响
- 算法函数的复杂度
- 抛出异常时的行为。
- 算法函数的步骤会在何时、从何处、以何种方式执行。
- 对算法函数复杂度所产生的作用
- 异常行为
- 如果按某种执行策略调用算法函数,而期间有异常抛出,则后果取决于所选用的执行策略。
- 如果有异常未被捕获,由 C++ 标准给出的 3 种执行策略就会调用std::terminate()。
- 只要按标准的执行策略调用标准库的算法函数,抛出的异常其实只有一种
- std::bad_alloc异常
- 当程序库无法为内部操作分配足够内存资源时即抛出该异常
- 算法中间步骤的执行起点和执行时机
- 执行策略指定了算法函数的中间步骤的执行主体
- 可能是平常的CPU线程、向量流(vector stream)、GPU线程或任何其他运算单元
- 还指定了算法的中间步骤存在的内存次序约束
- 这些步骤是否服从某种特定次序
- 独立的步骤之间是否可以互相交错执行
- 或是否可以彼此并行执行,等等
10.2.2 std::execution::sequenced_policy
- 顺序策略(sequenced policy)与并行无关
- 它令算法函数在发起调用的线程上执行全部操作,因而不会发生并行
- 几乎没有施加内存次序限制
- 它们之间可以自由选择同步机制,也会因同一线程上的操作而发生变化,但不得假设存在完全确定的操作次序
10.2.3 std::execution::parallel_policy
- 并行策略(parallel policy)给出了多个线程并行的基本
- 并行策略对这些目标的内存次序施加了更多限制
- 若它们涉及并行操作,就绝不能引发数据竞争,也不得假设其他任何操作会由同一个线程执行,还不得假设其他任何操作一定会由别的线程执行
- 绝大多数情况下,我们都可以令其采用并行策略
- 只有在下述情况下才会引发问题
- 某些元素的操作要求服从特定的次序,或共享数据的访问之间没有同步
- 只有在下述情况下才会引发问题
10.2.4 std::execution::parallel_unsequenced_policy
- 针对算法函数用到的各种迭代器、值和可调用对象,非顺序并行策略(parallel unsequenced policy)就其内存次序施加了最严格的限制,以便标准库最大程度发挥算法并行化的潜力
- 如果令算法函数采用非顺序并行策略,它就会在多个线程上按乱序执行算法步骤,线程之间的操作将不服从代码流程的先后顺序
10.3 C++ 标准库的并行算法函数
- 算法函数由头文件
<algorithm>
和 ` 给出- 其中大多数具有可以指定执行策略的重载版
- C++ 标准库的迭代器类别
- 输入迭代器(input iterator)
- 属于单通迭代器,用途是获取值,它常常用于控制台或网络的输入,我们也用它从生成序列中取得数据
- 输出迭代器(output iterator)
- 属于单通迭代器,用途是写出值。它常常用于文件输出,向容器添加新值。输出迭代器的步进会令其副本失效
- 前向迭代器(forward iterator)
- 属于多通(multi-pass)迭代器,用途是单向迭代持久化数据。虽然我们无法使前向迭代器逆转,返回过往的位置,但我们可以保存其居于某个位置时的副本,以提取早前访问过的元素
- 双向迭代器(bidirectional iterator)
- 双向迭代器属于多通迭代器,但它可以折返,可以访问前面的元素
- 随机访问迭代器(random access iterator)
- 属于多通迭代器,前向、后向移动皆可,但其移动距离不再限于单个元素,我们可以通过它的数组索引运算符,按偏移量直接访问目标元素
- 输入迭代器(input iterator)
第11章 多线程应用的测试和除错
11.1 与并发相关的错误类型
- 并发关联的错误分为两大类型。
- 多余的阻塞
- 条件竞争
11.1.1 多余的阻塞
- 若线程等待某项条件成立或某一状态出现,而无法继续处理任务,即称它被阻塞
- 等待目标可能是互斥、条件变量或future,也可能是I/O操作
11.1.2 条件竞争
- 条件竞争经常造成的问题类型如下
- 数据竞争
- 对共享内存区域的并发访问未采取同步措施,结果导致未定义行为
- 受到破坏的不变量
- 悬空指针
- 当前线程正在通过指针访问目标数据,而其他线程却同时删除指针
- 随机的内存数据损坏
- 数据正更新到一半,而其他线程却同时读取,造成数据不一致
- 重复释放内存
- 两个线程同时从队列弹出相同的值,它们都删除某份关联的数据
- 悬空指针
- 生存期问题
- 线程的生存期超出了它所访问的数据的生存期
- 数据被删除或以其他方式销毁后,线程仍试图访问它们,而相应的存储空间有可能已被另一个对象重用
- 数据竞争
11.2 定位并发相关的错误的技法
11.2.1 审查代码并定位潜在错误
- 多线程代码审查中需要考虑的问题
- 如果要进行并发访问,哪些数据需要保护
- 如何确保数据受到保护
- 若当前线程正在操作受保护的数据,那么其他线程可能同时在执行什么代码
- 当前线程持有哪些互斥
- 其他线程可能持有哪些互斥
- 当前线程和其他线程上的操作需要服从什么次序?该次序限制如何强制实施
- 当前线程所读取的数据是否仍旧合法、有效?该数据是否有可能已被其他线程改动过
- 如果假定其他线程有可能以并发方式改动数据,那么该改动的发生条件和影响是什么?我们如何能保证改动不会发生
11.2.2 通过测试定位与并发相关的错误
- 将应用软件调整为单线程模式,而错误依旧,即说明错误成因不是并发功能
- 测试环境考虑因素
- 每项测试中多线程的数目是多少
- 硬件系统所具有的处理器内核是否足够,能否让每个线程独具一个内核
- 应该在哪些处理器架构上运行测试
- 我们能否确保系统进行合理调度,使测试中的操作真正实现“同时”和“并发”
11.2.3 设计可测试的代码
- 通常只要做到以下几点,代码就相对容易测试
- 每个函数和类的职责清楚明确
- 函数短小精悍,功能切中要害
- 接受测试的目标代码处于测试环境中,而实施测试的代码和用例可以完全掌控该环境。
- 执行特定操作的相关代码应该汇聚在一起,以方便测试,不得散布于整个系统中
- 着手编写代码前,先想清楚如何对其进行测试
11.2.4 多线程测试技术
- 强力测试
- 让代码承受压力运行,看它是否崩溃
- 缺点
- 可能导致错误置信(特定环境才能复现)
- 组合模拟测试
- 用特定软件模拟真实的运行时环境,并在其中运行受测代码
- 两种测试方法
- 在普通环境下多次运行测试,但可能错失某些错误
- 特定的模拟环境中多次运行测试,而这更像是追查已经存在的错误
- 采用特殊的程序库检测错误
11.2.5 以特定结构组织多线程的测试代码
- 这种测试的根本问题是,我们需要编排一组线程,为其中每一个线程分别选定目标代码,并令它们同时执行
11.2.6测试多线程代码的性能
- 若要测试多线程代码性能,最好在尽量多的、不同硬件配置的系统上进行,这样我们才能清楚分析软件的可伸缩性