c++多线程 std::async std::future std::atomic volatile

c++标准库中对线程操作有完善的封装,其中最常用到的如std::thread, std::async。

EffectiveModernCpp中指出,除非是需用到线程的原生局部(只能调用std::thread的API,future无法做到),应尽量使用std::async即基于任务的编程而非基于线程的编程。std::thread在前面的文章有提到过,此处仅对std::async作以记录。

正如前面所说,std::async是基于任务的策略,本人理解为对线程操作的更抽象的封装。对比std::thread,其更灵活且可以异步访问结果。

将闭包传递给std::async后,其会返回一个std::future期物,可对期物使用wait()方法等待线程结束,get()方法得到线程返回值。

std::future有三种状态:

std::future_status::ready  //线程执行完

std::future_status::timeout  //线程超时

std::future_status::deferred  //线程延迟执行(还未开始执行)

对std::future对象可使用wait_for(std::chrono::milliseconds)方法得到线程当前的状态

auto fut = std::async(doAsyncWork);
...    //主线程的一些操作
fut.wait();    // 在此设置屏障,阻塞到期物的完成
auto res = fut.get();    //得到结果

值得注意的是,系统可以支持的线程数量是有限的,因此若开发者试图创建大于系统支持的线程数量,会抛出std::system_error异常。

即使没有超出软件线程的限额,仍然可能会遇到资源超额(oversubscription)的麻烦。这是一种当前准备运行的(即未阻塞的)软件线程大于硬件线程的数量的情况。

而std::async有两种启动策略:

  • std::launch::async启动策略意味着f必须异步执行,即在不同的线程。
  • std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行

而std::async默认的启动策略是二者的求或,即std::launch::async | std::launch::deferred,因此默认策略允许异步或者同步执行。这种灵活性允许std::async和标准库的线程管理组件承担线程创建和销毁的责任,避免资源超额,以及平衡负载。这就是使用std::async并发编程如此方便的原因。

需注意,若使用默认启动策略,使用者便无法得知线程的状态,即无法预测线程是否是同步或异步,是否在等待还是执行。

 

std::future析构时不会像std::thread一样,因为未声明等待方法而抛出异常终止程序,实际上future是通信信道的一端,被调用者通过该信道将结果发送给调用者。被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:

 因为与被调用者关联的对象和与调用者关联的对象都不适合存储运行结果(具体原因详见EffectiveModernCpp-item38),所以必须存储在两者之外的位置。此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,但是标准并未指定其类型、接口和实现。标准库的作者可以通过任何他们喜欢的方式来实现共享状态。

我们可以想象调用者,被调用者,共享状态之间关系如下图,虚线还是表示信息流方向:

 当future满足以下条件:

  • 它关联到由于调用std::async而创建出的共享状态。
  • 任务的启动策略是std::launch::async(参见Item36),原因是运行时系统选择了该策略,或者在对std::async的调用中指定了该策略。
  • 这个future是关联共享状态的最后一个future。对于std::future,情况总是如此,对于std::shared_future,如果还有其他的std::shared_future,与要被销毁的future引用相同的共享状态,则要被销毁的future遵循正常行为(即简单地销毁它的数据成员)。

其会对创建者线程进行隐式jion。

即引用了共享状态——使用std::async启动的未延迟任务建立的那个——的最后一个future的析构函数会阻塞住,直到任务完成。

 


std::atomic模板提供了原子操作的可能。即std::atomic对象的读写和自增自减都是原子性的,一次完成,在并发中可以避免数据竞争带来的未定义后果。

注意std::atomic对象不支持移动构造和移动赋值,需要使用std::atomicloadstore成员函数。load函数原子性地读取,store原子性地写入

volatile关键字修饰的变量不具备和std::atomic对象一样的原子性。

std::atomic也可避免编译器对代码的重排,例如:

auto imptValue = computeImportantValue();   //计算值
valAvailable = true;                        //告诉另一个任务,值可用了

此处虽然代码的顺序是这样的没问题,但是编译器可能重排两句的执行顺序,因为在编译器看来,这两句代码互不影响,可能会为了优化等原因重排代码。但是若更改两者顺序,则可能会对另一个任务产生影响(valAvailable=true在前,另一个任务得到信号开始执行,但是imptValue值还未得到),因此可借助std::atomic限制这种重排序:

std::atomic<bool> valVailable(false); 
auto imptValue = computeImportantValue();   //计算值
valAvailable = true;                        //告诉另一个任务,值可用了

但是volatile关键字无法阻止重排。

c++中volatile关键字只是用来告诉编译器,被其修饰的资源必须从内存中读取,不能被编译器执行优化。如连续两次对同一变量进行赋值,普通情况下编译器只会进行第二次赋值,第一次赋值会被忽略。但是对该变量用volatile修饰,那么编译器将不会无视第一次赋值。

而std::atomic无法保证阻止编译器的优化。

  • std::atomic用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
  • volatile用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。

 

 

本文参考EffectiveModernCpp,部分为个人思考,仅供参考。

 

posted @   _Explosion!  阅读(162)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具
· Manus的开源复刻OpenManus初探
点击右上角即可分享
微信分享提示