Effective Modern C++ 第七章 并发API 3

参考:EffectiveModernCppChinese/src/7.TheConcurrencyAPI/item38.md at master · CnTransGroup/EffectiveModernCppChinese (github.com)

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

有时,一个任务通知另一个异步执行的任务发生了特定的事件很有用,因为第二个任务要等到这个事件发生之后才能继续执行。事件也许是一个数据结构已经初始化,也许是计算阶段已经完成,或者检测到重要的传感器值。这种情况下,线程间通信的最佳方案是什么?

一个替代方案是让反应任务通过在检测任务设置的futurewait来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。

方案很简单。检测任务有一个std::promise对象(即通信信道的写入端),反应任务有对应的future。当检测任务看到事件已经发生,设置std::promise对象(即写入到通信信道)。同时,wait会阻塞住反应任务直到std::promise被设置。

现在,std::promisefutures(即std::futurestd::shared_future)都是需要类型参数的模板。形参表明通过通信信道被传递的信息的类型。在这里,没有数据被传递,只需要让反应任务知道它的future已经被设置了。我们在std::promisefuture模板中需要的东西是表明通信信道中没有数据被传递的一个类型。这个类型就是void。检测任务使用std::promise,反应任务使用std::future或者std::shared_future。当感兴趣的事件发生时,检测任务设置std::promise,反应任务在futurewait。尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise调用set_value“写入”了void数据。

所以,有

std::promise p;                   //通信信道的promise

 检测任务代码很简洁:

…                                       //检测某个事件
p.set_value();                          //通知反应任务

反应任务代码也同样简单:

…                                       //准备作出反应
p.get_future().wait();                  //等待对应于p的那个future
…                                       //对事件作出反应

像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait之前检测线程是否设置了std::promise都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。与基于条件变量的方法一样,反应任务在调用wait之后是真被阻塞住的,不会一直占用系统资源。是不是很完美?

当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promisefuture之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。

也许更重要的是,std::promise只能设置一次。std::promisefuture之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。(条件变量可以被重复通知,flag也可以重复清除和设置。)

假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用voidfuture就是一个可行方案。这是这个技术的关键点:

std::promise p;
void react();                           //反应任务的函数
void detect()                           //检测任务的函数
{
    std::thread t([]                    //创建线程
                  {
                      p.get_future().wait();    //挂起t直到future被置位
                      react();
                  });
    …                                   //这里,t在调用react前挂起
    p.set_value();                      //解除挂起t(因此调用react)
    …                                   //做其他工作
    t.join();                           //使t不可结合(见条款37)
}

 因为所有离开detect的路径中t都要是不可结合的,所以使用类似于Item37中ThreadRAII的RAII类很明智。代码如下:

void detect()
{
    ThreadRAII tr(                      //使用RAII对象
        std::thread([]
                    {
                        p.get_future().wait();
                        react();
                    }),
        ThreadRAII::DtorAction::join    //有危险!(见下)
    );
    …                                   //tr中的线程在这里被挂起
    p.set_value();                      //解除挂起tr中的线程
    …
}

展示如何扩展原始代码(即不使用RAII类)使其挂起然后取消挂起不仅一个反应任务,而是多个任务。简单概括,关键就是在react的代码中使用std::shared_future代替std::future。一旦你知道std::futureshare成员函数将共享状态所有权转移到share产生的std::shared_future中,代码自然就写出来了。唯一需要注意的是,每个反应线程都需要自己的std::shared_future副本,该副本引用共享状态,因此通过share获得的shared_future要被在反应线程中运行的lambda按值捕获:

std::promise p;                   //跟之前一样
void detect()                           //现在针对多个反映线程
{
    auto sf = p.get_future().share();   //sf的类型是std::shared_future
    std::vector vt;        //反应线程容器
    for (int i = 0; i < threadsToRun; ++i) {
        vt.emplace_back([sf]{ sf.wait();    //在sf的局部副本上wait;
                              react(); });  //emplace_back见条款42
    }
    …                                   //如果这个“…”抛出异常,detect挂起!
    p.set_value();                      //所有线程解除挂起
    …
    for (auto& t : vt) {                //使所有线程不可结合;
        t.join();                       //“auto&”见条款2
    }
}

使用future的设计可以实现这个功能值得注意,这也是你应该考虑将其应用于一次通信的原因。

要点速记:

  • 对于简单的事件通信,基于条件变量的设计需要一个多余的互斥锁,对检测和反应任务的相对进度有约束,并且需要反应任务来验证事件是否已发生。
  • 基于flag的设计避免的上一条的问题,但是是基于轮询,而不是阻塞。
  • 条件变量和flag可以组合使用,但是产生的通信机制很不自然。
  • 使用std::promisefuture的方案避开了这些问题,但是这个方法使用了堆内存存储共享状态,同时有只能使用一次通信的限制。

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

 分析如下使用std::atmoic的代码:

std::atomic ai(0);         //初始化ai为0
ai = 10;                        //原子性地设置ai为10
std::cout << ai;                //原子性地读取ai的值
++ai;                           //原子性地递增ai到11
--ai;                           //原子性地递减ai到10

在这些语句执行过程中,其他线程读取ai,只能读取到0,10,11三个值其中一个。没有其他可能(当然,假设只有这个线程会修改ai)。

这个例子中有两点值得注意。首先,在“std::cout << ai;”中,ai是一个std::atomic的事实只保证了对ai的读取是原子的。没有保证整个语句的执行是原子的。在读取ai的时刻与调用operator<<将值写入到标准输出之间,另一个线程可能会修改ai的值。这对于这个语句没有影响,因为intoperator<<是使用int型的传值形参来输出(所以输出的值就是读取到的ai的值),但是重要的是要理解原子性的范围只保证了读取ai是原子性的。

第二点值得注意的是最后两条语句——关于ai的递增递减。他们都是读-改-写(read-modify-write,RMW)操作,它们整体作为原子执行。这是std::atomic类型的最优的特性之一:一旦std::atomic对象被构建,所有成员函数,包括RMW操作,从其他线程来看都是原子性的。

相反,使用volatile在多线程中实际上不保证任何事情:

volatile int vi(0);             //初始化vi为0
vi = 10;                        //设置vi为10 
std::cout << vi;                //读vi的值
++vi;                           //递增vi到11
--vi;                           //递减vi到10

对于如下代码:

auto y = x;                             //读x
y = x;                                  //再次读x
x = 10;                                 //写x
x = 20;                                 //再次写x

编译器生成的代码是这样的:

auto y = x;                             //读x
x = 20;                                 //写x

可能你会想谁会写这种重复读写的代码(技术上称为冗余访问redundant loads)和无用存储dead stores)),答案是开发者不会直接写——至少我们不希望开发者这样写。但是在编译器拿到看起来合理的代码,执行了模板实例化,内联和一系列重排序优化之后,结果会出现冗余访问和无用存储,所以编译器需要摆脱这样的情况并不少见。

这种优化仅仅在内存表现正常时有效。“特殊”的内存不行。最常见的“特殊”内存是用来做内存映射I/O的内存。这种内存实际上是与外围设备(比如外部传感器或者显示器,打印机,网络端口)通信,而不是读写通常的内存(比如RAM)。这种情况下,再次考虑这看起来冗余的代码:

auto y = x;                             //读x
y = x;                                  //再次读x

如果x的值是一个温度传感器上报的,第二次对于x的读取就不是多余的,因为温度可能在第一次和第二次读取之间变化。

volatile是告诉编译器我们正在处理特殊内存。意味着告诉编译器“不要对这块内存执行任何优化”。所以如果x对应于特殊内存,应该声明为volatile。

因此情况很明显:

  • std::atomic用在并发编程中,对访问特殊内存没用。
  • volatile用于访问特殊内存,对并发编程没用。

因为std::atomicvolatile用于不同的目的,所以可以结合起来使用:

volatile std::atomic vai;          //对vai的操作是原子性的,且不能被优化掉

如果vai变量关联了内存映射I/O的位置,被多个线程并发访问,这会很有用。

最后一点,一些开发者在即使不必要时也尤其喜欢使用std::atomicloadstore函数,因为这在代码中显式表明了这个变量不“正常”。强调这一事实并非没有道理。因为访问std::atomic确实会比non-std::atomic更慢一些,我们也看到了std::atomic会阻止编译器对代码执行一些特定的,本应被允许的顺序重排。调用loadstore可以帮助识别潜在的可扩展性瓶颈。从正确性的角度来看,没有看到在一个变量上调用store来与其他线程进行通信(比如用个flag表示数据的可用性)可能意味着该变量在声明时本应使用而没有使用std::atomic

这更多是习惯问题,但是,一定要知道atomicvolatile的巨大不同。

要点速记:

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

你可能感兴趣的:(c++,开发语言)