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

  1. 创建std::thread然后在其上运行函数对象从而以异步方式运行函数对象的方式称为基于线程的途径,而将函数对象传递给std::async的方式称为基于任务的途径,此时函数对象被看做任务。
  2. 基于任务的方法通常要比基于线程实现的对应版本要好,因为基于线程的版本无法直截了当的获取函数的返回值,但是使用std::async返回的期值提供了get函数。在异常处理方面,如果调用的函数发生异常,get函数能访问到该异常,而如果使用基于线程的途径,函数抛出异常,程序就会中断(调用std::terminate)。
  3. 线程在C++中的三种意义:
    (1)硬件线程是实际执行运算的线程,计算机会为每个cpu内核提供一个或多个硬件线程。
    (2)软件线程(系统线程)是操作系统用以实施跨进程管理,以及进行硬件线程调度的线程。通常能够创建的软件线程会比硬件线程要多,因为当软件线程阻塞时,运行另外的非阻塞线程能够提升系统吞吐量。
    (3)std::thread是C++进程里的对象,用作底层软件线程的句柄,有些std::thread对象表示为“null”句柄,对应“无软件线程”。
  4. 软件线程是一种有限的资源,如果我们试图创建的线程数量多于系统能够提供的数量时,就会抛出std::system_error异常,即使待运行函数不能抛出异常(带有noexcept饰词)也会抛出异常。
  5. 即使没有用尽线程,系统可能还是会出现超订问题:就绪状态的软件线程超过了硬件线程的数量,这种情况下线程调度器会为软件线程在硬件线程上分配cpu时间片。当一个线程的时间片用完时,切换到另一个线程时会执行语境切换,语境切换会增加系统的总体线程管理开销。
  6. 避免线程用尽以及超订问题是困难的,std::async就能帮我们解决这一问题。调用std::async时,系统不保证会创建一个新的软件线程,相反它允许调度器把指定函数运行在请求这个函数运行结果的线程中(对std::async返回期值调用get或wait的线程),如果系统发生了超订或线程耗尽,合理的调度器就可以利用这个自由度。
  7. 相比较基于线程编程,基于任务的设计能够分担我们手工管理线程的艰辛,并且它能够让我们很简单地可以检查异步执行函数的结果(返回值或异常)。
  8. 虽然基于任务的设计有诸多好处,但是在以下场景下还是直接使用线程会更加合适:
    (1)需要访问底层线程实现的API:std::thread通常会提供native_handle成员函数来访问底层线程实现的API,但是std::async的返回值类型std::future并没有该功能
    (2)需要且有能力为自己的应用优化线程用法。 (3)需要实现超越C++并发API的线程技术,比如实现线程池

如果异步是必要的,则指定std::launch::async

  1. 仅仅调用std::async,函数不一定会以异步方式运行,因为此时采用的是默认启动策略使用std::async。默认启动策略就允许函数以异步或同步的方式运行皆可。
  2. 除去默认启动策略外,std::launch::async启动策略意味着函数f必须以异步方式运行,也就是在另一线程上运行。
    std::launch::deferred启动策略意味函数只会在std::async所返回的期望的get或wait得到调用时才运行,也就是说,函数的执行会推迟至其中一个调用发生的时刻。当调用get或wait时,函数会同步运行,调用方会阻塞至函数运行结束为止,如果get或wait都没有得到调用函数是不会运行的。
    默认启动策略是对上述两者策略进行或运算的结果。
  3. 使用默认启动策略可能会发生一些我们不可知的结果:
    (1)无法预知函数是否会和原线程并发运行,因为f可能会被调度为推迟运行
    (2)无法预知函数是否运行在与调用期值的get或wait函数的线程不同的某线程之上
    (3)连函数是否会运行这件事情都无法预知,因为无法保证期值的get或wait都会得到调用
  4. 默认启动策略在调度上的弹性会在使用thread_local变量时导致不明不白的混淆,因为这意味着如果函数读或写此线程级存储时,无法预知会取到的是哪个线程的局部存储。它也会影响那些基于wait的循环中以超时为条件者。这一类缺陷在开发或单元测试中很容易被忽略,因为只有在运行负载很重时才会现身
  5. 修正这一困难并不难:校验std::async返回的期值,确定任务是否被推迟,然后如果确实被推迟了,则避免进入基于超时的循环,但是没有直接的办法来询问期值任务是否被推迟了,作为替代,我们需要先调用一个基于超时的函数,比如wait_for,只需要在超时为0时查看返回值是否是std::future_status::deferred就行。
     auto fut=std::async(f);
     if(fut.wait_for(0s)==std::future_status::deferred){
         ...
     }else{
         ...
     }
    
  6. 以默认启动策略对任务使用std::async能够正常工作需要满足以下所有条件:
    (1)任务不需要与调用get或wait的线程并发执行
    (2)读/写哪个线程的thread_local变量并无影响
    (3)要么可以给出保证在std::async返回的期值之上调用get或wait,要么可以接受任务可能永不执行
    (4)使用wait_for或wait_until的代码会将任务被推迟的可能性纳入考量
    只要其中一个条件不满足,你就很有可能想要确保任务以异步方式执行,此时需要在调用时把std::launch::async作为第一个实参传递:
     auto fut=std::async(std::launch::async,f);
    

使std::thread类型对象在所有路径皆不可联结

  1. 每个std::thread对象都处于两种状态之一:可联结和不可联结。std::thread对象对应的底层线程若处于阻塞或等待调度状态,则它可联结,若已运行至结束则亦可认为其可联结。而不可联结的thread对象就不处于以上可联结的状态。
  2. 不可联结的std::thread类型对象包括:
    (1)默认构造的std::thread,因为此类thread没有可以执行的函数
    (2)已移动的std::thread,移动后一个thread所对应的底层执行线程被对应到另外一个std::thread
    (3)已联结的std::thread,联结后thread对象不再对应至已结束运行的底层执行线程
    (4)已分离的std::thread,分离操作会将thread对象和它对应的底层执行线程之间的连接断开
  3. 如果可联结的线程对象的析构函数被调用,则程序的执行就终止了。如果不这样设计,另外两种的设计会更加糟糕:
    (1)隐式join,在这种情况下thread对象的析构函数会等待底层异步执行线程完成,听上去合理但是可能导致难以追踪的性能异常,因为很可能不需要一直等待线程执行完成
    (2)隐式detach,此时thread对象的析构函数会分离thread对象与底层执行线程之间的连接,该底层线程会继续执行,但是当thread对象所在的线程执行完毕后而thread对象又使用了其中局部变量的引用,那么很有可能后续的函数会将此时局部变量的值进行修改。
    因此销毁一个可联结的线程实在太过可怕,因此规定可联结的线程的析构函数导致程序终止。
  4. 如果我们使用了thread对象,就得确保从它定义的作用域出去的任何路径,使它成为不可联结状态,最常见的方法就是在局部对象的析构函数中执行该操作,这样的对象称为RAII对象。
  5. 一般地,在一个对象之上同时调用多个成员函数,只有当这些函数都是const成员函数时才安全。
  6. 一个成员变量的初始化有可能会依赖另一个成员变量,又因为std::thread类型对象初始化之后可能会马上用来运行函数,所以我们应该在成员列表的最后声明std::thread对象。

对变化多端的线程句柄析构函数行为保持关注

  1. 可联结的线程对应着一个底层操作系统线程,使用std::async所产生的未推迟任务的期值和系统线程也有类似关系,因此thread对象和期值对象都可以视作系统线程的句柄。
  2. 期值作为调用方从系统线程这个被调方那里获得结果,但是由于被调方在期值调用get之前就有可能执行完毕,因此结果不会存在被调方的std::promise对象中,因为这是个局部变量,被调方执行完毕后就会被析构。但是也不能存在调用方的期值中,因为可能从future类型对象将所有权转移到std::shared_future对象,可能会被复制多次而如果结果类型是不可复制的则不行,因此结果也不能放在调用方中。
  3. 结果最终被保存在被调方和调用方外部的一个位置,这个位置称为共享状态,共享状态通常使用堆上的对象表示。该共享状态由指向它的期值和被调方的std::promise共同操纵。共享状态中包含引用计数,使得库里能够知道何时可以析构共享状态。
  4. 期值析构函数的行为是与其关联的共享状态决定的,具体就是:
    (1)指向由std::async启动的未推迟任务的共享状态的最后一个期值会保持阻塞,直至该任务结束,本质上对该线程执行了一次隐式jion
    (2)其他所有期值对象的析构函数只仅仅将期值对象析构就结束了
  5. 对线程执行隐式join,析构共享状态的期值析构函数行为只有在以下条件全部满足时才会发挥作用:
    (1)期值所指向的共享状态是由于调用了std::async才创建的
    (2)该任务的启动策略是std::launch::async,可能是默认启动策略时系统的选择,也可能是是我们指定的
    (3)该期值是指向该共享状态的最后一个期值
  6. 当我们的期值所对应的共享状态是由std::packaged_task产生的,则通常无须采用特别析构策略,因为关于是终止、联结还是分离的决定,会由操纵std::thread的代码作出,而std::packaged_task通常就运行在该线程之上。

考虑针对一次性事件通信使用以void为模板类型实参的期值

  1. 如果仅为了实现平凡事件通信,基于条件变量的设计会要求多于的互斥量,这会给相互关联的检测和反应任务带来约束,并要求反应任务校验事件确已发生。
  2. 使用条件变量会导致两个问题:
    (1)如果检测任务在反应任务调用wait之前就通知了条件变量则反应任务就会失去响应
    (2)反应任务的wait语句无法应对虚假唤醒,要想避免虚假唤醒可以往wait函数中添加测试等待条件的lambda作为第二个参数
  3. 使用标志位的设计可以避免上述问题,但这一设计基于轮询而非阻塞。
  4. 条件变量和标志位可以一起使用,但这样的通信机制设计结果不甚自然。
  5. 使用std::promise类型对象和期值就可以回避这些问题,但是一来这个途径为了共享状态需要使用堆内存,而且仅限于一次性通信。
     std::promise<void> p;  //信道的约值
     ...                    //检测事件
     p.set_value();         //通知反应任务
     ...                    //准备反应
     p.get_future().wait(); //等待p对应的期值
    
  6. std::promise类型对象和期值之间的通信通道是个一次性机制:它不能重复使用,这是它基于条件变量和基于标志位的设计之间的显著差异,前两者都可以用来进行多次通信。
  7. 创建时处于暂停状态的线程也可以使用std::promise和期值之间的通信通道:
     std::promise<void> p;
     void react();
     std::thread t([]{
         p.get_future().wait();//暂停t
         react();
     });
     ...                     //在此期间t处于暂停状态
     p.set_value();          //取消暂停t(调用react)
     ...
     t.join();               //置t于不可联结状态
    

对并发使用std::atomic,对特种内存使用使用volatile

  1. std::atomic模板的实例可以保证被其他线程视为原子的,一旦构造了一个std::atomic类型对象,针对它的操作就好像这些操作处于受互斥量保护的临界区域内一样,实际上这些操作通常会使用特殊的机器指令实现,这些指令比使用互斥量来的更加高效。
  2. 一旦构造出std::atomic对象,其上的所有成员函数(包括那些包含RMW(读取-修改-写入操作))都保证被其他线程视为原子的。相比之下,使用volatile的相应代码在多线程语境中几乎不能提供任何保证,一般都会导致数据竞险。
  3. 使用std::atomic来进行多线程通信时,atomic对象的运用会对代码可以如何重新排序加以限制,并且限制之一就是在源代码中不得将任何代码提前至后续会出现std::atomic类型变量的写入操作的位置(或使其他内核视作这样的操作会发生)。但是volatile关键字不会给代码施加同样的重新排序方面的约束。。
  4. 常规内存具有以下特征:如果向某内存位置写入某值,期间未读取该内存位置,然后再次写入该内存位置,则第一次写入可以消除,因为其写入结果未使用过。
     x=10;
     x=20;
     等价于
     x=20;
    
  5. 特种内存不会做这样的优化,最常见的特种内存就是用于内存映射I/O的内存,这种内存的位置实际上是用于与外设(显示器、打印机和网络端口等)通信,而非用于读取或写入常规内存(即RAM)。volatile的用处就是告诉编译器正在处理的是特种内存,也就是不要做这样的优化。
  6. std::atomic的复制操作是删除的,因为如果存在复制操作的话编译器就必须生成代码在单一的原子操作中读取x并写入y,硬件通常是无法完成这样的操作的,因此复制构造和复制赋值操作都是被删除的。
  7. 从x中取值并置入y是可行的,但是要求使用std::atomic的成员函数load和store,load函数以原子方式读取std::atomic类型对象的值,store成员函数则以原子方式写入之,因此如果想先用x初始化y,而后将x的值置入y,写法需要像如下一样:
     std::atomic y(x.load());               //读取x
     y.store(x.load());                     //再次读取x
    
  8. std::atomic对于并发程序设计有用,但不能访问特种内存。
    volatile对于访问特种内存有用,但不能用于并发程序设计。
    如果我们想要并发访问特种内存就可以把二者结合起来一起使用。
     volatile std::atomic<int> val;  //针对val的操作是原子的,并且不可以被优化掉