Effective Modern C++ Item 39 考虑针对一次性事件通信使用以void为模板型别实参的期值

本文探讨了在异步任务间通信时,如何选择合适的线程间通信机制。条件变量、共享标志位以及结合两者的方案各自有优缺点。条件变量可能需要不必要的互斥量,而标志位可能导致轮询开销。结合条件变量和标志位的方案虽然避免了一些问题,但涉及到一次性通信。最后,文章提出使用std::promise和期值可以避免这些问题,但要注意一次性通信限制和异常安全问题。
摘要由CSDN通过智能技术生成

有时候,提供让一个任务通知另一个以异步方式运行的任务发生了特定的事件的能力,会很有用,原因可能是第二个任务在事件发生之前无法推进。这事件也许是某个数据结构完成了初始化,也许是某个计算阶段结束了,又也许是某个重要传感器取值被检测到了等等。在此情况下,用什么方式完成线程间通信会是最佳的呢?

1. Solution 1 使用条件变量

一种明显的途径是使用条件变量,若我们把检测条件的任务成为检测任务,把对条件做出反应的任务成为反应任务,则策略表述起来很简答:反应任务等待这条件变量,而检测任务则在事件发生时,通知条件变量。给定:

std::condition_variable cv;     //事件的条件变量
std::mutex              m;      //在运用cv时,给它加的互斥量

检测任务的代码真的简单到不能再简单:

....                            //检测事件
cv.notify_one();                //通知反应任务

如果有多个反应任务需要通知到,那么使用 notify_all 替换 notify_one 才合适,但现在不妨假设只有一个反应任务。

反应任务的代码稍显复杂,因为在条件变量之上调用 wait 之前,必须通过 std::unique_lock 型别对象锁定互斥量(在等待条件变量之前锁定互斥量,对于线程库来说是典型操作。而通过std::unique_lock对象完成锁定互斥量的需求,是C++11在API中所提供的功能)。下面展示了概念上应该如何实现:

....                                            //准备反应
{                                               //临界区域开始
    std::unique_lock<std::mutex> lk(m);         //为互斥量加锁
    cv.wait(lk);                                //等待通知到来
    ......                                      //这里会出错!
                                                //针对事件做出反应(m被锁定)
}                                               //临界区域结束
                                                //通过lk的析构函数为m解锁
....                                            //继续等待反应
                                                //(m已解锁)

这种途径的第一个问题有时被称为代码异味 Code Smell : 即使代码能够一时运作,某些东西似乎也不太对劲。在本例中,异味源自于需要使用互斥体。互斥体是用于控制共享数据访问的,但检测和反应任务之间打有可能根本不需要这种介质。例如,检测任务可能会负责初始化一个全局数据结构,然后把它转交给反应任务使用。如果检测任务在初始化之后,从不访问该数据结构,并且在检测任务指示它已就绪之前,反应任务从不访问它,那么根据程序逻辑,这里两个任务将会相互阻止对方访问,根本不需要什么互斥量。采用条件变量这一途径却要求必须有个互斥量,这个事实就为设计留下了令人生疑和不安的气息。

即使对此视而不见,还有两个问题是无论如何都需要关切的:

  • 如果检测任务在反应任务调用wait之前就通知了条件变量,则反应任务将失去响应。为了实现通知条件变量唤醒另一个任务,该任务必须已在等待该条件变量。如果检测任务在响应任务执行wait之前就执行了通知动作,则反应任务就将错过该通知,并且将等待到天荒地老。

  • 反应任务的wait语句无法应对虚假唤醒。线程API的存在一个事实情况(很多语言中都如此,不仅仅是C++),即使没有通知条件变量,针对该条件变量等待的代码也可能被唤醒。这样的唤醒成为虚假唤醒。正确的代码通过确认等待的条件确实已经发生,并将其作为唤醒后的收割动作来处理这种情况。C++的条件变量API使得做到一点异常简单,因为它允许测试等待条件的lambda式(或其他函数对象)被传递给wait。换言之,反应任务中调用wait事可以这样写:

    cv.wait(lk, []{return 事件是否确已发生;});
    

    想要利用这项能力,就要求反应任务能够确认它所等待的条件是否成立。但是在我们考虑的上述场景中,是由检测线程负责识别它所等待的条件是否因为对应的事件发生导致的。反应线程可能无法确认它正在等待的事件是否已经发生。这也是为什么它等待的是个条件变量!

在许多情况下,使用条件变量进行任务间通信是对于所面对问题的适当解法,但我们现在看的这问题似乎并非其中之一。

2. Solution 2 使用共享的布尔标志位

许多软件工程师的下一个锦囊妙计是使用共享的布尔标志位。该标志位的初始值是false。当检测线程识别出它正在查找的事件时,会设置该标志位:

std::atomic<bool>  flag(false);         //共享的布尔标志位
                                        //关于std::atomic,详见Item 40
....                                    //检测事件
flag = true;                            //通知反应任务

在这一途径中,反应线程只是会轮询标志位。一旦看到该标志被设置时,就知道它正在等待的事件已经发生了。

....                //准备反应

while(!flag);       //等待事件

....                //针对事件作出反应

这种方法没有任何基于条件变量的设计的缺点。也不需要互斥体。如果检测任务在反应任务开始之前就设置了标志位,也没有任何问题。并且虚假唤醒的毛病也不见了。这个办法的确还是很不错的。

可是不那么好的地方在于,反应任务的轮询成本高昂。在任务等待标志位被设置的时候,它实质上应该被阻塞,但却仍然在运行。因此,它就占用了另一个任务本应该能够利用的硬件线程,而且在每次开始运行以及其时间片结束时,都会产生语境切换的成本。它还可能会让一颗硬件核心持续运行,而那颗核心本来可以关掉以节省电能真正处于阻塞状态的任务不会耗用所有以上这些。这倒是基于条件变量的途径的一个优点,因为等待调用的任务会真正地被阻塞。

3. Solution Normal 结合条件变量和基于标志位的设计

常用的手法是结合条件变量和基于标志位的设计。标志位表示是否发生了有意义的事件,但是访问该标志需要通过互斥量加以同步。因为互斥锁会阻止并发访问该标志位,所以,如Item 40所说,不需要该标志位采用std::atomic型别对象来实现,一个平凡的布尔量足矣。这么一来,检测任务会写成这样:

std::condition_varialbe  cv;            //同前
std::mutex  m;

bool flag(false);                       //非std::atomic型别对象
....                                    //检测事件
{
    std::lock_guard<std::mutex>  g(m);  //经由g的构造函数锁定m

    flag = true;                        //通知反应任务(第一部分)
}                                       //经由g的析构函数解锁m
cv.notify_one();                        //通知反应任务(第二部分)

以下是反应任务的实现:

....                                        //准备反应
{                                           //同前
    std::unqiue_lock<std::mutex>  lk(m);    //同前
    cv.wait(lk, []{return flag;});          //使用lambda式应对虚假唤醒
    .....                                   //针对事件做出反应
                                            //(m被锁定)
}
......                                      //继续等待反应
                                            //(m已解锁)

采用这一途径,可以避免我们之前讨论的问题。它能够运作,在检测任务通知之前响应任务就开始等待也没关系,在存在虚假唤醒的前提下也不影响,而且不需要轮询。然而,还是有一丝异味存在,因为探测任务和反应任务的沟通方式非常奇特。通知条件变量在这里的目的是告诉反应任务,它正在等待的事件可能已经发生了,然而反应任务必须检查标志位才能确定。设置标志位在这里的目的是告诉反应任务事件确确实实已经发生了,但是检测任务仍然需要通知条件变量才能让反应任务被唤醒并去检查标志位。这一途径是能够运作的,但是不够干净利落

4. Solution Best 让反应任务去等待检查任务设置的期值

另一种方法是摆脱条件变量,互斥量和标志位,方法是让反应任务去等待检查任务设置的期值。这看似是一种怪异的想法。毕竟,Item 38 曾经解释说,期值代表了从被调者到(通常以异步方式运行的)调用者的信道接收端,在检测和反应任务之间并不存在这种调用关系和被调用者的关系。不过,Item 38又指出,发送端是std::promise型别对象,并且其接收端是期值的通信信道用途不止于调用者和被调用者一种。这种信道可以用于任何需要将信息从一处传输到另一处的场合。在本例中,我们将它用来将信息从检测任务传输到响应任务,传达信息则是有意义的事件已经发生。

这种设计简单易行。检测任务有一个std::promise型别对象(即,信道的写入端),反应任务有对应的期值。当检测任务发现它正在查找的事件已经发生时,它会设置反应任务有对应的期值。当检测任务发现它正在查找的事件已经发生时,它会设置std::promise型别对象(即,向信道写入)。与此同时,反应任务调用wait以等待它的期值。该wait调用会阻塞反应任务直至std::promise型别对象被设置为止。

在这里的std::promise和期值(即std::futurestd::shared_future)都是需要型别形参的模板。该形参表示的是要通过信道发送数据的型别。在本例中,却没有数据要传送。对于反应任务有意义的唯一事情,就是它的期值是否被设置。我们所需要的std::promise和期值模板是一种表示没有数据要通过信道传送的那么一种型别。那种型别就是void。因此,检测任务将使用std::promise<void>,并且反应任务将使用std::future<void>std::shared_future<void>。当有意义的事件发生时,检测任务将设置其std::promise<void>,反应任务将等待其期值。即使反应任务不会接收任何来自检测任务的数据,信道也会允许反应任务通过在其std::promise型别对象上调用set_value来了解检测任务何时写入了其void型别的数据。

所以,给定

std::promise<void>  p;      // 信道的约值

检测任务的代码是平凡的:

...                         // 检测事件

p.set_value();              // 通知反应任务

而反应任务的代码也同样平平无奇:

....                        // 准备反应

p.get_future().wait();      // 等待p对应的期值

...                         // 针对事件作出反应

就像使用标志位的途径一样,这个设计也不需要互斥量,检测任务是否在响应任务等待之前设置它的std::promise都可以,并且对虚假唤醒免疫(只有条件变量会不能应对虚假唤醒)。也像基于条件变量的途径一样,在调用wait之后,反应任务真正被阻塞,所以在等待时不会消耗系统资源。完美,对不对?

不对!。当然,基于期值的途径可以绕开前面那些险滩,但仍不免于其他一些陷阱。例如,Item 38就解释过,std::promise和期值之间是共享状态,而共享状态通常是动态分配的。因此,你就得假设这种设计会招致在堆上进行分配和回收的成本。

可能这一点是最重要的std::promise型别对象只能设置一次。std::promise型别对象和期值之间的通信信道是个一次性机制:它不能重复使用。这是它基于条件变量和基于标志位的设计之间的明显差异,前两者都可以用来进行多次通信(条件变量可以被重复通知,标志位可以被清除并重新设置)。

一次性这一约束并不像你可能想象的先知那么大。假设你想创建一个暂停状态的系统线程。也就是说,你希望一开始就把与创建线程相关的所有开销都提前付清,而后一旦要在线程上执行某些操作时即可避免常规的线程创建延迟了。又或者你可能想创建一个暂停的线程,以便在它运行之前先对其实施一些配置动作。这样的配置可能包括诸如设置其优先级或内核亲和性之类。C++并发API并未提供做这些事情的方法,但std::thread型别对象提供了native_handle成员函数,意在让你得以以访问平台的底层线程API(通常是POSIX线程或者Windows线程)。低级API通常能够配置像优先级和亲和性这样的线程特性。

4.1 Solution Best的代码Demon

假定你只想暂停线程一次(在它创建之后,但在它运行其他线程函数之前),使用void期值的设计就是合理的选择。下面是该技术的重要部分:

std::promise<void>   p;

void react();                                   // 反应任务的函数

void detect()                                   // 检测任务的函数
{
    std::thread t([]                            // 创建线程
                  {
                      p.get_future().wait();    // 暂停 t
                      react();                  // 直至其期值被设置
                  });
    ....                                        // 这里t出于暂停状态,在调用react之前
    p.set_value();                              // 取消暂停t(调用react)
    ....                                        // 做其他工作
    t.join();                                   // 置t于不可联结状态(参见Item 37)
}

4.2 Solution Best的代码Demon plus

因为使t在所有detect的出向路径上都置为不可联结这件事很重要,所以使用 Item 37 中像ThreadRAII那样的RAII类应该是可取的。于是,你可能会脑补出这么一段代码:

void detect()
{
    ThreadRAII tr(                          // 使用RAII对象
        std::thread([]
                    {
                        p.get_future().wait();
                        react();
                    }),
        ThreadRAII::DtorAction::join        // 这里以后风险!(见下)
    );
    ....                                    // tr内的线程在此处被暂停
    p.set_value();                          // tr内的线程在此处被取消暂停
    ....
}

这段代码不像看上去那么安全。问题在于第一个....区域(带有// tr内的线程在此处被暂停注释的那个),如果抛出异常的话,set_value便永远不会在p上调用。

这意味着,在lambda式内部调用的wait将永远不会返回。但这,反过来又意味着,运行lambda式的线程将永远不会完成,这是个问题,因为RAII对象tr已被配置为在tr析构函数中针对该线程执行join。换而言之,如果从代码的第一个....区域抛出异常,这个函数将失去响应(也就是卡死了),因为tr的析构函数将永远不会完成。

有很多种方法可以解决该问题,但这里并不暂开描述。这里,主要战士如何对原始代码(即,不使用ThreadRAII)加以扩充,使之可以针对不止一个,可以是很多个反应任务实施先暂停再取消暂停的功能

这个拓展不难,因为关键之处在于在react的代码中使用std::shared_future而非std::future。一旦你了解到,std::futureshare成员函数是把共享状态的所有权转移给了由share生成的std::shared_future型别对象,代码也就自己呼之欲出了。唯一的微妙之处就是,每个反应线程都需要自己的那份std::shared_future副本去指涉到共享状态,所以,从share中获取的std::shared_future被运行在反应线程上的lambda式按值捕获:

std::promise<void>  p;                  //同前

void detect()                           //现在可以处理多个反应任务了
{
    auto sf = p.get_future().share();   //sf的型别是
                                        //std::shared_future<void>

    std::vector<std::thread> vt;        //反应任务的容器

    for (int i = 0; i < threadsToRun; ++i) {
        vt.emplace_back([sf]{
            sf.wait();                  //sf局部副本之上的wait
            react();                    //关于emplace_back,详见Item 42
        });
    }
    ....                                //若此"...."抛出异常,则detect会失去响应!
    p.set_value();                      //让所有线程取消暂停
    ....

    for (auto& t : vt) {                //把所有线程置为不可联结状态
        t.join();                       //auto&的详情,参见Item 2
    }
}

使用期值的设计能够实现这样的效果,此事实值得注意,这也是为何应该将一次性事件通信纳入考量的原因。

要点速记
1. 如果仅为了实现平凡事件通信,基于条件变量的设计会要求多余的互斥量,这会给相互关联的检测和反应任务带来约束,并要求反应任务检验事件确已发生。
2. 使用标志位的设计可以避免上述问题,但这一设计基于轮询而非阻塞。
3. 条件变量和标志位可以一起使用,但这一昂的通信机制设计结果不甚自然。
4. 使用std::promise型别对象和期值就可以回避这些问题,但是一来这个途径为了共享状态需要使用堆内存,而且仅限于一次性通信。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值