元数据

C++并发编程实战(第2版)

  •  C++并发编程实战(第2版)|200
  • 书名: C++并发编程实战(第2版)
  • 作者: 安东尼·威廉姆斯
  • 简介: 这是一本介绍C++并发和多线程编程的深度指南。本书从C++标准程序库的各种工具讲起,介绍线程管控、在线程间共享数据、并发操作的同步、C++内存模型和原子操作等内容。同时,本书还介绍基于锁的并发数据结构、无锁数据结构、并发代码,以及高级线程管理、并行算法函数、多线程应用的测试和除错。本书还通过附录及线上资源提供丰富的补充资料,以帮助读者更完整、细致地掌握C++并发编程的知识脉络。 本书适合需要深入了解C++多线程开发的读者,以及使用C++进行各类软件开发的开发人员、测试人员,还可以作为C++线程库的参考工具书。
  • 出版时间: 2021-12-01 00:00:00
  • ISBN: 9787115573551
  • 分类: 计算机-编程设计
  • 出版社: 人民邮电出版社
  • PC地址:https://weread.qq.com/web/reader/65a3287072898be365ae09e

高亮划线

第1章 你好,C++并发世界

📌 委员会遵守“班车模式”的新式发布规则,每隔3年发布一版新的C++标准 ⏱ 2022-06-08 10:12:04

📌 本书的主旨是介绍运用多线程编写C++并发程序,还有使之得以实现的C++特性和标准库工具。 ⏱ 2022-03-20 18:24:35

1.1 什么是并发

📌 按最简单、最基本的程度理解,并发(concurrency)是两个或多个同时独立进行的活动。 ⏱ 2022-06-08 10:12:07

📌 并发(concurrency)是两个或多个同时独立进行的活动。 ⏱ 2022-03-20 18:25:15

1.1.1 计算机系统中的并发

📌 计算机系统中的并发,则是指同一个系统中,多个独立活动同时进行,而非依次进行。 ⏱ 2022-06-08 10:11:56

📌 并发,则是指同一个系统中,多个独立活动同时进行,而非依次进行。 ⏱ 2022-03-20 18:25:26

📌 任务切换 ⏱ 2022-06-08 10:12:09

📌 任务切换对使用者和应用软件自身都制造出并发的表象。 ⏱ 2022-06-08 10:12:02

📌 无论是装配多个处理器,还是单个多核处理器,或是多个多核处理器,这些计算机都能真正并行运作多个任务,我们称之为硬件并发(hardware concurrency)。 ⏱ 2022-06-08 10:12:19

📌 为了交替执行,每当系统从某一个任务切换到另一个时,就必须完成一次上下文切换(context switch),于是耗费了时间。若要完成一次上下文切换,则操作系统需保存当前任务的CPU状态和指令指针[2],判定需要切换到哪个任务,并为之重新加载CPU状态。 ⏱ 2022-06-08 10:12:06

📌 真正需要注意的关键因素是硬件支持的线程数(hardware threads),也就是硬件自身真正支持同时运行的独立任务的数量。 ⏱ 2022-06-08 10:11:58

1.1.2 并发的方式

📌 。第一种方式采用多个进程,各进程都只含单一线程,情况类似于每位开发者都有自己的办公室;第二种方式只运行单一进程,内含多个线程,正如两位开发者同处一间办公室。 ⏱ 2022-06-08 10:12:12

📌 多进程并发在应用软件内部,一种并发方式是,将一个应用软件拆分成多个独立进程同时运行,它们都只含单一线程,非常类似于同时运行浏览器和文字处理软件。这些独立进程可以通过所有常规的进程间通信途径相互传递信息 ⏱ 2022-06-08 10:11:59

📌 进程间通信普遍存在短处:或设置复杂,或速度慢,甚至二者兼有 ⏱ 2022-06-08 10:11:54

📌 运行多个进程的固定开销大,进程的启动花费时间,操作系统必须调配内部资源来管控进程 ⏱ 2022-06-08 10:12:20

📌 进程间通信并非一无是处:通常,操作系统在进程间提供额外保护和高级通信机制。这就意味着,比起线程,采用进程更容易编写出安全的并发代码。 ⏱ 2022-06-08 10:11:55

📌 比起线程,采用进程更容易编写出安全的并发代码。 ⏱ 2022-01-16 10:40:35

📌 运用独立的进程实现并发,还有一个额外优势——通过网络连接,独立的进程能够在不同的计算机上运行。 ⏱ 2022-06-08 10:12:14

📌 另一种并发方式是在单一进程内运行多线程。 ⏱ 2022-06-08 10:12:10

📌 每个线程都独立运行,并能各自执行不同的指令序列。不过,同一进程内的所有线程都共用相同的地址空间,且所有线程都能直接访问大部分数据。 ⏱ 2022-06-08 10:12:17

1.1.3 并发与并行

📌 ,并发与并行(parallel)的含义很大程度上相互重叠 ⏱ 2022-06-08 10:12:11

📌 两个术语都是指使用可调配的硬件资源同时运行多个任务,但并行更强调性能。 ⏱ 2022-06-08 10:12:19

📌 两个术语都是指使用可调配的硬件资源同时运行多个任务,但并行更强调性能。当人们谈及并行时,主要关心的是利用可调配的硬件资源提升大规模数据处理的性能;当谈及并发时,主要关心的是分离关注点或响应能力。 ⏱ 2022-01-16 10:42:00

📌 谈及并行时,主要关心的是利用可调配的硬件资源提升大规模数据处理的性能;当谈及并发时,主要关心的是分离关注点或响应能力 ⏱ 2022-06-08 10:12:15

1.2 为什么使用并发技术

📌 应用软件使用并发技术的主要原因有两个:分离关注点与性能提升。 ⏱ 2022-06-08 10:12:03

1.2.1 为分离关注点而并发

📌 一直以来,编写软件时,分离关注点(separation of concerns)几乎总是不错的构思:归类相关代码,隔离无关代码,使程序更易于理解和测试,因此所含缺陷很可能更少。 ⏱ 2022-06-08 10:11:59

📌 分离关注点(separation of concerns)几乎总是不错的构思:归类相关代码,隔离无关代码,使程序更易于理解和测试,因此所含缺陷很可能更少。 ⏱ 2022-01-16 10:42:51

📌 线程的实际数量便与CPU既有的内核数量无关,因为用线程分离关注点的依据是设计理念,不以增加运算吞吐量为目的。 ⏱ 2022-06-08 10:12:08

1.2.2 为性能而并发:任务并行和数据并行

📌 增强性能的并发方式有两种。第一种,最直观地,将单一任务分解成多个部分,各自并行运作,从而节省总运行耗时。此方式即为任务并行。 ⏱ 2022-06-08 10:12:21

📌 针对数据,线程分别对数据的不同部分执行同样的操作,这被称为数据并行。 ⏱ 2022-06-08 10:12:21

📌 易于采用上述并行方式的算法常常被称为尴尬并行[4]算法。其含义是,将算法的代码并行化实在简单,甚至简单得会让我们尴尬 ⏱ 2022-06-08 10:12:13

📌 “天然并行”(naturally parallel)与“方便并发”(conveniently concurrent) ⏱ 2022-06-08 10:12:10

📌 尴尬并行算法具备的优良特性是可按规模伸缩——只要硬件支持的线程数目增加,算法的并行程度就能相应提升。 ⏱ 2022-06-08 10:12:04

📌 第二种增强性能的并发方式是利用并行资源解决规模更大的问题。 ⏱ 2022-06-08 10:11:57

1.2.3 什么时候避免并发

📌 知道何时避免并发,与知道何时采用并发同等重要 ⏱ 2022-06-08 10:12:16

📌 不用并发技术的唯一原因是收益不及代价 ⏱ 2022-06-08 10:12:01

📌 不用并发技术的唯一原因是收益不及代价。 ⏱ 2022-01-16 12:20:55

📌 采用了并发技术的代码更难理解,编写和维护多线程代码会更劳心费神,并且复杂度增加可能导致更多错误 ⏱ 2022-06-08 10:11:55

📌 除非潜在的性能提升或分离关注点而提高的清晰度值得这些开销,否则别使用并发技术。 ⏱ 2022-01-16 12:21:05

📌 性能增幅可能不如预期。线程的启动存在固有开销,因为系统须妥善分配相关的内核资源和栈空间,然后才可以往调度器添加新线程,这些都会耗费时间 ⏱ 2022-06-08 10:12:22

📌 线程是一种有限的资源。若一次运行太多线程,便会消耗操作系统资源,可能令系统整体变慢 ⏱ 2022-06-08 10:11:57

📌 运行的线程越多,操作系统所做的上下文切换就越频繁,每一次切换都会减少本该用于实质工作的时间。 ⏱ 2022-06-08 10:12:12

1.3.2 新标准对并发的支持

📌 新的C++库以Boost线程库作为原始范本,其中很多类在Boost线程库中存在对应者,名字和结构均一致。 ⏱ 2022-06-08 10:12:17

1.3.3 C++14和C++17进一步支持并发和并行

📌 C++14进一步增添了对并发和并行的支持,具体而言,是引入了一种用于保护共享数据的新互斥(见第3章)。C++17则增添了一系列适合新手的并行算法函数(见第10章) ⏱ 2022-06-08 10:12:08

实例——“Hello Concurrent World”

📌 管控线程的函数和类在中声明, ⏱ 2022-06-08 10:12:18 ^42568675-30-1076-1098

📌 每个线程都需要一个起始函数(initial function),新线程从这个函数开始执行 ⏱ 2022-06-08 10:12:14

2.1 线程的基本管控

📌 每个C++程序都含有至少一个线程,即运行main()的线程,它由C++运行时(C++ runtime)系统启动 ⏱ 2022-06-08 10:35:13

2.1.1 发起线程

📌 线程通过构建std::thread对象而启动 ⏱ 2022-06-08 10:35:42

📌 复杂任务则是另一个极端,它可以由函数对象(function object)表示,还接收参数,并且在运行过程中,经由某种消息系统协调,按照指定执行一系列独立操作,只有收到某指示信号(依然经由消息系统接收)时,线程才会停止。 ⏱ 2022-06-08 10:36:06

📌 任何可调用类型(callable type)[1]都适用于std::thread ⏱ 2022-06-08 10:36:17

📌 上面的代码在构造std::thread实例时,提供了函数对象f作为参数,它被复制到属于新线程的存储空间中,并在那里被调用,由新线程执行。故此,副本的行为必须与原本的函数对象等效,否则运行结果可能有违预期。 ⏱ 2022-06-08 10:36:57

📌 为临时函数对象命名即可解决问题,做法是多用一对圆括号,或采用新式的统一初始化语法(uniform initialization syntax,又名列表初始化) ⏱ 2022-06-08 10:39:07

📌 假如等到std::thread对象销毁之际还没决定好,那std::thread的析构函数将调用std::terminate()终止整个程序 ⏱ 2022-06-08 10:39:41

📌 假定程序不等待线程结束,那么在线程运行结束前,我们需保证它所访问的外部数据始终正确、有效。 ⏱ 2022-06-08 10:40:19

📌 令线程函数完全自含(self-contained),将数据复制到新线程内部,而不是共享数据 ⏱ 2022-06-08 10:50:05

2.1.2 等待线程完成

📌 若需等待线程完成,那么可以在与之关联的std::thread实例上,通过调用成员函数join()实现。 ⏱ 2022-06-08 10:50:38

📌 其中的意义是,对于某个给定的线程,join()仅能调用一次;只要std::thread对象曾经调用过join(),线程就不再可汇合(joinable),成员函数joinable()将返回false。 ⏱ 2022-06-08 10:51:33

2.1.3 在出现异常的情况下等待

📌 通常,若要分离线程,在线程启动后调用detach()即可,这不成问题。然而,假使读者打算等待线程结束,则需小心地选择执行代码的位置来调用join()。原因是,如果线程启动以后有异常抛出,而join()尚未执行,则该join()调用会被略过。 ⏱ 2022-06-08 10:52:15

2.1.4 在后台运行线程

📌 “启动后即可自主完成”(a fire-and-forget task) ⏱ 2022-06-09 16:13:36

📌 只有当t.joinable()返回true时,我们才能调用t.detach()。 ⏱ 2022-06-09 16:15:17

2.2 向线程函数传递参数

📌 若需按引用方式传递参数,只要用std::ref()函数加以包装即可 ⏱ 2022-06-09 16:29:25

📌 根据std::thread的构造函数和std::bind()函数的定义,它们都运用相同的机制进行内部操作 ⏱ 2022-06-09 16:34:39

📌 若要将某个类的成员函数设定为线程函数,我们则应传入一个函数指针,指向该成员函数。此外,我们还要给出合适的对象指针,作为该函数的第一个参数 ⏱ 2022-06-09 16:37:43

📌 若源对象是临时变量,移动就会自动发生。若源对象是具名变量,则必须通过调用std::move()直接请求转移 ⏱ 2022-06-09 16:39:56

2.3 移交线程归属权

📌 这里无须显式调用std::move(),因为新线程本来就由临时变量持有,而源自临时变量的移动操作会自动地隐式进行。 ⏱ 2022-06-09 22:02:41

📌 只要std::thread对象正管控着一个线程,就不能简单地向它赋新值,否则该线程会因此被遗弃。 ⏱ 2022-06-09 22:02:40

📌 函数可以便捷地向外部转移线程的归属权 ⏱ 2022-06-09 22:02:39

2.4 在运行时选择线程数量

📌 std::thread::hardware_concurrency()函数,它的返回值是一个指标,表示程序在各次运行中可真正并发的线程数量。 ⏱ 2022-06-09 22:02:38

2.5 识别线程

📌 线程ID所属型别是std::thread::id,它有两种获取方法。首先,在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。如果std::thread对象没有关联任何执行线程,调用get_id()则会返回一个std::thread::id对象,它按默认构造方式生成,表示“线程不存在”。其次,当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件内。 ⏱ 2022-06-09 22:31:08 ^42568675-41-367-602

📌 线程ID所属型别是std::thread::id,它有两种获取方法。首先,在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。如果std::thread对象没有关联任何执行线程,调用get_id()则会返回一个std::thread::id对象,它按默认构造方式生成,表示“线程不存在”。其次,当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件内。 ⏱ 2022-06-09 22:02:38 ^42568675-41-367-596

3.1 线程间共享数据的问题

📌 归根结底,多线程共享数据的问题多由数据改动引发。如果所有共享数据都是只读数据,就不会有问题。 ⏱ 2022-06-10 20:32:30

📌 改动线程间的共享数据,可能导致的最简单的问题是破坏不变量 ⏱ 2022-06-10 20:33:43

📌 条件竞争(race condition) ⏱ 2022-06-10 20:34:21

3.1.1 条件竞争

📌 在并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争。 ⏱ 2022-06-10 20:34:48

📌 当条件竞争导致不变量被破坏时,才会产生问题 ⏱ 2022-06-10 20:35:08

📌 当论及并发时,条件竞争通常特指恶性条件竞争。 ⏱ 2022-06-10 20:39:20

📌 “数据竞争”(data race):并发改动单个对象而形成的特定的条件竞争 ⏱ 2022-06-10 20:39:26

📌 诱发恶性条件竞争的典型场景是,要完成一项操作,却需改动两份或多份不同的数据,如上例中的两个链接指针。 ⏱ 2022-06-10 20:39:37

📌 采用并发技术的软件会涉及许多复杂的逻辑,其目的正是避开恶性条件竞争。 ⏱ 2022-06-10 20:40:17

3.1.2 防止恶性条件竞争

📌 最简单的就是采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见 ⏱ 2022-06-10 20:40:25

📌 另一种方法是,修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。这通常被称为无锁编程,难以正确编写。 ⏱ 2022-06-10 20:40:39

📌 还有一种防止恶性条件竞争的方法,将修改数据结构当作事务(transaction)来处理 ⏱ 2022-06-10 20:42:28

📌 软件事务内存 ⏱ 2022-06-10 20:42:02

📌 事务处理的基本思想是,单独操作,然后一步提交完成,相关内容我们会在后文展开。 ⏱ 2022-06-10 20:42:14

📌 在C++标准中,保护共享数据的最基本方式就是互斥 ⏱ 2022-06-10 20:42:34

3.2 用互斥保护共享数据

📌 C++线程库保证了,一旦有线程锁住了某个互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。 ⏱ 2022-06-10 20:46:58

3.2.1 在C++中使用互斥

📌 C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。代码清单3. ⏱ 2022-06-10 20:53:27

📌 C++17引入了一个新特性,名为类模板参数推导(class template argument deduction),顾名思义,对于std::lock_guard<>这种简单的类模板,模板参数列表可以忽略。如果编译器支 ⏱ 2022-06-10 20:54:18

📌 过大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增强保护。 ⏱ 2022-06-10 20:54:59

📌 所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门。

  • 💭 get release? - ⏱ 2022-06-10 20:55:39

3.2.2 组织和编排代码以保护共享数据

📌 向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。 ⏱ 2022-06-10 20:57:00

📌 若成员函数在自身内部调用了别的函数,而这些函数却不受我们掌控,那么,也不得向它们传递这些指针或引用。 ⏱ 2022-06-10 20:57:47

📌 以上代码的问题在于,它未能真正实现我们原有的设想:把所有访问共享数据的代码都标记成互斥。 ⏱ 2022-06-14 15:18:42

📌 锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。 ⏱ 2022-06-10 20:58:49

3.2.3 发现接口固有的条件竞争

📌 尽管运用了互斥或其他方式保护共享数据,条件竞争依然无法避免 ⏱ 2022-06-14 15:20:44

📌 它的根本原因在于函数接口,即使在内部使用互斥保护栈容器中的元素,也无法防范。 ⏱ 2022-06-14 15:21:20

📌 栈容器类的简要定义,它消除了接口的条件竞争。其成员函数pop()具有两份重载,分别实现了方法1和方法3:一份接收引用参数,指向某外部变量的地址,存储弹出的值;另一份返回std::shared_ptr<>。类的接口很简单,只有push()和pop()两个函数。 ⏱ 2022-06-14 15:26:20

📌 精细粒度的加锁策略也存在问题。为了保护同一个操作涉及的所有数据,我们有时候需要锁住多个互斥。 ⏱ 2022-06-14 15:28:12

📌 条件竞争是两个线程同时抢先运行,死锁则差不多是其反面:两个线程同时互相等待,停滞不前。 ⏱ 2022-06-14 15:28:36

3.2.4 死锁:问题和解决方法

📌 死锁(deadlock)。为了进行某项操作而对多个互斥加锁,由此诱发的最大的问题之一正是死锁。 ⏱ 2022-06-14 15:29:01

📌 防范死锁的建议通常是,始终按相同顺序对两个互斥加锁。 ⏱ 2022-06-14 15:29:06

📌 C++标准库提供了std::lock()函数,专门解决这一问题。它可以同时锁住多个互斥,而没有发生死锁的风险。 ⏱ 2022-06-14 15:29:53

📌 td::lock()在其内部对lhs.m或rhs.m加锁,这一函数调用可能导致抛出异常 ⏱ 2022-06-14 15:31:15

📌 std::lock()函数的语义是“全员共同成败”(all-or-nothing,或全部成功锁定,或没获取任何锁并抛出异常)。 ⏱ 2022-06-14 15:31:25

📌 C++17还进一步提供了新的RAII类模板std::scoped_lock<>。std::scoped_lock<>和std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。 ⏱ 2022-06-14 15:31:49

📌 C++17具有隐式类模板参数推导(implicit class template parameterdeduction)机制,依据传入构造函数的参数对象自动匹配①,选择正确的互斥型别。 ⏱ 2022-06-14 15:32:14

3.2.5 防范死锁的补充准则

📌 防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。 ⏱ 2022-06-14 15:33:02

📌 第一条准则最简单:假如已经持有锁,就不要试图获取第二个锁。 ⏱ 2022-06-14 15:33:15

📌 万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。 ⏱ 2022-06-14 15:33:26

📌 一旦持锁,就须避免调用由用户提供的程序接口 ⏱ 2022-06-14 15:34:04

📌 如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。 ⏱ 2022-06-14 15:34:13

📌 锁的层级划分就是按特定方式规定加锁次序,在运行期据此查验加锁操作是否遵从预设规则。 ⏱ 2022-06-14 15:35:28

📌 若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。具体做法是将层级的编号赋予对应层级应用程序上的互斥,并记录各线程分别锁定了哪些互斥。 ⏱ 2022-06-14 15:35:41

📌 为了存储当前层级编号,hierarchical_mutex的实现使用了线程专属的局部变量。所有互斥的实例都能读取该变量,但它的值因不同线程而异。这使代码可以独立检测各线程的行为,各互斥都能判断是否允许当前线程对其加锁。 ⏱ 2022-06-14 15:37:20

📌 死锁现象并不单单因加锁操作而发生,任何同步机制导致的循环等待都会导致死锁出现。 ⏱ 2022-06-14 15:38:37

3.2.6 运用std::unique_lock<>灵活加锁

📌 类模板std::unique_lock<>放宽了不变量的成立条件,因此它相较std::lock_guard<>更灵活一些。std::unique_lock对象不一定始终占有与之关联的互斥。首先,其构造函数接收第二个参数[7]:我们可以传入std::adopt_lock实例,借此指明std::unique_lock对象管理互斥上的锁;也可以传入std::defer_lock实例,从而使互斥在完成构造时处于无锁状态,等以后有需要时才在std::unique_lock对象(不是互斥对象)上调用lock()而获取锁,或把std::unique_lock对象交给std::lock()函数加锁。 ⏱ 2022-06-14 15:41:13

📌 std::unique_lock占用更多的空间,也比std::lock_guard略慢。但std::unique_lock对象可以不占有关联的互斥,具备这份灵活性需要付出代价:需要存储并且更新互斥信息。 ⏱ 2022-06-14 15:41:45

📌 std::unique_lock实例还含有一个内部标志,亦随着这些函数的执行而更新,以表明关联的互斥目前是否正被该类的实例占据。这一标志必须存在,作用是保证析构函数正确调用unlock()。 ⏱ 2022-06-14 15:47:51

📌 std::unique_lock类更为合适。延时加锁即属此例,前文已有示范。另一种情形是,需要从某作用域转移锁的归属权到其他作用域。 ⏱ 2022-06-14 15:48:28

3.2.7 在不同作用域之间转移互斥归属权

📌 因为std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。转移会在某些情况下自动发生,譬如从函数返回实例时,但我们须针对别的情形显式调用std::move()。 ⏱ 2022-06-14 15:48:44

📌 若是左值(lvalue,实实在在的变量或指向真实变量的引用),则必须显式转移,以免归属权意外地转移到别处;如果是右值(rvalue,某种形式的临时变量),归属权转移便会自动发生。std::unique_lock属于可移动却不可复制的型别。 ⏱ 2022-06-14 15:49:03

📌 转移有一种用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作。 ⏱ 2022-06-14 15:49:13

📌 通道(gate way)类是一种利用锁转移的具体形式,锁的角色是其数据成员,用于保证只有正确加锁才能够访问受保护数据,而不再充当函数的返回值。 ⏱ 2022-06-14 15:53:29

📌 std::unique_lock类十分灵活,允许它的实例在被销毁前解锁。其成员函数unlock()负责解锁操作,这与互斥一致。 ⏱ 2022-06-14 15:53:57

3.2.8 按适合的粒度加锁

📌 锁粒度”,该术语描述一个锁所保护的数据量,但它没有严格的实质定义。粒度精细的锁保护少量数据,而粒度粗大的锁保护大量数据。 ⏱ 2022-06-14 15:54:40

📌 两个要点:一是选择足够粗大的锁粒度,确保目标数据受到保护;二是限制范围,务求只在必要的操作过程中持锁。 ⏱ 2022-06-14 15:54:53

📌 只要条件允许,我们仅仅在访问共享数据期间才锁住互斥,让数据处理尽可能不用锁保护。 ⏱ 2022-06-14 15:55:25

📌 持锁期间应避免任何耗时的操作 ⏱ 2022-06-14 15:55:43

📌 有时候,数据结构的各种访问须采取不同层级的保护,但固定级别的粒度并不适合。std::mutex功能平实,无法面面俱到,故改用其他保护方式可能更恰当 ⏱ 2022-06-14 15:58:23

3.3 保护共享数据的其他工具

📌 C++标准提供了一套机制,仅为了在初始化过程中保护共享数据。 ⏱ 2022-06-14 15:58:49

3.3.1 在初始化过程中保护共享数据

📌 假设我们需要某个共享数据,而它创建起来开销不菲。因为创建它可能需要建立数据库连接或分配大量内存,所以等到必要时才真正着手创建。这种方式称为延迟初始化(lazy initialization),常见于单线程代码。 ⏱ 2022-06-14 15:59:08

📌 倍受诟病的双重检验锁定模式(double-checkedlocking pattern)。首先,在无锁条件下读取指针①,只有读到空指针才获取锁。其次,当前线程先判別空指针①,随即加锁。两步操作之间存在间隙,其他线程或许正好借机完成初始化。我们需再次检验空指针②(双重检验),以防范这种情形发生 ⏱ 2022-06-14 16:00:27

📌 当前线程在锁保护范围外读取指针①,而对方线程却可能先获取锁,顺利进入锁保护范围内执行写操作③,因此读写操作没有同步,产生了条件竞争,既涉及指针本身,还涉及其指向的对象。 ⏱ 2022-06-14 16:00:53

📌 数据竞争(data race),是条件竞争的一种,其将导致未定义行为 ⏱ 2022-06-14 16:03:30

📌 在C++标准库中提供了std::once_flag类和std::call_once()函数,以专门处理该情况 ⏱ 2022-06-14 16:05:13

📌 令所有线程共同调用std::call_once()函数,从而确保在该调用返回时, 指针初始化由其中某线程安全且唯一地完成(通过适合的同步机制)。 ⏱ 2022-06-14 16:05:41

📌 比显式使用互斥,std::call_once()函数的额外开销往往更低,特别是在初始化已经完成的情况下,所以如果功能符合需求就应优先使用。 ⏱ 2022-06-14 16:05:52

📌 值得一提的是,std::once_flag的实例既不可复制也不可移动,这与std::mutex类似。 ⏱ 2022-06-14 16:07:24

📌 如果把局部变量声明成静态数据,那样便有可能让初始化过程出现条件竞争。 ⏱ 2022-06-14 16:07:50

📌 C++11解决了这个问题,规定初始化只会在某一线程上单独发生,在初始化完成之前,其他线程不会越过静态数据的声明而继续运行。于是,这使得条件竞争原来导致的问题变为,初始化应当由哪个线程具体执行。 ⏱ 2022-06-14 16:08:09

📌 大多数时候,这些数据结构都处于只读状态,因此可被多个线程并发访问,但它们偶尔也需要更新。我们需要一种保护机制专门处理这种场景。 ⏱ 2022-06-14 16:08:43

3.3.2 保护甚少更新的数据结构

📌 读写互斥:允许单独一个“写线程”进行完全排他的访问,也允许多个“读线程”共享数据或并发访问。 ⏱ 2022-06-14 16:09:17

📌 对于那些无须更新数据结构的线程,可以另行改用共享锁std::shared_lockstd::shared_mutex实现共享访问。C++14引入了共享锁的类模板,其工作原理是RAII过程,使用方式则与std::unique_lock相同,只不过多个线程能够同时锁住同一个std::shared_mutex。共享锁仅有一个限制,即假设它已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全都释放该共享锁[11]。反之,如果任一线程持有排他锁,那么其他线程全都无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。 ⏱ 2022-06-14 16:11:38

3.3.3 递归加锁

📌 假如线程已经持有某个std::mutex实例,试图再次对其重新加锁就会出错,将导致未定义行为。 ⏱ 2022-06-14 16:12:03

📌 在某些场景中,确有需要让线程在同一互斥上多次重复加锁,而无须解锁。C++标准库为此提供了std::recursive_mutex,其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。我们必须先释放全部的锁,才可以让另一个线程锁住该互斥。 ⏱ 2022-06-14 16:12:14

📌 若要设计一个类以支持多线程并发访问,它就需包含互斥来保护数据成员,递归互斥常常用于这种情形。每个公有函数都需先锁住互斥,然后才进行操作,最后解锁互斥。但有时在某些操作过程中,公有函数需要调用另一公有函数。在这种情况下,后者将同样试图锁住互斥,如果采用std::mutex便会导致未定义行为。有一种“快刀斩乱麻”的解决方法:用递归互斥代替普通互斥。 ⏱ 2022-06-14 16:12:49

3.4 小结

📌 具体过程如下。在undefined_behaviour_with_double_checked_locking()函数的代码中,最初指针为空,当前线程和对方线程均进入第一层if分支①。接着对方线程夺得锁lk,顺利进入第二层if分支②;当前线程则被阻塞,在①②之间等待。然后对方线程一气呵成执行完余下全部代码:创建新实例并令指针指向它③,继而离开if分支,锁lk随之被销毁,最终调用do_something()操作新实例,更改其初始值④。当前线程后来终于获得了锁,便继续运行:这时它发现指针非空②,遂转去执行do_something()④,但实例已被改动过而当前线程却不知情,于是仍按其初值操作,错误发生。 ⏱ 2022-06-14 16:14:42

第4章 并发操作的同步

📌 上述线程间的同步操作很常见,C++标准库专门为之提供了处理工具:条件变量(conditional variable)和future。 ⏱ 2022-06-15 16:04:22

📌 线程闩(latch)和线程卡(barrier) ⏱ 2022-06-15 16:04:31

4.1 等待事件或等待其他条件

📌 如果线程甲需要等待线程乙完成任务[1],可以采取几种不同方式。 ⏱ 2022-06-15 16:04:59

📌 方式一:在共享数据内部维护一标志(受互斥保护),线程乙完成任务后,就设置标志成立。 ⏱ 2022-06-15 16:05:07

📌 方式二:让线程甲调用std::this_thread::sleep_for()函数(详见4.3节),在各次查验之间短期休眠。 ⏱ 2022-06-15 16:05:52

📌 因为线程休眠,所以处理时间不再被浪费。然而,休眠期的长短却难以预知 ⏱ 2022-06-15 16:06:13

📌 方式三:使用C++标准库的工具等待事件发生。我们优先采用这种方式。 ⏱ 2022-06-15 16:06:23

📌 。按照“条件变量”的概念,若条件变量与某一事件或某一条件关联,一个或多个线程就能以其为依托,等待条件成立。当某线程判定条件成立时,就通过该条件变量,知会所有等待的线程,唤醒它们继续处理。 ⏱ 2022-06-15 16:06:42

4.1.1 凭借条件变量等待条件成立

📌 C++标准库提供了条件变量的两种实现:std::condition_variable 和 std::condition_variable_any。 ⏱ 2022-06-15 16:07:00

📌 std::condition_variable仅限于与std::mutex一起使用;然而,只要某一类型符合成为互斥的最低标准,足以充当互斥,std::condition_variable_any即可与之配合使用,因此它的后缀是“_any”。 ⏱ 2022-06-15 16:07:14

📌 线程甲在std::condition_variable实例上调用wait(),传入锁对象和一个lambda函数,后者用于表达需要等待成立的条件 ⏱ 2022-06-15 16:09:48

📌 ait()在内部调用传入的lambda函数,判断条件是否成立:若成立(lambda函数返回true),则wait()返回;否则(lambda函数返回false),wait()解锁互斥,并令线程进入阻塞状态或等待状态 ⏱ 2022-06-15 16:10:35

📌 我们舍弃std::lock_guard而采用std::unique_lock,原因就在这里:线程甲在等待期间,必须解锁互斥,而结束等待之后,必须重新加锁,但std::lock_guard无法提供这种灵活性。 ⏱ 2022-06-15 16:10:42

📌 如果线程甲重新获得互斥,并且查验条件,而这一行为却不是直接响应线程乙的通知,则称之为伪唤醒(spurious wake) ⏱ 2022-06-15 16:11:35

📌 在线程间传递数据的常见方法是运用队列,如代码清单4.1所示。若队列的实现到位,同步操作就可以被限制在其内部,从而大幅减少可能出现的同步问题和条件竞争。 ⏱ 2022-06-15 16:12:29

4.1.2 利用条件变量构建线程安全的队列

📌 虽然empty()是const成员函数,拷贝构造函数的形参other也是const引用,但是其他线程有可能以非const形式引用队列容器对象,也可能调用某些成员函数,它们会改动数据成员,因此我们仍需锁定互斥。由于互斥因锁操作而变化,因此它必须用关键字mutable修饰①,这样才可以在empty()函数和拷贝构造函数中锁定。 ⏱ 2022-06-15 16:16:12

📌 如果多个线程因执行wait()而同时等待,每当有新数据就绪并加入data_queue(见成员函数push())时,notify_one()的调用就会触发其中一个线程去查验条件,让它从wait()返回。 ⏱ 2022-06-15 16:16:39

📌 如果几个线程都在等待同一个目标事件,那么还存在另一种可能的行为方式:它们全部需要做出响应。以上行为会在两种情形下发生:一是共享数据的初始化,所有负责处理的线程都用到同一份数据,但都需要等待数据初始化完成;二是所有线程都要等待共享数据更新(如定期执行的重新初始化)。 ⏱ 2022-06-15 16:17:10

📌 调用成员函数notify_all()。顾名思义,该函数通知当前所有执行wait()而正在等待的线程,让它们去查验所等待的条件 ⏱ 2022-06-15 16:17:27

📌 某个线程按计划仅仅等待一次,只要条件成立一次,它就不再理会条件变量。条件变量未必是这种同步模式的最佳选择。若我们所等待的条件需要判定某份数据是否可用,上述论断就非常正确。future更适合此场景。 ⏱ 2022-06-15 16:17:44

4.2 使用future等待一次性事件发生

📌 C++标准程序库使用future来模拟这类一次性事件:若线程需等待某个特定的一次性事件发生,则会以恰当的方式取得一个future,它代表目标事件;接着,该线程就能一边执行其他任务(光顾机场茶座),一边在future上等待;同时,它以短暂的间隔反复查验目标事件是否已经发生(查看出发时刻表) ⏱ 2022-06-15 16:18:13

📌 :独占future(unique future,即std::future<>)和共享future(shared future,即std::shared_future<>)。它们的设计参照了std::unique_ptr和std::shared_ptr。同一事件仅仅允许关联唯一一个std::future实例,但可以关联多个std::shared_future实例。 ⏱ 2022-06-15 16:18:42

📌 future对象本身不提供同步访问。若多个线程需访问同一个future对象,必须用互斥或其他同步方式进行保护, ⏱ 2022-06-15 16:19:23

4.2.1 从后台任务返回值

📌 只要我们并不急需线程运算的值,就可以使用std::async()按异步方式启动任务 ⏱ 2022-06-15 16:20:20

📌 从std::async()函数处获得std::future对象(而非std::thread对象),运行的函数一旦完成,其返回值就由该对象最后持有。若要用到这个值,只需在future对象上调用get(),当前线程就会阻塞,以便future准备妥当并返回该值。 ⏱ 2022-06-15 16:20:37

📌 在调用std::async()时,它可以接收附加参数,进而传递给任务函数作为其参数,此方式与std::thread的构造函数相同。 ⏱ 2022-06-15 16:20:59

📌 参数的类型是std::launch,其值可以是std::launch::deferred或std::launch::async[8]。前者指定在当前线程上延后调用任务函数,等到在future上调用了wait()或get(),任务函数才会执行;后者指定必须另外开启专属的线程,在其上运行任务函数。 ⏱ 2022-06-15 16:22:47

📌 。不过,使std::future和任务关联并非唯一的方法:运用类模板std::packaged_task<>的实例,我们也能将任务包装起来;又或者,利用std::promise<>类模板编写代码,显式地异步求值。 ⏱ 2022-06-15 16:29:27

4.2.2 关联future实例和任务

📌 std::packaged_task<>对象在执行任务时,会调用关联的函数(或可调用对象),把返回值保存为future的内部数据,并令future准备就绪。它可作为线程池的构件单元 ⏱ 2022-06-15 16:29:47

📌 std::packaged_task<>是类模板,其模板参数是函数签名(function signature) ⏱ 2022-06-15 16:30:16

📌 类模板std::packaged_task<>具有成员函数get_future(),它返回std::future<>实例 ⏱ 2022-06-15 16:30:51

4.2.3 创建std::promise

📌 。配对的std::promise和std::future可实现下面的工作机制:等待数据的线程在future上阻塞,而提供数据的线程利用相配的promise设定关联的值,使future准备就绪。 ⏱ 2022-06-15 16:35:22

📌 promise的值通过成员函数set_value()设置,只要设置好,future即准备就绪,凭借它就能获取该值。 ⏱ 2022-06-15 16:35:43

4.2.4 将异常保存到future中

📌 若经由std::async()调用的函数抛出异常,则会被保存到future中,代替本该设定的值,future随之进入就绪状态,等到其成员函数get()被调用,存储在内的异常即被重新抛出(C++标准没有明确规定应该重新抛出原来的异常,还是其副本;为此,不同的编译器和库有不同的选择) ⏱ 2022-06-15 16:37:46

📌 std::promise也具有同样的功能,它通过成员函数的显式调用实现。假如我们不想保存值,而想保存异常,就不应调用set_value(),而应调用成员函数set_exception()。 ⏱ 2022-06-15 16:38:10

📌 。如果关联的future未能准备就绪,无论销毁两者中的哪一个,其析构函数都会将异常std::future_error存储为异步任务的状态数据[10],它的值是错误代码std::future_errc::broken_promise[11] ⏱ 2022-06-15 16:39:04

📌 std::future自身存在限制,关键问题是:它只容许一个线程等待结果。若我们要让多个线程等待同一个目标事件,则需改用std::shared_future。 ⏱ 2022-06-15 16:39:20

4.2.5 多个线程一起等待

📌 这是std::future特性:它模拟了对异步结果的独占行为,get()仅能被有效调用唯一一次 ⏱ 2022-06-15 16:48:18

📌 只有一个线程可以获取目标值,原因是第一次调用get()会进行移动操作,之后该值不复存在。 ⏱ 2022-06-15 16:48:35

📌 std::shared_future的实例则能复制出副本,因此我们可以持有该类的多个对象,它们全指向同一异步任务的状态数据。 ⏱ 2022-06-15 16:48:58

📌 即便改用std::shared_future,同一个对象的成员函数却依然没有同步。若我们从多个线程访问同一个对象,就必须采取锁保护以避免数据竞争。首选方式是,向每个线程传递std::shared_future对象的副本,它们为各线程独自所有,并被视作局部变量 ⏱ 2022-06-15 16:49:12

📌 std::shared_future的实例依据std::future的实例构造而得,前者所指向的异步状态由后者决定。因为std::future对象独占异步状态,其归属权不为其他任何对象所共有,所以若要按默认方式构造std::shared_future对象,则须用std::move向其默认构造函数传递归属权,这使std::future变成空状态(empty state)。 ⏱ 2022-06-15 16:50:34

📌 std::future具有成员函数share(),直接创建新的std::shared_future对象,并向它转移归属权。 ⏱ 2022-06-15 16:51:04

4.3 限时等待

📌 :一是迟延超时(duration-based timeout),线程根据指定的时长而继续等待(如30毫秒);二是绝对超时(absolute timeout),在某特定时间点(time point)来临之前,线程一直等待。 ⏱ 2022-06-15 23:14:23

📌 大部分等待函数都具有变体,专门处理这两种机制的超时。处理迟延超时的函数变体以“_for”为后缀,而处理绝对超时的函数变体以“_until”为后缀。 ⏱ 2022-06-15 23:14:32

📌 其中一个重载停止等待的条件是收到信号、超时,或发生伪唤醒;我们需要向另一个重载函数提供断言,在对应线程被唤醒之时,只有该断言成立(向条件变量发送信号),它才会返回,如果超时,这个重载函数也会返回。 ⏱ 2022-06-15 23:15:14

4.3.1 时钟类

📌 时钟类的计时单元属于名为period的成员类型,它表示为秒的分数形式:若时钟每秒计数25次,它的计时单元即为std::ratio<1,25>;若时钟每隔2.5秒计数1次,则其计时单元为std::ratio<5,2>。 ⏱ 2022-06-15 23:16:43

📌 若时钟的计时速率恒定(无论速率是否与计时单元相符)且无法调整,则称之为恒稳时钟 ⏱ 2022-06-15 23:17:09

📌 时钟类具有静态数据成员is_steady,该值在恒稳时钟内为true,否则为false。 ⏱ 2022-06-15 23:17:05

📌 C++标准库提供了恒稳时钟类std::chrono::steady_clock ⏱ 2022-06-15 23:17:40

4.3.2 时长类

📌 std::chrono::duration<>是标准库中最简单的时间部件(C++标准库用到不少处理时间的工具,它们全都位于名字空间std::chrono内)。它是类模板,具有两个模板参数,前者指明采用何种类型表示计时单元的数量(如int、long或double),后者是一个分数,设定该时长类的每一个计时单元代表多少秒。 ⏱ 2022-06-15 23:18:41

📌 标准库在std::chrono名字空间中,给出了一组预设的时长类的typedef声明:nanoseconds、microseconds、milliseconds、seconds、minutes和hours,分别对应纳秒、微秒、毫秒、秒、分钟、小时。 ⏱ 2022-06-16 00:00:44

📌 为方便起见,C++14引入了名字空间std::chrono_literals,其中预定义了一些字面量后缀运算符(literal suffix operator)。这能够缩短明文写入代码的时长值 ⏱ 2022-06-16 00:01:09

📌 如果与整数字面值[17]一起使用,这些后缀就相当于由typedef预设的时长类,因此,15ns和std::chrono::nanoseconds(15)是两个相等的值。 ⏱ 2022-06-16 00:01:35

📌 计时单元的数量可通过成员函数count()获取, ⏱ 2022-06-16 00:02:28

📌 所有等待函数都返回一个状态值,指明是否超时或目标事件是否已发生。 ⏱ 2022-06-16 00:05:18

📌 ,我们借助future进行等待,所以一旦超时,函数就返回std::future_status::timeout;假如准备就绪,则函数返回std::future_status::ready;若future的相关任务被延后,函数返回std::future_status::deferred。 ⏱ 2022-06-16 00:05:41

4.3.3 时间点类

📌 在时钟类中,时间点由类模板std::chrono::time_point<>的实例表示,它的第一个模板参数指明所参考的时钟,第二个模板参数指明计时单元[19](std::chrono::duration<>的特化)。 ⏱ 2022-06-16 00:05:56

📌 时间点是一个时间跨度,始于一个称为时钟纪元的特定时刻,终于该时间点本身。 ⏱ 2022-06-16 00:06:11

📌 尽管时钟纪元的时刻无从得知,不过,我们可以在给定的时间点上调用time_since_epoch(),这个成员函数返回一个时长对象,表示从时钟纪元到该时间点的时间长度。 ⏱ 2022-06-16 00:07:14

📌 时间点用于带有后缀“_until”的等待函数的变体 ⏱ 2022-06-16 10:29:44

📌 条件变量之上的等待函数理应接收一个断言,借此判定所等的条件是否成立。假如读者不提供断言,则该函数只能依据是否超越时限来决定要继续等待还是要结束,那么,我推荐效仿上例的方式,这样就限定了循环的总耗时。 ⏱ 2022-06-16 00:08:49

4.3.4 接受超时时限的函数

📌 超时时限的最简单用途是,推迟特定线程的处理过程,若它无所事事,就不会占用其他线程的处理时间 ⏱ 2022-06-16 00:09:08

📌 普通的std::mutex 和std::recursive_mutex不能限时加锁,但std::timed_mutex和std::recursive_timed_mutex可以。这两种锁都含有成员函数try_lock_for()和try_lock_until(),前者尝试在给定的时长内获取锁,后者尝试在给定的时间点之前获取锁。 ⏱ 2022-06-16 00:12:07

4.4 运用同步操作简化代码

📌 线程间不会直接共享数据,而是由各任务分别预先备妥自己所需的数据,并借助future将结果发送到其他有需要的线程。 ⏱ 2022-06-16 00:12:31

4.4.1 利用future进行函数式编程

📌 术语“函数式编程”(functional programming)是指一种编程风格,函数调用的结果完全取决于参数,而不依赖任何外部状态。 ⏱ 2022-06-16 00:12:37

📌 纯函数(pure function)产生的作用被完全限制在返回值上,而不会改动任何外部状态。 ⏱ 2022-06-16 00:12:45

📌 future对象可在线程间传递,所以一个计算任务可以依赖另一个任务的结果,却不必显式地访问共享数据。 ⏱ 2022-06-16 00:13:50

📌 并发编程范式存在多种风格,函数式编程是其中之一,它能够摆脱共享的可变数据(shared mutable data),但不是唯一选择。通信式串行进程(Communicating Sequential Process,CSP)[26]范式也具有同样的特性:按设计概念,CSP线程相互完全隔离,没有共享数据,采用通信管道传递消息。 ⏱ 2022-06-16 00:20:47

4.4.2 使用消息传递进行同步

📌 CSP的理念很简单:假设不存在共享数据,线程只接收消息,那么单纯地依据其反应行为,就能独立地对线程进行完整的逻辑推断 ⏱ 2022-06-16 00:21:04

📌 因此,每个CSP线程[28]实际上都与状态机(state machine)等效:它们从原始状态起步,只要收到消息就按某种方式更新自身状态,也许还会向其他CSP线程发送消息。 ⏱ 2022-06-16 00:21:11

📌 真正的CSP模型没有共享数据,全部通信都经由消息队列传递,但是C++线程共享地址空间,因而这一规定无法强制实施 ⏱ 2022-06-16 00:21:24

📌 角色模型(actor model):系统中含有一些分散的角色(actor),它们各自在独立线程上运行,它们彼此收发消息以执行手上的任务,还直接通过消息传递状态,但除此以外,它们之间没有共享数据。 ⏱ 2022-06-16 00:23:44

📌 CSP风格的编程可大幅简化并发系统的设计工作,因为我们可以完全独立地处理每个线程。 ⏱ 2022-06-16 00:25:13

📌 分离关注点”的软件设计原则:通过利用多个线程,整体任务按要求被明确划分。 ⏱ 2022-06-16 00:25:18

4.4.3 符合并发技术规约的后续风格并发[30]

📌 并发技术规约在名字空间std::experimental内,给出了对应std::promise和std:: packaged_task的新版本,两者都与std名字空间中的原始版本有异:它们都返回std:: experimental::future实例,而非std::future。这让使用者得以使用std::experimental:: future的关键新特性——后续。 ⏱ 2022-06-16 00:25:40

📌 我们希望有一种方法能下达指令“一旦结果数据就绪,就接着进行某项处理”,这正是后续的功能。不出意外,为future添加后续调用的成员函数名为then()。 ⏱ 2022-06-16 00:26:05

📌 给定future对象fut,调用then(continuation)即可为之增添后续函数continuation()。 ⏱ 2022-06-16 00:26:09

📌 。所以,只要用then()添加了后续,原来的future对象(fut)就会失效;反而,then()的调用会返回新的future对象,后续函数的结果由它持有。 ⏱ 2022-06-16 00:26:19

📌 ,我们无法向后续函数传递参数,因为参数已经由程序库预设好,先前准备就绪的future会传入后续函数,它所包含的结果会触发后续函数的调用。 ⏱ 2022-06-16 00:27:04

📌 future上的后续函数发生连锁调用,而该future可能会持有结果的值,也可能持有异常。假若隐式地将值从future提取出来,再直接传递给后续函数,就不得不交由线程库决定如何处理异常,但只要把future传递给后续函数,异常就能交由它处理。 ⏱ 2022-06-16 00:27:40

📌 并发技术规约没有提供具备以下功能的函数:既与std::async()等价,又支持std::experimental::future。但我们可自行实现作为扩展,它编写起来相当简明、直接:凭借std::experimental::promise预先获取future对象,然后生成新线程运行一个lambda表达式,进而通过该lambda表达式执行任务函数,并将promise的值设定为任务函数的返回值 ⏱ 2022-06-16 00:28:00

📌 then()调用的返回值是一个功能完整的future,其意义在于,我们能对后续函数进行连锁调用。 ⏱ 2022-06-16 00:29:17

4.4.4 后续函数的连锁调用

📌 在最末尾的后续函数中,经由info_to_display.get()的调用抛出,该处的catch块能集中处理全部异常 ⏱ 2022-06-16 00:31:03

📌 :后续具有一个灵便的特性,名为future展开(future-unwrapping)。假设传递给then()的后续函数返回future <some_type>对象,那么then()的调用会相应地返回future<some_type>。 ⏱ 2022-06-16 00:31:43

📌 规约还给出了两个重载函数,准许我们在一组future上等待,或等待其中一个就绪,或等待全部就绪。 ⏱ 2022-06-16 00:33:07

4.4.5 等待多个future

📌 开销。上述“等待-切换”的行为实属无谓,采用std::experimental::when_all()函数即可避免。我们向该函数传入一系列需要等待的future,由它生成并返回一个新的总领future,等到传入的future全部就绪,此总领future也随之就绪 ⏱ 2022-06-16 00:34:42

4.4.6 运用std::experimental::when_any()函数等待多个future,直到其中之一准备就绪

📌 采用std::experimental::when_any()函数统筹众多future,它生成一个新的future返回给调用者,只要原来的future中至少有一个准备就绪,则该新future也随之就绪 ⏱ 2022-06-16 00:36:07

📌 std::experimental::when_any()与之不同,它产生的新future还增加了一层结构即一个依照类模板std::experimental::when_any_result<>而产生的内部实例,它由一个序列和一个索引值组成,其中序列包含传入的全体future,索引值则指明哪个future就绪,因而触发上述的新future ⏱ 2022-06-16 00:36:44

📌 这两个函数还具有可变参数的重载形式,能接收多个future直接作为参数,都返回future对象,而不是vector容器:std::experimental::when_all()所返回的future持有元组(tuple),而when_any()返回的future持有when_any_result实例。 ⏱ 2022-06-16 00:38:28

4.4.7 线程闩和线程卡——并发技术规约提出的新特性

📌 。线程闩是一个同步对象,内含计数器,一旦减到0,就会进入就绪状态。名字“线程闩”源于其特定用途,它对线程加闩,并保持封禁状态(只要它就绪,就一直保持该状态不变,除非对象被销毁)。因此,线程闩是一个轻量级工具,用于等待一系列目标事件发生。 ⏱ 2022-06-16 00:39:05

📌 ,线程卡是可重复使用的同步构件,针对一组给定的线程,在它们之间进行同步。 ⏱ 2022-06-16 00:39:22

📌 同一线程能令线程闩计数器多次减持,而多个线程也可分别令其计数器减持一次 ⏱ 2022-06-16 00:39:33

📌 在线程卡的每个同步周期内,只准许每个线程唯一一次运行到其所在之处。线程运行到线程卡处就会被阻塞,一直等到同组的线程全都抵达,在那个瞬间,它们会被全部释放。然后,这个线程卡可以被重新使用。在下一个同步周期中,同组的线程再度运行到该处而被阻塞,需再次等齐同组线程。 ⏱ 2022-06-16 00:39:58

4.4.8 基本的线程闩类std::experimental::latch

📌 std::experimental::latch由头文件<experimental/latch>定义。std::experimental::latch的构造函数接收唯一一个参数,在构建该类对象时,我们需通过这个参数设定其计数器的初值。 ⏱ 2022-06-16 00:40:13

📌 每当等待的目标事件发生时,我们就在线程闩对象上调用count_down(),一旦计数器减到0,它就进入就绪状态。若我们要等待线程闩的状态变为就绪,则在其上调用wait();若需检查其是否已经就绪,则调用is_ready()。最后,假如我们要使计数器减持,同时要等待它减到0,则应该调用count_down_and_wait()。 ⏱ 2022-06-16 00:40:36

4.4.9 基本的线程卡类std::experimental::barrier

📌 std::experimental::barrier和std::experimental:: flex_barrier,在头文件<experimental/barrier>中定义。前者相对简单,因而额外开销可能较低,而后者更加灵活,但额外开销可能较高。 ⏱ 2022-06-16 00:42:06

📌 假定有一组线程在协同处理某些数据,各线程相互独立,分别处理数据,因此操作过程不必同步。但是,只有在全部线程都完成各自的处理后,才可以操作下一项数据或开始后续处理,std::experimental::barrier针对的就是这种场景 ⏱ 2022-06-16 00:42:13

📌 。线程在完成自身的处理后,就运行到线程卡处,通过在线程卡对象上调用arrive_and_wait()等待同步组的其他线程。只要组内最后一个线程也运行至此,所有线程即被释放,线程卡会自我重置。 ⏱ 2022-06-16 00:42:27

📌 std::experimental::flex_barrier。后者之所以更灵活,是因为它的其中一种用途是设定串行区域:当所有线程运行至该线程卡处时,区域内的代码就会接着运行,直到完成后全部线程才会释放。 ⏱ 2022-06-16 00:43:35

4.4.10 std::experimental::flex_barrier——std::experimental::barrier的灵活版本

📌 std::experimental::flex_barrier类的接口与std::experimental::barrier类的不同之处仅仅在于:前者具备另一个构造函数,其参数既接收线程的数目,还接收补全函数(completion function)。只要全部线程都运行到线程卡处,该函数就会在其中一个线程上运行(并且是唯一一个)。 ⏱ 2022-06-16 00:43:55

第5章 C++内存模型和原子操作

📌 。内存模型精确定义了基础构建单元应当如何运转。 ⏱ 2022-06-16 00:45:28

📌 内存模型精确定义了基础构建单元应当如何运转。 ⏱ 2022-06-16 11:47:38

5.1 内存模型基础

📌 就C++而言,归根结底,基本结构就是对象和内存区域。 ⏱ 2022-06-16 11:48:29

5.1.1 对象和内存区域

📌 虽然C++为对象赋予了各种性质,如类型和生存期,但C++标准只将“对象”定义为“某一存储范围”(a region of storage)。 ⏱ 2022-06-16 11:49:10

📌 不论对象属于什么类型,它都会存储在一个或多个内存区域中。每个内存区域或是对象/子对象,属于标量类型(scalar type),如unsigned short和my_class*,或是一串连续的位域(bit field)。 ⏱ 2022-06-16 20:57:26

📌 尽管相邻的位域分属不同对象,但照样算作同一内存区域。 ⏱ 2022-06-16 20:57:40

📌 每个变量都是对象,对象的数据成员也是对象;每个对象都占用至少一块内存区域;若变量属于内建基本类型(如int或char),则不论其大小,都占用一块内存区域(且仅此一块),即便它们的位置相邻或它们是数列中的元素;相邻的位域属于同一内存区域。 ⏱ 2022-06-16 11:51:39

5.1.2 对象、内存区域和并发

📌 所有与多线程相关的事项都会牵涉内存区域。 ⏱ 2022-06-16 11:52:26

📌 任一线程改动数据都有可能引发条件竞争。要避免条件竞争,就必须强制两个线程按一定的次序访问。 ⏱ 2022-06-16 20:59:29

📌 假设两个线程访问同一内存区域,却没有强制它们服从一定的访问次序,如果其中至少有一个是非原子化访问,并且至少有一个是写操作,就会出现数据竞争,导致未定义行为。 ⏱ 2022-06-16 11:53:22

📌 凡是涉及数据竞争的内存区域,我们都通过原子操作来访问,即可避免未定义行为。这种做法不能预防数据竞争本身 ⏱ 2022-06-16 21:00:54

📌 改动序列(modificationorder) ⏱ 2022-06-16 21:01:03

5.1.3 改动序列

📌 在一个C++程序中,每个对象都具有一个改动序列[1],它由所有线程在对象上的全部写操作构成,其中第一个写操作即为对象的初始化。大部分情况下,这个序列会随程序的多次运行而发生变化,但是在程序的任意一次运行过程中,所含的全部线程都必须形成相同的改动序列。 ⏱ 2022-06-16 11:54:07

📌 在不同的线程上观察属于同一个变量的序列,如果所见各异,就说明出现了数据竞争和未定义行为(见5.1.2节) ⏱ 2022-06-16 11:54:24

📌 在程序内部,对于同一个对象,全部线程都必须就其形成相同的改动序列,并且在所有对象上都要求如此 ⏱ 2022-06-16 21:04:35

5.2 C++中的原子操作及其类别

📌 原子操作是不可分割的操作(indivisible operation) ⏱ 2022-06-16 11:55:24

📌 在C++环境中,多数情况下,我们需要通过原子类型实现原子操作。 ⏱ 2022-06-16 21:05:53

5.2.1 标准原子类型

📌 标准原子类型的定义位于头文件内。这些类型的操作全是原子化的 ⏱ 2022-06-16 11:56:19 ^42568675-94-371-408

📌 ,我们可以凭借互斥保护,模拟出标准的原子类型:它们全部(几乎)都具备成员函数is_lock_free() ,准许使用者判定某一给定类型上的操作是能由原子指令(atomic instruction)直接实现(x.is_lock_free()返回true),还是要借助编译器和程序库的内部锁来实现(x.is_lock_free()返回false)。 ⏱ 2022-06-16 11:56:48

📌 原子操作的关键用途是取代需要互斥的同步方式 ⏱ 2022-06-16 11:56:52

📌 针对由不同整数类型特化而成的各种原子类型,在编译期判定其是否属于无锁数据结构 ⏱ 2022-06-16 21:07:00

📌 从C++17开始,全部原子类型都含有一个静态常量表达式成员变量(static constexpr member variable),形如X::is_always_lock_free,功能与那些宏相同:考察编译生成的一个特定版本的程序,当且仅当在所有支持该程序运行的硬件上,原子类型X全都以无锁结构形式实现,该成员变量的值才为true。 ⏱ 2022-06-16 21:07:22

📌 如果多种硬件可以运行该程序,但仅有其中一部分支持这些指令,那么等到运行时才可以确定它是否属于无锁结构, ⏱ 2022-06-16 21:07:46

📌 假设某原子类型从来都不属于无锁结构,那么,对应的宏取值为0;若它一直都属于无锁结构,则宏取值为2;如果像前文所述,等到运行时才能确定该原子类型是否属于无锁结构,就取值为1。 ⏱ 2022-06-16 21:08:11

📌 只有一个原子类型不提供is_lock_free()成员函数:std::atomic_flag。它是简单的布尔标志(boolean flag),因此必须采取无锁操作。 ⏱ 2022-06-16 21:08:25

📌 类型std::atomic_flag的对象在初始化时清零,随后即可通过成员函数test_and_set()查值并设置成立,或者由clear()清零。整个过程只有这两个操作。没有赋值,没有拷贝构造,没有“查值并清零”的操作,也没有任何其他操作。 ⏱ 2022-06-16 21:08:50

📌 其余的原子类型都是通过类模板std::atomic<>特化得出的,功能更加齐全,但可能不属于无锁结构 ⏱ 2022-06-26 19:57:09

📌 由于不具备拷贝构造函数或拷贝赋制操作符,因此按照传统做法,标准的原子类型对象无法复制,也无法赋值。然而,它们其实可以接受内建类型赋值,也支持隐式地转换成内建类型,还可以直接经由成员函数处理,如load()和store()、exchange()、compare_exchange_weak()和compare_exchange_strong() ⏱ 2022-06-16 21:10:38

📌 习惯上,C++的赋值操作符通常返回引用,指向接受赋值的对象,但原子类型的设计与此有别,要防止暗藏错误。否则,为了从引用获得存入的值,代码须执行单独的读取操作,使赋值和读取操作之间存在间隙,让其他线程有机可乘,得以改动该值,结果形成条件竞争。 ⏱ 2022-06-16 21:11:11

📌 枚举类std::memory_order具有6个可能的值,包括std::memory_order_relaxed、std:: memory_order_acquire、std::memory_order_consume、std::memory_order_acq_rel、std::memory_order_release和 std::memory_order_seq_cst。 ⏱ 2022-06-16 21:11:42

📌 操作的类别决定了内存次序所准许的取值。若我们没有把内存次序显式设定成上面的值,则默认采用最严格的内存次序,即std::memory_order_seq_cst。 ⏱ 2022-06-16 21:11:52

5.2.2 操作std::atomic_flag

📌 std::atomic_flag类型的对象必须由宏ATOMIC_FLAG_INIT初始化,它把标志初始化为置零状态:std::atomic_flag f=ATOMIC_FLAG_INIT。 ⏱ 2022-06-16 21:12:34

📌 全部原子类型中,只有std::atomic_flag必须采取这种特殊的初始化处理,它也是唯一保证无锁的原子类型。 ⏱ 2022-06-16 21:12:47

📌 正因为std::atomic_flag功能有限,所以它可以完美扩展成自旋锁互斥(spin-lockmutex) ⏱ 2022-06-16 21:23:44

5.2.3 操作std::atomic

📌 原子类型的又一个常见模式:它们所支持的赋值操作符不返回引用,而是按值返回 ⏱ 2022-06-16 21:25:14

📌 store()是存储操作,而load()是载入操作,但exchange()是“读-改-写”操作。 ⏱ 2022-06-16 21:25:53

📌 这一新操作被称为“比较-交换”(compare-exchange),实现形式是成员函数compare_exchange_weak()和compare_exchange_strong()。比较-交换操作是原子类型的编程基石。使用者给定一个期望值,原子变量将它和自身的值比较,如果相等,就存入另一既定的值;否则,更新期望值所属的变量,向它赋予原子变量的值。比较-交换函数返回布尔类型,如果完成了保存动作(前提是两值相等),则操作成功,函数返回ture;反之操作失败,函数返回false。 ⏱ 2022-06-16 21:26:36

📌 对于compare_exchange_weak(),即使原子变量的值等于期望值,保存动作还是有可能失败,在这种情形下,原子变量维持原值不变,compare_exchange_weak()返回false。 ⏱ 2022-06-16 21:26:46

📌 佯败(spurious failure) ⏱ 2022-06-16 21:27:18

📌 compare_exchange_weak()可能佯败,所以它往往必须配合循环使用。 ⏱ 2022-06-16 21:27:21

📌 比较-交换函数还有一个特殊之处:它们接收两个内存次序参数。这使程序能区分成功和失败两种情况,采用不同的内存次序语义。合适的做法是:若操作成功,就采用std::memory_order_acq_rel内存次序,否则改用std::memory_order_relaxed内存次序。 ⏱ 2022-06-16 21:28:55

读书笔记

3.2.1 在C++中使用互斥

划线评论

📌 所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门。 ^15826765-7zUQ5QpT0 - 💭 get release? - ⏱ 2022-06-10 20:56:28

本书评论