C++并发API总结

本文是阅读《Effective Modern C++》之后的一点理解和笔记
github博客地址

基于线程 VS. 基于任务

int doAsyncWork();

std::thread t(doAsyncWork); // 基于线程
auto fut = std::async(doAsyncWork); // 基于任务
  • 硬件线程:实际执行计算的线程,和实际的CPU核心有关(一个核心对应一个或多个硬件线程);
  • 软件线程:进行进程管理、硬件线程调度的线程;系统能够提供的软件线程有限;
  • 超订(oversubscription):就绪状态的软件线程超过了硬件线程个数;
  • 软件线程和硬件线程的最佳比例很难确定,因为它取决于:
    • 软件线程变为可运行状态的频繁程度(IO密集型 VS. 计算密集型)
    • 上下文切换的开销成本(与体系结构有关)
    • 软件线程使用CPU缓存的命中率(与体系结构有关)
    • ...
  • std::thread:底层软件线程的句柄
  • 在通常情况下,基于任务会比基于线程更好,原因如下:

    • std::async的返回值类型std::future拥有get()成员函数,可以方便的获取运行函数doAsyncWork()的返回值;std::thread则没有直接了当的方式;
    • std::future::get()可以访问程序异常,而std::thread在遇到异常时,直接崩死;
    • std::async承担了维护硬件线程和软件线程比例的重担,让开发者得以解脱;
  • 需要使用std::thread而不是std::async的一些场景:

    • 需要访问底层API(如pthread库)
    • 需要且有能力优化线程算法
    • 实现标准库中未实现的技术(如线程池)

两类不同的启动策略std::launch::async VS. std::launch::deferred

  • std::async对于待执行函数int f()的默认启动策略有如下特点:
    • 执行std::future::get()std::future::wait()的线程和执行f的线程是否是不同线程无法得知
    • f函数内部需要读写本线程的thread_local变量时,无法确保其一定属于本线程;
    • f函数是否会并行执行也无法得知,因为有可能会被推迟执行;
    • f函数甚至根本不会执行;
    • 上述存在的不确定性情况在系统负载较大时更有可能发生
    • 默认启动策略等价于std::launch::async | std::launch::deferred
  • 默认启动策略看似充满了不确定性,但正是因为如此,才使得标准库组件有能力去承担线程创建销毁、避免超订、负载均衡等复杂的操作。
  • 如果f必须以异步方式执行,则改为:
auto fut = std::async(std::launch::async, f); // 强制异步执行

可联结 VS. 不可联结

  • 可联结的std::thread:对应底层以异步方式可运行或已运行的线程,或已经运行结束,或者处于阻塞、等待调度的状态;
  • 不可联结的std::thread包括:
    • 默认构造的std::thread,即没有待执行函数;
    • 已移动的std::thread,即对应的底层线程被映射到另一个std::thread
    • 已联结的std::thread,即已经执行std::thread::join()
    • 已分离的std::thread,即已经执行std::thread::detach()
  • 直接销毁一个可联结的std::thread是被命令禁止的,所以如果该情况发生,程序直接崩死退出(官方的规定)。被禁止的原因在于另外两种可选项更加糟糕:
    • 隐式join可能会造成:创建std::thread的函数都已经要结束运行了,却还不得不等待std::thread的执行函数f运行结束,导致效率问题;
    • 隐式detach可能会导致:被分离的底层线程有可能会改变本已经被销毁的栈帧中的局部变量,导致程序异常;

线程句柄

  • 广义上的线程句柄包括std::thread以及std::future,因为两者都会指涉到一个底层的真实的执行线程;此处的std::future指的就是通过std::async调用得到的期值对象(当然,需要以std::launch::async进行启动);

  • 共享状态:即std::async的被调方需要返回给调用方的信息

    • 被调方会通过std::promise对象将调用方的std::future::get()会用到的信息存入一个共享状态之中
    • 共享状态既不能存储于std::promise对象中,也不能存储于std::future对象中;
    • 前者可能会导致变量被析构(即被调方的std::promise对象在调用方的std::future对象执行get()方法之前就已经被销毁);
    • 后者则可能会导致返回信息的所有权的不确定性(因为std::future对象可以构造出多个std::shared_future对象);如果返回信息不具备共享语义的话,就出大麻烦了;
    • 一般的做法是将共享状态存储在堆中
  • 线程句柄的析构函数的默认行为:

    • std::thread的析构行为在上一章已经有所阐述
    • std::future的析构行为由它关联的共享状态决定
  • std::future的析构行为:

    • 常规情况下:只析构自身,以及对其指涉的共享状态的引用计数实施一次自减;
    • 特殊情况:当且仅当①std::future对象是由std::async创建;②std::async的启动策略为异步起动而非延迟启动;③该std::future对象是共享状态的最后一个指涉对象时:该std::future阻塞直至std::thread::join()完成;
  • std::packaged_task

    • 对需要执行的任务函数f执行一个包装;
    • 作用是提供一个std::packaged_task::get_future()成员函数,作用是:返回的std::future对象通常仅执行常规析构策略(因为不是由std::async创建的嘛);join, detach的决定交由std::thread来操控
// 关于std::packaged_task的实例
int f(); // 需要调用的异步函数

std::packaged_task pt(f);
auto fut = pt.get_future(); // fut不干涉底层线程行为
std::thread t(std::move(pt)); // 线程的join, detach行为由t控制

线程间的一次性通信事件

  • 线程间的同步方式(也就是通信方式)主要包括:
    • 互斥锁
    • 信号量(不推荐);
    • 条件变量:需要和互斥锁配合起来一起使用;
  • 现实环境中,存在这样一种情况:
    • 线程A是探测方,用于探测某个条件是否已经满足;
    • 线程B是反应方,当A探测到条件已经满足的时候就会对B进行通知,然后B就开始做出相应的反应;
    • 这显然也是线程间通信的一种情况,因此使用condition variable是完全可以实现这个需求的;
    • 但condition variable毕竟需要mutex的支持,而mutex是用于控制共享数据的同步;显然,在本例下,A和B是不存在什么共享数据这样的东西的;
    • 当A通知完B以后,两者实质上就不再有什么关联,它们各自执行各自的事情;这就是所谓的一次性通信
  • 针对线程间的一次性通信,更高效的做法是采用std::promise
    • 前面已经说过,promise的作用是在一个信道中发送信息(也就是改变相应的共享状态);信道的另一端使用std::future来获取信息
    • 在一次性通信中,实质上是没有发送数据的,所谓的通信仅仅是一次内容为空的通知
    • 这就是使用std::promise tmp的机制,探测方使用tmp.set_value()来发送通知;反应方使用tmp.get_future().wait()来等待探测方的通知;set_value()就是一个一次性的函数;
    • 如果涉及到多个反应方的话,甚至可以在反应方使用auto sf = tmp.get_future().share(); sf.wait();来达到相同的目的;这样做只是将future转化为共享式的shared_future而已;

atomic VS. volatile

  • volatile关键字其实和并发编程没有任何关系,而atomic却是在并发编程中的一项很重要的工具;
  • std::atomic的作用是针对任何的具现化类型(std::atomic, std::atomic等等),其内部的操作都是原子性的,最突出的就是++运算符,保证原子性;
  • std::atomic的原子性并非基于mutex机制,而是在硬件上生成特殊的指令代码来实现目的;因此atomic的开销是更低的;
  • 除了原子性以外,std::atomic还拥有以下的约束:
    • 某两条目的是改变atomic变量的值的代码之间的代码无法越过这两个上下界,比如下面的示例代码中,“一些代码”是无法越过这个上下界的;(所谓的越过,指的是编译器在执行编译优化的时候,很可能改变某些代码行的执行顺序,以寻求加速;但这样的优化在并发编程中更可能带来灾难,因为并发代码中的代码执行顺序是很重要的,上下之间很可能构成条件关系)
std::atomic ai(0);
/** 一些代码 **/
ai = 3;
  • atomic的原子性约束代码顺序重排的约束正是它能在并发编程中带来作用的根本原因;
  • 而恰好,volatile完全不具备这两项约束;因此volatile根本和并发编程无关;
  • volatile关键字的作用是:
    • 告诉系统这个变量所指涉的内存是一块特种内存,编译器不能为他执行任何冗余优化;
    • 所谓的冗余优化,指的是编译器可以将一些看起来是冗余的代码进行合并;比如说对一个变量连续的赋值,那么优化之后,就只赋最后一个值就可以了;
    • 比如下面的代码,编译器不能将其合并为一句volatile int ioBuf = 2;,原因就在于这是一块特种内存(比如说IO映射的内存位置,更具体一点,它可能映射到的是一个无线发射器的发送端口,那么下面代码的含义就是,我要染发射器先发送一个1,再发送一个2;这显然是不能合并为一句的)
volatile int ioBuf = 1;
ioBuf = 2;
  • volatile的特性也是atomic没有的,因此两者之间完全没有任何关系;
  • 这也以为着这两者是可以一起使用的,其语义就是:一块涉及到多个线程同时访问的特种内存;

你可能感兴趣的:(C++并发API总结)