第七章 并发API

C++11将并发融入了语言和库中。

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

基于线程的设计就是使用std::thread来运行函数;基于任务的设计就是使用std::async来运行函数。

int f();
thread(f);  //基于线程
auto r = async(f);  //基于任务

使用async调用f的时候,传递给async的函数对象被看做是任务。在基于线程的调用中,获取函数的返回值比较麻烦,而在基于任务的调用中,获取函数的返回值就比较简单,因为async返回的期值提供了get函数。并且当运行的f函数抛出异常后,get函数能够访问到此异常,而对于thread则做不到对异常的访问和处理。

线程在C++软件设计中的意义如下:

  • 硬件线程是实际执行计算的线程,现代计算机体系结构会为每一个CPU内核提供一个或多个硬件线程。
  • 软件线程(又称操作系统线程或系统线程)是操作系统用以实施跨进程的管理,以及进行硬件线程调度的线程。通常,能够创建的软件线程会比硬件线程要多,因为当一个软件线程阻塞了,运行另外的非阻塞线程能够提升吞吐率。
  • thread是C++进程里的对象,用作底层软件线程的句柄。有些thread对象表示为“null”句柄,对应于“无软件线程”,可能的原因有:它们处于默认构造状态(没有待执行的函数),或者被移动了(作为移动目的的thread对象成为了底层线程的句柄),或者被联结了(待运行的函数已结束运行),或者被分离了(thread对象与其低层软件线程的连接被切断了)。

总的来说,基于线程的程序设计要求手动管理线程耗尽、超订、负载均衡、以及新平台适配。

当创建的软件线程超出系统能够提供的数量将会抛出std::system_error异常。

即使没有用尽线程,还是会发生超订问题,也就是说,就绪状态的软件线程超出了硬件线程数量的时候,这种情况发生后,线程调度器(操作系统的一部分)会为软件线程在硬件线程之上分配CPU时间片。当发生线程切换的时候,会出现语境切换,这种语境切换会导致系统总体线程管理开销。尤其是发生在不同CPU内核上的语境切换。主要是软件线程的CPU缓存的不命中,并且覆盖CPU为曾经在此内核上运行过的线程准备的数据,并且该线程可能还会被调到此CPU上工作。

这些问题是必须要考虑的,如果使用async来管理线程,就会非常轻松,因为async将会由标准库的实现者负责线程管理。当使用thread会产生超订问题,但是使用async呢?系统不保证会创建一个新的软件线程。并且允许调度器把要运行的线程函数放在请求线程结果的线程中运行。如果线程发生了超订或者线程耗尽,那么合理的调度器就可以使用这个自由度。

但是即便是运行async,调度器也不知道哪一个线程在响应性上比较紧迫,这就可以通过传递launch::async的启动策略传递给async来保证线程的运行是一定会分配线程资源的。

最高水平的线程调度器会使用全系统范围的线程池来避免超订,而且还是通过运行工作窃取算法来提高硬件内核间的负载均衡。

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。

假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

但是在以下几个方面最好还是使用thread。

  • 需要访问底层线程实现的API。thread提供native_handle成员函数来访问底层线程实现的API,而future则没有这个功能。
  • 需要且有能力为应用优化线程算法。
  • 需要实现超越C++并发APIDE线程技术。

P.s.native_handle是为了访问底层线程的API用的,在linux上是pthread_*线程文件描述符,在windows上是windows线程句柄

三十六 如果异步是必要的,则指定launch::async

当使用async来执行线程函数的时候,基本上都是要求此线程函数异步运行。当调用async的时候,可以传入一定的参数(枚举量)控制异步运行这种行为。

  • launch::async启动策略意味着线程函数必须以异步方式运行,即在另一个线程上执行
  • launch::deferred启动策略意味线程函数只会在async所返回的期值的get或wait得到调用时才运行,即执行会推迟到其中一个调用发生的时刻。当调用get或者wait,线程函数会同步运行,调用方会阻塞至该线程函数运行完毕。

默认的启动策略是两个枚举量相或的结果。这种启动策略就允许线程函数以异步或同步的方式运行皆可。

//等价
auto fut = async(f);    //默认方式
auto fut = async(launch::async | launch::deferred, f);  //异步或者推迟的方式运行

使用默认方式启动函数f时

  • 无法预知f是否和调用线程并发运行,因为f可能会被调度为推迟运行。
  • 无法预知f是否运行在调用fut的get或wait函数的线程不同的某线程之上。
  • 无法预知f是否会运行。

默认启动策略在调度上的弹性会在使用thread_local变量时导致问题,因为这意味着f读或写次线程局部存储(thread_local storage,TLS)时,无法预知会取到的是哪一个线程的局部存储。也就是说f的TLS可能和一个独立线程相关,也可能和调用fut的get或wait相关。

在等待线程结束的时候有四种方式:

  • wait:等待结果变得可用
  • get:返回结果
  • wait_for:等待结果,如果在指定的超时间隔后仍然无法得到结果,则返回
  • wait_until:等待结果,如果在已经到达指定的时间点时仍然无法得到结果,则返回
int func(int n)
{
    this_thread::sleep_for(chrono::seconds(2)); //sleep2s,是this_thread命名空间的专属函数
    return n += 100;
}
int main()
{
    int n = 0;
    future<int> fs = async(func, n);    //func的返回类型为T,则async的返回类型为future<T>
    future_status::future_status st;
    do{
        st = fs.wait_for(chrono::seconds(1));   //超时时间为1s,超时等待返回,超出时间则返回
        if(st == future_status::deferred)
            cout << "Asynchronous operation not begin" << endl;
        else if(st == future_status::timeout)
            cout << "Asynchronous operation timeout" << endl;
        else if(st == future_status::ready)
            cout << "Asynchronous operation finish" << endl;
    }while(st != future_status::ready);
    /*
    st = fs.wait_until(chrono::system_clock::now() + chrono::seconds(2));
    if(st == future_status::deferred)
        cout << "Asynchronous operation not begin" << endl;
    else if(st == future_status::timeout)
        cout << "Asynchronous operation timeout" << endl;
    else if(st == future_status::ready)
        cout << "Asynchronous operation finish" << endl;
    */
    //int ret = fs.get();   //等待线程结束,并获取返回值,返回值类型和函数返回值类型一致
    //cout << ret << endl;
    //fs.wait();    //只是等待线程结束,并不返回值
}

wait_for返回值是一个枚举量future_status:

enum class future_status {
    ready,      //共享状态就绪
    timeout,    //共享状态在经过指定的时限时长前仍未就绪
    deferred    //共享状态含有延迟的函数,故将仅在显式请求时计算结果
};

如果出现了超订现象,在wait_for的循环中,可能出现返回值永远是deferred的情况。这时候就会无限循环。

解决方式可以是通过判断,当返回值是future_status::deferred的时候,就使用别的方式等待返回值,否则循环等待即可。

以下四点满足,才使用默认方式启动程序:

  • 任务不需要和调用get或wait的线程并发执行
  • 读写哪个线程的thread_local变量并无影响
  • 保证在async返回的期值之上调用get或wait,或者接受任务永远不执行
  • 使用wait_for或wait_until的代码会将任务被推迟的可能性纳入考量

如果上述条件不满足,或者异步是必要的。最好使用launch::async方式启动程序。

接下来给出一个默认调用async方式启动线程的函数。

template<typename F, typename... Ts>    //F:函数;Ts:函数的可变参数
future<typename result_of<F(Ts...)>::type>  //返回值:future<T>,T是调用的线程函数的返回值
reallyAsync(F&& f, Ts&&... params)
{
    return async(launch::async, forward<F>(f), forward<Ts>(params)...); //完美转发
}

三十七 使std::thread型别对象在所有路径皆不可联结

每个thread型别对象皆处以两种状态之一:可联结或者不可联结。可联结的thread对应等以一步方式已运行或可运行的线程。

thread型别对象对应的底层线程若处于阻塞或等待调度,则它可联结,thread型别对象对应的底层线程如已运行结束,则亦认为其可联结。

不可联结的thread型别对象包括:

  • 默认构造的thread:此类thread没有可执行的函数,因此也没有对应的底层执行函数
  • 已移动的thread:移动操作的结果是,一个thread所对应的底层执行线程(若有)被对应到另外一个thread
  • 已联结的thread:联结后,thread型别对象不再对应至已运行结束的底层执行线程
  • 已分享的thread:分离操作会把thread型别对象和它对应的底层执行线程之间的连接断开

thread的可联结性之所以重要的原因之一是:如果可联结的线程对象的析构函数被调用,则程序的执行就终止了。

如果要设置线程的优先级,则需要使用基于线程的thread,并使用线程的低级句柄。以此句柄实现优先级的设置。

int a = 3, b = 5;
    thread t([&a, &b]{ return a++ * b++;});
    auto nh = t.native_handle();    //所有的c++11同步对象都有一个native_handle()成员,它返回具体实现句柄

两个和thread有关的问题:

  • 隐式join:程序结束后,join未执行,这样thread的析构函数会等待底层异步执行线程去完成。这样会造成性能问题。
  • 隐式detach:thread的析构函数会分离thread型别对象和底层执行线程之间的连接,而该底层执行线程会继续执行。调用函数退出,调用函数内部的局部变量消失,但是detach的线程仍然在运行。

所以为了防止上述问题的出现,在使用thread型别对象的时候,要确保从它定义的作用于出去的任何路径,使其成为不可联结状态。简单的方法之一就是使用RAII类。

RAII类的析构函数进行状态判断,如果是可联结状态,则自动执行联结,使其成为不可联结状态。

具体代码非常简单:

int func(int &n)    //线程函数,按引用传值
{
    this_thread::sleep_for(chrono::seconds(2)); //sleep2秒钟,是this_thread命名空间的专属函数
    n += 100;
    return n;
}
class threadRAII    //线程RAII类
{
public:
    enum class DtorAction{join, detach};    //表示线程联结状态的枚举
    threadRAII(thread&& _t, DtorAction d) : act(d), t(move(_t)){ }  //构造函数,移动构造线程,线程是不可复制的,成员初始化
    ~threadRAII(){ //析构函数
        if(t.joinable()){   //查询联结状态,可联结情况进if
            if(act == DtorAction::join) //如果是联结状态
                t.join();
            else
                t.detach(); //如果是不可联结状态
        }
    }
    threadRAII(threadRAII&&) = default;
    threadRAII& operator=(threadRAII&&) = default; 
    thread& get(){ return t; }  //访问底层的thread型别对象,类似于智能指针的get函数
private:
    DtorAction act;
    thread t;   //在成员列表的最后声明t,可以保证当t初始化完毕之后,所以其他成员变量已经初始化完毕,这样如果t要使用别的成员变量就不会产生问题
};
int main()
{
    int t = 3;
    //按引用传值,ref(t)。
    threadRAII tr(thread(func, ref(t)), threadRAII::DtorAction::join);
    this_thread::sleep_for(chrono::seconds(5)); //等待异步线程执行完毕
    cout << t << endl;
    return 0;
}

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

可联结的线程对应着一个底层系统执行线程,未推迟任务的期值和系统线程也有着类似的关系。

对可联结状态的thread对象执行析构(通常是调用线程运行完毕退出作用域之后的自动析构)会造成程序的终止。

这里介绍一下和thread有关的,取得线程函数返回值的模板类。

参考网页:

C++11 并发指南四( 详解一 std::promise 介绍)

C++11 并发指南四( 详解二 std::packaged_task 介绍)

std::promise

promise对象可以保存某一类型T的值,该值可被future对象读取(可能在另外一个线程中),因此 promise 也提供了一种线程同步的手段。在 promise 对象构造时可以和一个共享状态(通常是std::future)相关联,并可以在相关联的共享状态(std::future)上保存一个类型为T的值。

可以通过get_future来获取与该promise对象相关联的future对象,调用该函数之后,两个对象共享相同的共享状态(shared state) 。

  • promise 对象是异步 Provider,它可以在某一时刻设置共享状态的值。
  • future 对象可以异步返回共享状态的值,或者在必要的情况下阻塞调用者并等待共享状态标志变为 ready,然后才能获取共享状态的值。

promise构造函数包括:默认构造函数,带有自定义内存分配器的构造函数,移动构造函数。promise对象不可拷贝。

get_future成员函数返回一个与promise共享状态相关联的future。返回的future对象可以访问由promise对象设置在共享状态上的值或者某个异常对象。 只能从promise共享状态获取一个future对象。

set_value成员函数设置共享状态的值,此后 promise 的共享状态标志变为ready。

set_exception成员函数为promise设置异常,此后promise的共享状态变标志变为ready。

set_value_at_thread_exit设置共享状态的值,但是不将共享状态的标志设置为ready,当线程退出时该promise 对象会自动设置为ready。如果某个future对象与该promise对象的共享状态相关联,并且该future正在调用get,则调用get的线程会被阻塞,当线程退出时,调用future::get的线程解除阻塞,同时get返回set_value_at_thread_exit所设置的值。

promise<int> pr;
future<int> f = pr.get_future();    //和pr绑定
thread t1([](promise<int>& p){p.set_value(9);}, move(pr));  //设置p要传回的值为9,传入pr
auto r = f.get();
cout << r << endl;  //9
t1.join();

上述代码中,创建了一个promise对象pr,f是和promise相关联的future。返回的future可以访问pr设置的值。

在线程t1中,传入promise,并在线程函数内部,把需要返回的值设置为promise的值,并通过引用传入pr,等到set_value函数结束,promise对象为ready后,通过get获取到future内部的值。

std::packaged_task

packaged_task包装一个可调用的对象,并且允许异步获取该可调用对象产生的结果,从包装可调用对象意义上来讲,packaged_task 与function类似,只不过packaged_task将其包装的可调用对象的执行结果传递给一个 future对象(该对象通常在另外一个线程中获取packaged_task任务的执行结果)。

packaged_tas 对象内部包含了两个最基本元素:

  • 被包装的任务(stored task),任务(task)是一个可调用的对象,如函数指针、成员函数指针或者函数对象。
  • 共享状态(shared state),用于保存任务的返回值,可以通过future对象来达到异步访问共享状态的效果。

可以通过get_future函数来获取与共享状态相关联的future对象。在调用该函数之后,两个对象共享相同的共享状态,具体解释如下:

  • packaged_task对象是异步Provider,它在某一时刻通过调用被包装的任务来设置共享状态的值
  • future对象是一个异步返回对象,通过它可以获得共享状态的值,当然在必要的时候需要等待共享状态标志变为 ready

packaged_task的共享状态的生命周期一直持续到最后一个与之相关联的对象被释放或者销毁为止。

packaged_task构造函数有:默认构造函数,初始化共享状态的构造函数,带自定义分配器的构造函数,移动构造函数。复制操作被禁用。

valid成员函数检查当前packaged_task是否和一个有效的共享状态相关联,对于由默认构造函数生成的packaged_task对象,该函数返回false,除非中间进行了move赋值操作或者swap操作。

get_future成员函数返回了一个与packaged_task对象共享状态相关的future对象。返回的future对象可以获得由另外一个线程在该packaged_task对象的共享状态上设置的某个值或者异常。

packaged_task<int()> task([](){return 7;}); //创建一个task,类型是线程函数的函数类型
future<int> f = task.get_future();  //获取期值
thread t(move(task));   //将该任务放在线程中运行
auto r = f.get();   //获得保存的值
cout << r << endl;
t.join();

上述代码中,创建了一个packaged_task任务,将f关联到task的共享状态上,并将任务放入到线程中开始执行。

线程句柄析构函数分析

期值位于信道的一端,调用方把结果通过该信道传输给调用方,被调方(通常以异步方式执行)把其计算结果写入信道(通常经由一个promise型别对象),而调用方则使用使用一个期值来读取结果。

首先这个结果不能存放在被调方的promise型别对象内部,因为这个promise型别对象通过move移入被调函数,当函数结束的时候,就会被析构释放。其次这个结果也不能存到调用函数的内部,因为这个结果可能是只移对象,这样当future通过share()函数或者移动进入shared_future后,只移对象就必须保存在所有指涉到该期值中生存期最长的一个中,但是我们无法得知哪一个线程的生存期是最长的。

所以最终只能将该结果存储在位于两者外部的某个位置。这个位置就是共享状态,共享状态使用堆上的对象来表示,但是型别、接口和实现标准都未指定。关系如下图:

这里写图片描述

共享状态的存在很重要,因为期值析构函数的行为是由与其关联的共享状态决定的。具体来说就是:

  • 指涉到经由async启动的为推迟任务的共享状态的最后一个期值会保持阻塞,直到该任务结束。本质上,这样一个期值的析构函数是对底层异步执行任务的线程实施了一次隐式join。
  • 其他所有期值对象的析构函数只仅仅将期值对象析构就结束了。对于底层异步运行的任务,这样做类似于对线程实施了一次隐式detach。对于那些被推迟任务而言,如果这一期值是最后一个,也就意味着被推迟的任务将不会有机会运行了。

期值的析构函数一般情况下只会析构期值对象,不实施join或者detach,仅会析构期值的成员变量并且针对共享状态的引用计数实施一次自减。该共享状态由指涉到它的期值和被调方的promise共同操纵

而当满足以下条件时,期值的析构函数会出发阻塞,直到异步线程执行完毕。这相当于针对正在运行async所创建的线程实施了一次隐式join。条件如下:

  • 期值所指涉的共享状态是由于调用了async才创建的
  • 该任务的启动策略是launch::async(线程函数必须以异步方式运行),这可能是运行时系统的而选择或者是显式指定的
  • 该期值是指涉到该共享状态的最后一个期值,对于future型别对象而言,这一点总是成立,而对于shared_future型别对象而言,析构时如果不是最后一个指涉到该共享状态的期值,则它仅析构期值并针对共享状态的引用计数实施自减

问题就在于任意一个期值都是不知道自己是不是来源于async创建,也就是说,期值是否会阻塞在析构函数中也是不知道的。

如果想知道确切的析构时间,则可以使用packaged_task,packaged_task型别对象会准备一个函数(或其他可调用的对象,例如lambda表达式)以供异步执行,手法是将其加上一层包装,把其结果置入共享状态。而指涉到该共享状态的期值则可以经由packaged_task的get_future函数得到。

一个简单的测试代码:

packaged_task<int()> task([](){return 7;}); //创建一个task,类型是线程函数的函数类型
future<int> f = task.get_future();  //获取期值
thread t(move(task));   //将该任务放在线程中运行
auto r = f.get();   //获得保存的值
cout << r << endl;
t.join();

这里task一经创建就会运行在线程之上,当要将其传递给thread构造函数,要将其强转为右值。

并且在创建t之后,对于t实施detach或join,f都无需理会,因为在调用的代码中已经做过此事了。

三十九 考虑针对一次性时间通信使用以void为模版型别实参的期值

线程间的同步操作需要线程间通信来完成。

条件变量

一种方式是条件变量。C++11中引入了条件变量,位于头文件\中 。condition_variable类是一个同步原语,可以被用来阻塞一个线程或者同时阻塞多个线程,直到另一个线程既修改了共享变量(即“条件”),也做了通知。

想要修改条件变量的线程必须:

  • 获得 mutex
  • 在保有锁时进行修改
  • 在condition_variable上执行notify_one或notify_all(不需要为通知保有锁

即使共享变量是原子的,也必须在互斥下修改它,以正确地发布修改到等待的线程。

任何有意在条件变量上等待的线程必须:

  • 获得锁,在与用于保护共享变量者相同的互斥上
  • 执行wait、wait_for或wait_until,等待操作自动释放互斥,并挂起线程的执行
  • 条件变量被通知时,时限消失或虚假唤醒发生,线程被唤醒,且自动重新获得互斥。之后线程应检查条件,若唤醒是虚假的,则继续等待。
void func1()    //线程函数用于通知
{
    unique_lock<mutex> lk(mt);  //获取互斥量(用于访问条件变量)
    ...
    cv1.notify_all();   //告诉21线程退出了
}
void func2()    //线程函数用于被通知
{
    unique_lock<mutex> lk(mt);  //获取互斥量(用于访问条件变量)
    cv1.wait(lk, []{return flag == 1;});    //lambda表达式防止虚假唤醒
    ...
}

条件变量的成员函数:

  • wait
    • 当condition_variable对象的某个wait函数被调用的时候,它使用unique_lock绑定mutex来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的condition_variable对象上调用了notify_one或者notify_all函数(通知函数不用加锁)来唤醒当前线程。在线程被阻塞时,调用wait的函数会自动调用unlock释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。一旦当前线程获得通知,通常是另外某个线程调用 notify_*唤醒了当前线程,wait函数将会自动获得锁
    • 此外还有一个重载版本的wait,需要多传入一个bool值,仅当这个值为false时,wait才会阻塞当前线程,并且在收到其他线程的通知后只有当此值为true时才会被解除阻塞。可以通过这个值来判断虚假唤醒。
  • wait_for:wait_for可以指定一个时间段,在当前线程收到通知或者指定的时间超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_for返回,剩下的处理步骤和 wait类似。
  • wait_until:wait_until可以指定一个时间点,在当前线程收到通知或者指定的时间点超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_until返回。(多一个版本,同wait第二个版本)
  • notify_one:唤醒某个wait线程。如果当前没有等待线程,则该函数什么也不做,如果同时存在多个等待线程,则唤醒某个线程是不确定的。
  • notify_all:唤醒所有的wait线程。如果当前没有等待线程,则该函数什么也不做。

所以一般情况下,搭配notify和wait函数,并且使用锁来保证条件变量的安全,就可以实现线程的同步问题。

condition_variable cv;  //条件变量
mutex m;    //互斥锁
//通知过程(通知过程无需加锁)
cv.notify_one();
//接收过程
unique_lock<mutex> lk(m);
/* 等待线程必须先获取锁,该锁被传递给wait方法,wait方法会释放互斥量并挂起
线程,直到条件变量收到信号,收到信号后,线程会被唤醒,同时锁也会被重新获取*/
cv.wait(lk);    //暂时未处理虚假唤醒的问题

要考虑以下两个问题:

  • 如果检测任务在反应任务调用wait之前就通知了条件变量,则反应任务将失去响应,为了实现通知条件变量唤醒另一个任务,该任务必须已在等待该条件变量。而如果wait之前就通过过了,就会一直等待。
  • 反应任务的wait语句无法应对虚假唤醒,线程API即使没有通知条件变量(未调用notify_all或notify_one),针对该条件变量等待的代码也有可能被唤醒(wait从阻塞返回),这就是虚假唤醒(据说是中断也有可能造成唤醒,这里我不是非常清楚)。正确的方法通过确认等待的条件确实已经发生,将其作为唤醒后的首个动作来处理这种情况。

针对虚假唤醒,可以使用while循环或者使用lambda表达式来解决。

使用while循环等待条件时,可以在醒来之后立马再判断一次条件是否成立再决定是否需要继续等待(即当条件不满足就wait等待,即使当次条件满足wait返回,也会再进行一次while()的判断,如果这次仍然是满足,那么就会推出while循环,否则就会继续wait)

while(flag != 2) cv2.wait(lk);

使用lambda表达式是利用了wait的第二个重载版本:

template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);

只有当pred为false时调用wait()才会阻塞当前线程,并且在收到其他线程的通知后只有当pred为true时才会被解除阻塞。

cv2.wait(lk, []{return flag == 2;});

也就是说,通过变量flag,在通知的线程里面替换为另一个值2,在反应的线程里面使用lambda表达式进行判断值是否等于2,如果判断值为假将会阻塞在wait中,直到这个通知线程更改这个值后,才会进入到通知线程的后续执行。

//一个多线程条件变量的简单实现代码
/*
1 有一int型全局变量g_Flag初始值为0;
2 在主线程中起动线程1,打印“this is thread1”,并将g_Flag设置为1
3 在主线程中启动线程2,打印“this is thread2”,并将g_Flag设置为2
4 线程序1需要在线程2退出后才能退出
5 主线程在检测到g_Flag从1变为2的时候退出
*/
#include <iostream>
#include <thread>
#include <atomic>
#include <functional>
#include <future>
#include <chrono>
#include <mutex>
#include <condition_variable>
using namespace std;
using namespace chrono;
mutex mt;
atomic<int> flag(0);    //使用普通的全局布尔变量也可以
condition_variable cv1;
condition_variable cv2;


void func1()
{
    unique_lock<mutex> lk(mt);  //获取互斥量(用于访问条件变量)
    cout << "this is thread1, id:" << this_thread::get_id() << endl;
    flag = 1;
    cout << "thread1 exit" << endl;
    cv1.notify_all();   //告诉2,1线程退出了
}

void func2()
{
    unique_lock<mutex> lk(mt);  //获取互斥量(用于访问条件变量)
    //wait、wait_for、wait_until,这些等待动作原子性地释放mutex,并使得线程的执行暂停
    cv1.wait(lk, []{return flag == 1;});    //lambda表达式防止虚假唤醒
    cout << "this is thread2, id:" << this_thread::get_id() << endl;
    flag = 2;
    cout << "thread2 exit" << endl;
    cv2.notify_all();   //告诉主线程,2退出了
}

int main()
{
    thread t1(func1);
    thread t2(func2);
    t1.detach();
    t2.detach();
    unique_lock<mutex> lk(mt);
    cv2.wait(lk, []{return flag == 2;});    //防止虚假唤醒,也可以用while循环 while(flag != 2) cv2.wait(lk);
    cout << "main exit" << endl;

    return 0;
}

标志位

如果不使用条件变量,还有其他的方法,其一就是使用全局布尔变量,通过轮询实现,这就会导致程序占用较多的资源(因为使用条件变量是阻塞,阻塞的时候操作系统会剥夺这个线程的CPU控制权,使其暂停执行,同时将资源让给其他的工作线程,而使用轮询线程会一直运行),原本应该阻塞的线程一直在运行就会占用另一个任务本应该能够利用的硬件线程。而且在每次开始运行以及其时间片结束的时候,都会产生语境切换的成本,阻塞则没有这些成本负担。

轮询的代码非常简单,使用全局布尔变量,通知进程置位,接收进程循环判断,如下所示:

#include <iostream>
#include <thread>
#include <future>
#include <atomic>
#include <chrono>
using namespace std;
atomic<bool> flag = false;  //原子变量
void notice()
{
    this_thread::sleep_for(chrono::microseconds(100));  //睡眠2s
    cout << "this is notice thread" << endl;
    flag = true;
}
void accept()
{
    while(!flag); //cout << "flag is false" << endl;
    cout << "this is accept thread" << endl;
}
int main()
{
    thread a1(notice);
    thread a2(accept);

    a1.join();
    a2.join();
    /*
    auto f1 = async(launch::async, notice);
    auto f2 = async(launch::async, accept);
    int a;
    cin >> a;
    */

    return 0;
}

promise与期值

另一种方法是摆脱条件变量,互斥量和标志位,方法是让反映任务去等待检测任务设置的期值。具体的方法就是使用promise。检测任务有一个promise型别对象,反应任务有对应的期值, 当检测任务发现它正在查找的事件已经发生的时候,就向信道写入(设置promise型别对象),与此同时,反应任务调用wait以等待它的期值。该wait调用会阻塞反应任务直到promise型别对象被设置为止。

在这里的promise和期值(future,shared_future)都是需要型别形参的模板。该形参表示的是要通过信道发送数据的型别。本例中没有数据要传送,也就是说对于期值唯一的用处就是期值是否被设置。并没有数据要在信道上面传输,所以直接以void为参数就行。也就是检测任务使用promise<void>,反应任务使用future<void>shared_future<void>

具体代码如下:

promise<void> p;    //promise型别对象
void notice()
{
    this_thread::sleep_for(chrono::microseconds(100));  //睡眠2s
    cout << "this is notice thread" << endl;
    p.set_value();  
}
void accept()
{
    auto f = p.get_future();    //获得期值
    thread t1(notice);
    f.wait();   //阻塞等待
    cout << "this is accept thread" << endl;
    t1.join();
}
int main()
{
    thread a1(accept);

    a1.join();

    return 0;
}

上述代码成功的用promise对象实现了线程的同步,但是也存在一定的问题,首先是共享状态是在堆上生成的对象,有内存分配回收成本;其次是promise型别对象只能被设置一次,不可以重复使用。(条件变量和标志位都能重复使用)

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

atomic构造的实例提供的操作可以会被其他的线程视为是原子的。一旦构造了atomic型别对象,针对其的操作就好像这些操作处于受互斥量保护的临界区域内一样,但是实际上,这些操作通常会使用特殊的机器指令来实现,这些指令比互斥量更高效。

原子性质

对于原子变量来说,赋值,自增,自减都是原子操作。但是对于普通变量来说,自增自减都是读取—修改—写入(RMW)操作,不是原子操作。即便这些变量使用了volatile来修饰。

一个简单的测试程序如下:

#include <iostream>
#include <atomic>
#include <thread>
using namespace std;
volatile int a1(0); //volatile型变量
atomic<int> a2(0);  //原子变量
void func() //自增函数
{
    int i = 1e6;
    while(i-- > 0){
        a1++;
        a2++;
    }
}
int main()
{
    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();
    cout << "volatile: " << a1 << endl; //不确定
    cout << "atomic: " << a2 << endl;   //2'000'000

    return 0;
}

上述代码中,每次volatile的结果都接近2’000’000,但是不会为2’000’000,而原子变量每次的结果都是2’000’000。

volatile修饰的a1涉及到数据竞险,也就是说,执行两次自增,可能结果只加1。过程如下:

线程1读取a的值:0->线程2读取a的值:0->线程1增加a的值:1->线程2增加a的值:1->线程1把值写入a->线程2把值写入a

代码重新排序

编译器底层或者硬件底层可能会更改两个代码的执行顺序,这是代码优化导致的结果。

atomic可以阻止这种优化,这样所有在atomic后面执行的语句都不会放到atomic之前执行。

但是voilatile修饰的变量不会阻止这种优化。

volatile所做的事情就是告诉编译器,正在处理的内存不具备常规行为。也就是说,如果有个变量x,编译器会优化此行为:

auto x = y; 
x = y;

编译器会优化冗余赋值,并且如果连续两次写入之间没有读取x的值的操作存在,编译器也会优化消除第一次的写入。

x = 10;
//无读取x的操作存在
x = 20;

会优化为:

x = 20;

上述的冗余可能出现在编译器进行模板实例化、内联、以及重新排列后的代码内。

volatile会阻止编译器的这种优化。

auto y = x; //auto推导的是非引用且非指针的型别时,会自动去掉cv属性,也就是说这里推导的x是int型别
y = x;

但是atomic不会,也就是说,atomic上的冗余操作会被编译器自动消除。

atomic是不允许复制操作的(被删除了),原因是atomic的所有操作都必须是原子的,但是为了使x出发构造y也是原子的,编译器必须生成代码来在单一的原子操作中读取x并写入y。硬件通常无法完成这样的操作。

但是从x中取值并放入y是可以实现的,方法就是使用成员函数load和store,load以原子方式读取atomic型别对象的值,store以原子方式写入。但是如果搭配起来就不会是原子操作。

y.store(x.load());  //非原子操作

通过上述分析可以知道:

  • atomic对于并发程序设计有用,但是不能用于访问特种内存
  • volatile对于访问特种内存有用,但是不能用于并发程序设计

并且这两个部分可以一起使用:

volatile atomic<int> val;   //针对val的操作是原子的并且不可被优化

猜你喜欢

转载自blog.csdn.net/u012630961/article/details/81100695