这一部分没有太认真看,因为没有基础,看的云里雾里的。等着把 c++并发编程看了再优化这部分笔记

条款35 优先选用基于任务而非基于线程的程序设计

如果开发者想异步执行doAsyncWork函数,通常有两种方式:

  • 通过创建std::thread执行,基于线程
  1. int doAsyncWork();
  2. std::thread t(doAsyncWork);
  • doAsyncWork传递给std::async,基于任务
  1. auto fut = std::async(doAsyncWork); //fut是一个future类型的变量

这种方式中,传递给std::async的函数对象被称为一个任务

基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设调用doAsyncWork的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法就简单了,因为std::async返回的future提供了get函数(从而可以获取返回值)。如果doAsycnWork发生了异常,get函数就显得更为重要,因为get函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork抛出了异常,程序会直接终止(通过调用std::terminate)。

C++的thread有三种含义:

  • 硬件线程(hardware threads)是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
  • 软件线程(software threads)(也被称为系统线程(OS threads、system threads))是操作系统(假设有一个操作系统。有些嵌入式系统没有。)管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程,因为当软件线程被阻塞的时候(比如 I/O、同步锁或者条件变量),操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
  • **std::thread** 是C++执行过程的对象,并作为软件线程的句柄(handle)。有些std::thread对象代表“空”句柄,即没有对应软件线程,因为它们处在默认构造状态(即没有函数要执行);有些被移动走(移动到的std::thread就作为这个软件线程的句柄);有些被join(它们要运行的函数已经运行完);有些被detach(它们和对应的软件线程之间的连接关系被打断)。

如果试图创建大于系统支持的线程数量,会抛出std::system_error异常。

即使没有超出软件线程的限额,仍然可能会遇到资源超额oversubscription)的麻烦。这是一种当前准备运行的(即未阻塞的)软件线程大于硬件线程的数量的情况。情况发生时,线程调度器(操作系统的典型部分)会将软件线程时间切片,分配到硬件上。当一个软件线程的时间片执行结束,会让给另一个软件线程,此时发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销,当软件线程安排到与上次时间片运行时不同的硬件线程上,这个开销会更高。这种情况下,(1)CPU缓存对这个软件线程很冷淡(即几乎没有什么数据,也没有有用的操作指南);(2)“新”软件线程的缓存数据会“污染”“旧”线程的数据,旧线程之前运行在这个核心上,而且还有可能再次在这里运行。

  1. auto fut = std::async(doAsyncWork);

这种调用方式将线程管理交给了标准库。它使用默认启动策略,允许通过调度器将特定函数运行在等待此函数结果的线程上(即在对fut调用get或者wait的线程上)。

image.png

条款36 如果有异步的必要请指定std::launch::async

std::launch有两种启动策略,都通过std::launch这个限域enum的一个枚举名来表示。

  • **std::launch::async**启动策略意味着f必须异步执行,即在不同的线程。
  • **std::launch::deferred**启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当getwait被调用,f会同步执行,即调用方被阻塞,直到f运行结束。如果getwait都没有被调用,f将不会被执行。(这是个简化说法。关键点不是要在其上调用getwait的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future支持移动,也可以用来构造std::shared_future,并且因为std::shared_future可以被拷贝,对共享状态——对f传到的那个std::async进行调用产生的——进行引用的future对象,有可能与std::async返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async返回的future上调用getwait。)

如果不显式指定策略的话,默认的启动策略并不是上述的任意一个,而是|的关系

  1. auto fut = std::async(f); //使用默认启动策略运行f
  • 无法预测**f**是否会与**t**并发运行,因为f可能被安排延迟运行。
  • 无法预测**f**是否会在与某线程相异的另一线程上执行,这个某线程在**fut**上调用**get****wait**。如果对fut调用函数的线程是t,含义就是无法预测f是否在异于t的另一线程上执行。
  • 无法预测**f**是否执行,因为不能确保在程序每条路径上,都会不会在fut上调用get或者wait

默认启动策略的调度灵活性导致使用thread_local变量比较麻烦,如果f读取了线程本地存储,不能预测到哪个线程的变量被访问:

  1. auto fut = std::async(f); //f的TLS可能是为单独的线程建的,
  2. //也可能是为在fut上调用get或者wait的线程建的

image.png

条款37 使std::thread在所有路径最后都不可结合

每个std::thread对象处于两个状态之一:可结合的joinable)或者不可结合的unjoinable)。

可结合状态的std::thread对应于正在运行或者可能要运行的异步执行线程。

不可结合的std::thread对象包括:

  • 默认构造的**std::thread**s。这种std::thread没有函数执行,因此没有对应到底层执行线程上。
  • 已经被移动走的**std::thread**对象。移动的结果就是一个std::thread原来对应的执行线程现在对应于另一个std::thread
  • 已经被**join****std::thread** 。在join之后,std::thread不再对应于已经运行完了的执行线程。
  • 已经被**detach****std::thread**detach断开了std::thread对象与执行线程之间的连接。

可结合的std::thread析构会终止程序。C++委员会认为销毁可结合的线程的后果非常严重:

  • 隐式**join** 。这种情况下,std::thread的析构函数将等待其底层的异步执行线程完成。这听起来是合理的,但是可能会导致难以追踪的异常表现。比如,如果conditonAreStatisfied()已经返回了falsedoWork继续等待过滤器应用于所有值就很违反直觉。
  • 隐式**detach** 。这种情况下,std::thread析构函数会分离std::thread与其底层的线程。底层线程继续运行。听起来比join的方式好,但是可能导致更严重的调试问题。比如,在doWork中,goodVals是通过引用捕获的局部变量。它也被lambda修改(通过调用push_back)。假定,lambda异步执行时,conditionsAreSatisfied()返回false。这时,doWork返回,同时局部变量(包括goodVals)被销毁。栈被弹出,并在doWork的调用点继续执行线程。
    调用点之后的语句有时会进行其他函数调用,并且至少一个这样的调用可能会占用曾经被doWork使用的栈位置。我们调用那么一个函数f。当f运行时,doWork启动的lambda仍在继续异步运行。该lambda可能在栈内存上调用push_back,该内存曾属于goodVals,但是现在是f的栈内存的某个位置。这意味着对f来说,内存被自动修改了!想象一下调试的时候“乐趣”吧。

所以要自行保证std::thread在离开作用域时是不可结合的。**最通用的办法就是将该操作放入局部对象的析构函数中:

  1. class ThreadRAII {
  2. public:
  3. enum class DtorAction { join, detach }; //enum class的信息见条款10
  4. ThreadRAII(std::thread&& t, DtorAction a) //析构函数中对t实行a动作
  5. : action(a), t(std::move(t)) {}
  6. ~ThreadRAII()
  7. { //可结合性测试见下
  8. if (t.joinable()) {
  9. if (action == DtorAction::join) {
  10. t.join();
  11. } else {
  12. t.detach();
  13. }
  14. }
  15. }
  16. std::thread& get() { return t; } //见下
  17. private:
  18. DtorAction action;
  19. std::thread t;
  20. };

该构造器只支持std::thread右值,因此要移动进来(因为std::thread不可以复制)。

image.png

条款38 关注不同线程句柄的析构行为

因为与被调用者关联的对象和与调用者关联的对象都不适合存储被调用者的结果,所以必须存储在两者之外的共享状态。通常是基于堆的对象。

image.png

共享状态的存在非常重要,因为future的析构函数取决于与future关联的共享状态:

  • 引用了共享状态——使用**std::async**启动的未延迟任务建立的那个——的最后一个future的析构函数会阻塞住,直到任务完成。本质上,这种future的析构函数对执行异步任务的线程执行了隐式的join
  • 其他所有future的析构函数简单地销毁future对象。对于异步执行的任务,就像对底层的线程执行detach。对于延迟任务来说如果这是最后一个future,意味着这个延迟任务永远不会执行了。

image.png

条款39 对于一次性事件通信考虑使用voidfutures

没看懂…

条款40 对于并发使用std::atomic,对于特殊内存使用volatile

  • 一旦std::atomic对象被构建,其上的操作表现的像操作是在互斥锁保护的关键区内。
  1. std::atomic<int> ai(0); //初始化ai为0
  2. ai = 10; //原子性地设置ai为10
  3. std::cout << ai; //原子性地读取ai的值
  4. ++ai; //原子性地递增ai到11
  5. --ai; //原子性地递减ai到10

volatile 是告诉编译器不要对这块内存执行任何优化

  1. volatile int x;
  2. auto y = x; //读x
  3. y = x; //再次读x(不会被优化掉)
  4. x = 10; //写x(不会被优化掉)
  5. x = 20; //再次写x

std::atomic的拷贝和移动操作都被禁止了。所以要使用std::atomicloadstore成员函数。load原子性的读取,store原子性的写入。

  1. std::atomic<int> x(10);
  2. std::atomic<int> y(x.load()); //读x
  3. y.store(x.load()); //再次读x

image.png