计算机系统中的并发:进行上下文的切换时,操作系统必须为当前运行的任务保存CPU状态和指令指针,并计算出要切换到哪个任务,并为即将切换到的任务重新加载处理器状态。然后CPU可能要将新任务的指令和数据的内存载入到缓存中,这会阻止CPU执行任何指令,从而造成的多的延迟。
多进程并发:
为什么使用并发:
开始入门:新的线程启动之后,初始线程继续执行。如果它不等待新线程结束,它就将自顾自地继续运行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 构造函数传递函数名,还可以传递函数所需的参数(实参)。当然,也有其他方法可以完成这项功能,比如:使用带有数据的成员函数,代替需要传参的普通函数。
C++标准库为互斥量提供了RAII模板类 std::lock_guard ,在构造时就能提供已锁的互斥量,并在析构时进行解锁,从而保证了互斥量能被正确解锁。
C++17中添加了一个新特性,称为模板类参数推导,类似 std::lock_guard 这样简单的模板类型,其模板参数列表可以省略。
一个指针或引用,也会让这种保护形同虚设。切勿将受保护数据的指针或引用传递到互斥锁作用域之外。
对于有返回值的pop()函数来说,只有“异常安全”方面的担忧(当拷贝构造函数在栈中抛出一个异常)。
解决方案:
避免死锁的一般建议,就是让两个互斥量以相同的顺序上锁。
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
管理共享锁定。
四个需要牢记的原则:
标准原子类型的实现可能是这样的:它们(几乎)都有一个 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也为非原子访问排序。
对数据进行预处理划分:一项任务被分割成多个,放入一个并行任务集中,执行线程独立的执行这些任务,结果在主线程中合并。
递归划分:使用 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():如果没有共享状态或共享状态已存储值或异常,则抛出异常。
C++17为标准库添加并行算法。是对之前已存在的一些标准算法的重载,增加指定执行策略。
将执行策略传递给标准算法库中的算法,算法的行为就由执行策略控制。这会有几方面的影响:
算法复杂度
抛出异常时的行为
算法执行的位置、方式和时间
如果有异常未捕获,标准执行策略都会调用 std::terminate 。
有执行策略和没有执行策略的函数列表间有一个重要的区别,会影响到一些算法:如果“普通”算法允许输入迭代器或输出迭代器,那执行策略的重载则需要前向迭代器。
输入流迭代器不仅会改变它所指向的元素(在引用时得到的结果),也会改变底层流中确定下一次读操作从哪里开始的位置。我们无法生成两个指向同一个流中两个不同值的流迭代器。(而前向迭代器可以多次遍历)
std::execution::seq
使算法在单个线程中以确定性顺序执行,即不并行且不并发。
std::execution::par
使算法在多个线程中执行,并且线程各自具有自己的顺序任务。即并行但不并发。
std::execution::par_unseq
使算法在多个线程中执行,并且线程可以具有并发的多个任务。即并行和并发。
std::execution::par是最常使用的策略,除非实现提供了更适合的非标准策略。某些情况下,可以使用std::execution::par_unseq代替。这可能根本没什么用(没有任何标准的执行策略可以保证能达到并行性的级别),但它可以给库额外的空间,通过重新排序和交错任务执行来提高代码的性能,以换取对代码更严格的要求。更严格的要求中值得注意的是,访问元素或对元素执行操作时不使用同步。这意味着不能使用互斥量或原子变量,或前面章节中描述的任何其他同步机制,以确保多线程的访问是安全的(可能导致死锁)。相反,必须依赖于算法本身,而不是使用多个线程访问同一个元素,在调用并行算法外使用外部同步,从而避免其他线程访问数据。(经过测试,并没有什么问题)