第一章 你好,C++的并发世界
-
计算机系统中的并发:进行上下文的切换时,操作系统必须为当前运行的任务保存CPU状态和指令指针,并计算出要切换到哪个任务,并为即将切换到的任务重新加载处理器状态。然后CPU可能要将新任务的指令和数据的内存载入到缓存中,这会阻止CPU执行任何指令,从而造成的多的延迟。
-
多进程并发:
- 操作系统在进程间提供附加的保护操作和更高级别的通信机制意味着可以更容易编写安全的并发代码。
- 使用多进程实现并发还有一个额外的优势———可以使用远程连接(可能需要联网)的方式,在不同机器上运行独立的进程。虽然,这增加了通信成本,但在设计精良的系统上,这可能是一个提高并可用行和性能的低成本方式。
- 多个单线程/进程间的通信(包含启动)要比单一进程中的多线程间的通信(包括启动)的开销大。
-
为什么使用并发:
- 为了分离关注点。
- 为了性能。将一个单个任务分成几部分,且各自并行运行,从而降低总运行时间。这就是任务并行(task parallelism)。
-
开始入门:新的线程启动之后,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运行main()的结束,从而结束程序——有可能发生在新线程运行之前。
第二章 线程管理
线程管理的基础
-
使用C++线程库启动线程,可以归结为构造std::thread对象。
-
std::thread可以用可调用类型构造,将带有函数调用符类型的实例传入std::thread类中,替换默认的构造函数。
-
提供的函数对象会复制到新线程的存储空间当中,函数对象的执行和调用都在线程的内存空间中进行。函数对象的副本应与原始函数对象保持一致,否则得到的结果会与我们的期望不同。
-
如果你传递了一个临时变量,而不是一个命名的变量;C++编译器会将其解析为函数声明,而不是类型对象的定义。
使用在前面命名函数对象的方式,或使用多组括号,或使用新统一的初始化语法,可以避免这个问题。
使用lambda表达式也能避免这个问题。
-
std::thread的析构函数会调用std::terminate()。
-
如果不等待线程,就必须保证线程结束之前,可访问的数据得有效性。
处理方法:将数据复制到线程中,而非复制到共享数据中,对于对象中包含的指针和引用还需谨慎。
-
只能对一个线程使用一次join(),一旦使用过join(), std::thread 对象就不能再次汇入了。当对其使用joinable()时,将返回false。
-
避免应用被抛出的异常所终止。通常,在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题。
-
调用 std::thread 成员函数detach()来分离一个线程。之后,相应的 std::thread 对象就与实际执行的线程无关了,并且这个线程也无法汇入。
-
当 std::thread 对象使用t.joinable()返回的是true,就可以使用t.detach()。
-
不仅可以向 std::thread 构造函数传递函数名,还可以传递函数所需的参数(实参)。当然,也有其他方法可以完成这项功能,比如:使用带有数据的成员函数,代替需要传参的普通函数。
传递参数
- 这些参数会拷贝至新线程的内存空间中(同临时变量一样)。即使函数中的参数是引用的形式,拷贝操作也会执行。
- 无法保证隐式转换的操作和 std::thread 构造函数的拷贝操作的顺序,解决方案就是在传递到 std::thread 构造函数之前,就将字面值转化为 std::string。
- 使用std::ref传递std::thread回调函数中的左值引用。(为了只支持移动的类型,thread内部以右值为实参调用回调函数,编译报错)
- 也可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数。
- 使用移动操作可以将对象转换成函数可接受的实参类型,或满足函数返回值类型要求。
- 线程的所有权可以在多个 std::thread 实例中转移,这依赖于 std::thread 实例的可移动且不可复制性。不可复制性表示在某一时间点,一个 std::thread 实例只能关联一个执行线程。可移动性使得开发者可以自己决定,哪个实例拥有线程实际执行的所有权。
- 赋值给一个已经有关联线程的std::thread,系统直接调用 std::terminate() 终止程序继续运行。
- 不能通过赋新值给 std::thread 对象的方式来"丢弃"一个线程。
- 如果这个容器是移动敏感的(比如,标准中的 std::vector<> ),那么移动操作同样适用于这些容器。
- 函数模板std :: mem_fn生成指向成员的指针的包装对象,该对象可以存储,复制和调用指向成员的指针。 调用std :: mem_fn时,可以使用对象的引用和指针(包括智能指针)。
第三章 共享数据
-
C++标准库为互斥量提供了RAII模板类 std::lock_guard ,在构造时就能提供已锁的互斥量,并在析构时进行解锁,从而保证了互斥量能被正确解锁。
-
C++17中添加了一个新特性,称为模板类参数推导,类似 std::lock_guard 这样简单的模板类型,其模板参数列表可以省略。
-
一个指针或引用,也会让这种保护形同虚设。切勿将受保护数据的指针或引用传递到互斥锁作用域之外。
-
对于有返回值的pop()函数来说,只有“异常安全”方面的担忧(当拷贝构造函数在栈中抛出一个异常)。
解决方案:
- 将变量的引用作为参数。有些类型可能不支持赋值操作。
- 无异常抛出的拷贝构造函数或移动构造函数(意义就在此)。有抛出异常的拷贝构造函数往往更多。
- 返回指向弹出值的指针(shared_ptr)。对于简单数据类型(比如:int),内存管理的开销要远大于直接返回值。
-
避免死锁的一般建议,就是让两个互斥量以相同的顺序上锁。
-
std::lock ——可以一次性锁住多个互斥量,并且没有副作用(死锁风险)。
-
std::scoped_lock<> 是一种新的RAII模板类型,与 std::lock_guard<> 的功能相同,这个新类型能接受不定数量的互斥量类型作为模板参数,以及相应的互斥量(数量和类型)作为构造参数。互斥量支持构造时上锁,与 std::lock 的用法相同,解锁在析构中进行。
-
避免死锁的进阶指导:
- 避免嵌套锁。
- 避免在持有锁时调用外部代码。
- 使用固定顺序获取锁。
- 使用层次锁结构。当代码试图对互斥量上锁,而低层已持有该层锁时,不允许锁定。因此锁的顺序只能先锁层级高的锁再锁层级低的锁。
-
std::unique_lock 实例不会总与互斥量的数据类型相关,使用起来要比 std:lock_guard 更加灵活。 std::unique_lock 会占用比较多的空间,并且比 std::lock_guard 稍慢一些(需要维护锁的状态)。当实例中没有互斥量时,析构函数就不能去调用unlock(),这个标志可以通过owns_lock()成员变量进行查询。 std::unique_lock 是可移动,但不可赋值的类型。
-
一般情况下,尽可能将持有锁的时间缩减到最小。
-
双重检查锁模式:解决Singleton实际上只有第一次实例创建的时候才需要加锁。new operator和reset可能发生指令重排,不安全。C++标准库提供std::once_flag 和 std::call_once 来处理这种情况。
-
用
std::unique_lock
与std::lock_guard
管理排他性锁定。用
std::shared_lock
管理共享锁定。
第四章 同步操作
- std::condition_variable_any 更加通用,不过在性能和系统资源的使用方面会有更多的开销,所以通常会将 std::condition_variable 作为首选类型。wait传递谓词可避免spurious wake-up calls(相当于while),虚假唤醒的一个可能性是条件变量的等待被信号中断。
- std::future提供访问异步操作结果的机制。std::future 只能与指定事件相关联,而 std::shared_future 就能关联多个事件。future对象本身并不提供同步访问。future的get()函数的设计包含移动语义,即只能调用一次,第二次调用时会报异常。shared_future的get()函数的设计包含复制语义,可以多次调用。std::shared_future对象可以通过std::future对象隐式转换,也可以通过显示调用std::future::share显示转换,在这两种情况下,原std::future对象都将变得无效。
- 当不着急让任务结果时,可以使用 std::async 启动一个异步任务。std::async 会返回一个 std::future 对象。get()等价与先调用wait()再调用get()。 std::launch::defered 表明函数调用延迟到wait()或get()函数调用时才执行,std::launch::async 表明函数必须在其所在的独立线程上执行。当函数调用延迟,可能不会再运行。
- std::packaged_task<> 会将future与函数或可调用对象进行绑定。当 std::packaged_task 作为函数调用时,实参将由函数调用操作符传递至底层函数,并且返回值作为异步结果存储在 std::future 中。
- std::promise/std::future 对提供一种机制:future可以阻塞等待线程,提供数据的线程可以使用promise对相关值进行设置,并将future的状态置为“就绪”。
- 任何情况下,当future的状态还不是“就绪”时,调用 std::promise 或 std::packaged_task 的析构函数,将会存储一个与 std::future_errc::broken_promise 错误状态相关的 std::future_error 异常。
- 当调用抛出一个异常时,这个异常就会存储到future中,之后调用get()会抛出已存储的异常。
- std::current_exception() 来检索抛出的异常,可用 std::copy_exception() 作为替代方案, std::copy_exception() 会直接存储新的异常而不抛出。
- 因为 std::future 是只移动的,所以其所有权可以在不同的实例中互相传递,但只有一个实例可以获得特定的同步结果,而 std::shared_future 实例是可拷贝的,所以多个对象可以引用同一关联期望值的结果。
- 为了在多个线程访问一个独立对象时避免数据竞争,必须使用锁来对访问进行保护。当每个线程都通过自己拥有的 std::shared_future 对象获取结果,那么多个线程访问共享同步结果就是安全的。
- 函数化编程(functional programming)是一种编程方式,函数结果只依赖于传入函数的参数。使用相同的参数调用函数,不管多少次都会获得相同的结果,主要思想是把运算过程尽量写成一系列嵌套的函数调用。函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。
第五章 内存模型和原子架构
-
四个需要牢记的原则:
- 每个变量都是对象,包括其成员变量的对象。
- 每个对象至少占有一个内存位置。
- 基本类型都有确定的内存位置(无论类型大小如何,即使他们是相邻的,或是数组的一部分)。
- 相邻位域是相同内存中的一部分。
-
标准原子类型的实现可能是这样的:它们(几乎)都有一个 is_lock_free() 成员函数,这个函数可以让用户查询某原子类型的操作是直接用的原子指令( x.is_lock_free() 返回 true ),还是内部用了一个锁结构( x.is_lock_free() 返回 false )。
-
如果操作内部使用互斥量实现,那么不可能有性能的提升。所以要对原子操作进行实现,最好使用不基于互斥量的实现。
-
std::atomic_flag都是无锁的,可以使用该类型实现一个简单的锁。
-
对于 std::atomic 模板,使用相应的T类型去特化模板的方式,要好于使用别名的方式。
-
通常,标准原子类型不能进行拷贝和赋值,它们没有拷贝构造函数和拷贝赋值操作符。但是,可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值。
-
如果想在并行编程中获得更好的性能,设定原子操作间的内存顺序则很有必要。
-
保证执行顺序会牺牲一些执行效率,因为这意味着放弃了编译器、处理器等的优化处理。
-
强顺序的内存模型指: 代码顺序和寄存器实际执行的顺序一致。
弱顺序的内存模型指: 寄存器实际执行的顺序与代码顺序不一致,被处理器调整过。
-
对于弱顺序内存模型的平台,如果要保证指令执行的顺序,通常需要加入内存栅栏指令,该指令迫使已经进入流水线中的指令都完成后处理器才执行sync以后的指令(对性能影响很大)。
-
store()是一个存储操作,而load()是一个加载操作,exchange()是一个“读-改-写”操作。
-
“比较/交换”操作是原子类型编程的基石,它比较原子变量的当前值和期望值,当两值相等时,存储所提供值,不等时,用实际值替换期待值。
-
compare_exchange_weak()可能发生伪失败(线程的操作执行到必要操作的中间时被切换),相比strong可能有更好的性能。
-
原子操作的非成员函数的设计是为了与C语言兼容。
-
atomic默认内存序列为memory_order_seq_cst(顺序一致性)。
-
使用memory_order_acq_rel语义的“读-改-写”操作,每一个动作都包含获取和释放操作,所以可以和之前的存储操作进行同步,并且可以对随后的加载操作进行同步。
-
如果将获取-释放和序列一致进行混合,“序列一致”的加载动作就如使用了获取语义的加载操作,序列一致的存储操作就如使用了释放语义的存储,“序列一致”的读-改-写操作行为就如使用了获取和释放的操作。
-
锁住互斥量是一个获取操作,并且解锁这个互斥量是一个释放操作。
-
实际操作中,应该使用memory_order_acquire,而不是memory_order_consume和 std::kill_dependency 。
-
有时,不想为携带依赖增加其他开销。想使用编译器在寄存器中缓存这些值,以及优化重排序操作代码。可以使用 std::kill_dependecy() 显式打破依赖链, std::kill_dependency() 是一个简单的函数模板,会复制提供的参数给返回值。
-
栅栏属于全局操作,执行栅栏操作可以影响到在线程中的其他原子操作。
-
不仅是栅栏可对非原子操作排序,memory_order_release/memory_order_consume也为非原子访问排序。
第六章 设计基于锁的并发数据结构
- 可以使用“预分配虚拟节点(无数据),确保这个节点永远在队列的最后,用来分离头尾指针能访问的节点”的办法来实现细粒度锁的线程安全队列(防止head = tail)。
- 和队列和栈一样,标准容器的接口不适合多线程进行并发访问。
- 哈希表:假设有固定数量的桶,每个桶都有一个键值(关键特性),以及散列函数。这就意味着你可以安全的对每个桶上锁。当再次使用互斥量(支持多读者单作者)时,就能将并发访问的可能性增加N倍,这里N是桶的数量。
- 哈希表在有质数个桶时(默认19),工作效率最高。每一个桶都会被一个 std::shared_mutex ①实例锁保护,对于每一个桶只有一个线程能对其进行修改。
- 因为桶的数量固定,所以get_bucket()⑦可以无锁调用。
- 迭代器的问题在于,STL类的迭代器需要持有容器内部引用。
第七章 设计无锁的并发数据结构
- 使用无锁结构的主要原因:最大化并发。使用基于锁的容器,会让线程阻塞或等待,并且互斥锁削弱了结构的并发性。无锁数据结构中,某些线程可以逐步执行。无等待数据结构中,每一个线程都可以独自向前运行,这种理想的方式实现起来很难。结构太简单,反而不容易实现。
- 使用无锁数据结构的第二个原因就是鲁棒性。当一个线程在持有锁时被终止,那么数据结构将会永久性的破坏。不过,当线程在无锁数据结构上执行操作,在执行到一半终止时,数据结构上的数据没有丢失(除了线程本身的数据),其他线程依旧可以正常执行。
- 仅使用原子操作是不够的,需要确定其他线程看到的修改,是否遵循正确的顺序。
- “无锁-无等待”代码的缺点:虽然提高了并发访问的能力,减少了单个线程的等待时间,但是其可能会将整体性能拉低。首先,原子操作的无锁代码要慢于无原子操作的代码,原子操作就相当于无锁数据结构中的锁。不仅如此,硬件必须通过同一个原子变量对线程间的数据进行同步。
第八章 并发设计
-
对数据进行预处理划分:一项任务被分割成多个,放入一个并行任务集中,执行线程独立的执行这些任务,结果在主线程中合并。
-
递归划分:使用 std::async() 时,C++线程库就能决定何时让一个新线程执行任务,并对任务进行同步。
-
划分任务序列:当任务会应用到相同操作序列,去处理独立的数据项时,就可以使用流水线(pipeline)系统进行并发。通过对线程间任务的划分,就能对应用的性能有所改善。
-
整批处理的时间要长于流水线。
-
使用 std::thread::hardware_concurrency() 需要谨慎,因为不会考虑其他应用已使用的线程数量(除非已经将系统信息进行共享)。 std::async() 可以避免这个问题,标准库会对所有调用进行安排。同样,谨慎的使用线程池也可以避免这个问题。
-
当两个线程在不同处理器上时,对同一数据进行读取,通常不会出现问题。因为数据会拷贝到每个线程的缓存中,并让两个处理器同时进行处理。当有线程对数据进行修改,并且需要更新到其他核芯的缓存中去,就要耗费一定的时间。这样的修改可能会让第二个处理器停下来,等待硬件内存更新缓存中的数据。根据CPU指令,这是一个特别特别慢的操作。
-
循环中counter的数据将在每个缓存中传递若干次,这就是乒乓缓存(cache ping-pong),这会对应用的性能有着重大的影响。
-
原子操作与互斥锁的区别:
互斥锁是一种数据结构,使你可以执行一系列互斥操作。而原子操作是互斥的单个操作,这意味着没有其他线程可以打断它。
首先
atomic
操作的优势是更轻量,比如CAS
可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。原子操作也有劣势。还是以
CAS
操作为例,使用CAS
操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS
操作并不那么容易成功。而使用互斥锁的做法则趋于悲观,我们总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。 -
同一缓存行存储的是无关数据时,且需要被不同线程访问,这就会造成性能问题。
-
C++17标准在头文件 中定义了 std::hardware_destructive_interference_size 它指定了当前编译目标可能共享的连续字节的最大数目。如果确保数据间隔大于等于这个字节数,就不会有错误的共享存在了。将所需的数据大小控制在这个字节数内,就能提高缓存命中率。
-
当为多线程性能设计数据结构时,需要考虑竞争(contention),伪共享(false sharing)和邻近数据(dataproximity)。
-
尝试调整数据在线程间的分布,让同一线程中的数据紧密联系在一起。尝试减少线程上所需的数据量。尝试让不同线程访问不同的存储位置,以避免伪共享。
-
一种测试伪共享问题的方法:填充大量的数据块,让不同线程并发访问。
-
互斥锁是作为“读-改-写”原子操作实现的,对于相同位置的操作都需要先获取互斥量,如果互斥量已锁,就会调用系统内核。这种“读-改-写”操作可能会让数据存储在缓存中,让线程获取的互斥量变得毫无作用。从目前互斥量的发展来看,这并不是个问题,因为线程不会直到互斥量解锁才接触互斥量。当互斥量共享同一缓存行时,其中存储的是线程已使用的数据,这时拥有互斥量的线程将会遭受到性能打击,因为其他线程也在尝试锁住互斥量。
-
std::packaged_task 和 std::future 是线程安全的,所以可以用来对结果进行转移。
-
使用std::future的优势之一是调用者有机会感知到工作线程抛出的异常(通过get抛出)。
-
如果不止一个工作线程抛出异常,那么只有一个异常能在主线程中抛出。如果这个问题很重要,可以使用类似 std::nested_exception 对所有抛出的异常进行捕捉。
-
对于异常安全,还需要注意一件事,如果没有等待的情况下对future实例进行销毁,析构函数会等待对应线程执行完毕后才执行。(经过测试会抛出std::future_error)
-
标准库能保证 std::async 的调用能够充分的利用硬件线程,并且不会产生线程的超额申请。
-
std::promise.set_val():如果没有共享状态或共享状态已存储值或异常,则抛出异常。
第九章 高级线程管理
- 不过 std::packaged_task<> 实例是不可拷贝的,仅可移动,所以不能再使用 std::function<> 来实现任务队列,因为 std::function<> 需要存储可复制构造的函数对象。
- 为了避免乒乓缓存,每个线程建立独立的任务队列。这样,每个线程就会将新任务放在自己的任务队列上,并且当线程上的任务队列没有任务时,去全局的任务列表中取任务。不过当任务分配不均时,造成的结果就是:某个线程本地队列中有很多任务的同时,其他线程无所事事。
第十章 并行算法
-
C++17为标准库添加并行算法。是对之前已存在的一些标准算法的重载,增加指定执行策略。
-
将执行策略传递给标准算法库中的算法,算法的行为就由执行策略控制。这会有几方面的影响:
-
算法复杂度
-
抛出异常时的行为
-
算法执行的位置、方式和时间
-
-
如果有异常未捕获,标准执行策略都会调用 std::terminate 。
-
有执行策略和没有执行策略的函数列表间有一个重要的区别,会影响到一些算法:如果“普通”算法允许输入迭代器或输出迭代器,那执行策略的重载则需要前向迭代器。
-
输入流迭代器不仅会改变它所指向的元素(在引用时得到的结果),也会改变底层流中确定下一次读操作从哪里开始的位置。我们无法生成两个指向同一个流中两个不同值的流迭代器。(而前向迭代器可以多次遍历)
-
std::execution::seq
使算法在单个线程中以确定性顺序执行,即不并行且不并发。std::execution::par
使算法在多个线程中执行,并且线程各自具有自己的顺序任务。即并行但不并发。std::execution::par_unseq
使算法在多个线程中执行,并且线程可以具有并发的多个任务。即并行和并发。 -
std::execution::par是最常使用的策略,除非实现提供了更适合的非标准策略。某些情况下,可以使用std::execution::par_unseq代替。这可能根本没什么用(没有任何标准的执行策略可以保证能达到并行性的级别),但它可以给库额外的空间,通过重新排序和交错任务执行来提高代码的性能,以换取对代码更严格的要求。更严格的要求中值得注意的是,访问元素或对元素执行操作时不使用同步。这意味着不能使用互斥量或原子变量,或前面章节中描述的任何其他同步机制,以确保多线程的访问是安全的(可能导致死锁)。相反,必须依赖于算法本身,而不是使用多个线程访问同一个元素,在调用并行算法外使用外部同步,从而避免其他线程访问数据。(经过测试,并没有什么问题)