参考:EffectiveModernCppChinese/src/7.TheConcurrencyAPI/item38.md at master · CnTransGroup/EffectiveModernCppChinese (github.com)
有时,一个任务通知另一个异步执行的任务发生了特定的事件很有用,因为第二个任务要等到这个事件发生之后才能继续执行。事件也许是一个数据结构已经初始化,也许是计算阶段已经完成,或者检测到重要的传感器值。这种情况下,线程间通信的最佳方案是什么?
一个替代方案是让反应任务通过在检测任务设置的future上wait
来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise
,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。
方案很简单。检测任务有一个std::promise
对象(即通信信道的写入端),反应任务有对应的future。当检测任务看到事件已经发生,设置std::promise
对象(即写入到通信信道)。同时,wait
会阻塞住反应任务直到std::promise
被设置。
现在,std::promise
和futures(即std::future
和std::shared_future
)都是需要类型参数的模板。形参表明通过通信信道被传递的信息的类型。在这里,没有数据被传递,只需要让反应任务知道它的future已经被设置了。我们在std::promise
和future模板中需要的东西是表明通信信道中没有数据被传递的一个类型。这个类型就是void
。检测任务使用std::promise
,反应任务使用std::future
或者std::shared_future
。当感兴趣的事件发生时,检测任务设置std::promise
,反应任务在future上wait
。尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对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::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
也许更重要的是,std::promise
只能设置一次。std::promise
和future之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。(条件变量可以被重复通知,flag也可以重复清除和设置。)
假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用void
的future就是一个可行方案。这是这个技术的关键点:
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::future
的share
成员函数将共享状态所有权转移到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的设计可以实现这个功能值得注意,这也是你应该考虑将其应用于一次通信的原因。
std::promise
和future的方案避开了这些问题,但是这个方法使用了堆内存存储共享状态,同时有只能使用一次通信的限制。 分析如下使用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
的值。这对于这个语句没有影响,因为int
的operator<<
是使用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::atomic
和volatile
用于不同的目的,所以可以结合起来使用:
volatile std::atomic vai; //对vai的操作是原子性的,且不能被优化掉
如果vai
变量关联了内存映射I/O的位置,被多个线程并发访问,这会很有用。
最后一点,一些开发者在即使不必要时也尤其喜欢使用std::atomic
的load
和store
函数,因为这在代码中显式表明了这个变量不“正常”。强调这一事实并非没有道理。因为访问std::atomic
确实会比non-std::atomic
更慢一些,我们也看到了std::atomic
会阻止编译器对代码执行一些特定的,本应被允许的顺序重排。调用load
和store
可以帮助识别潜在的可扩展性瓶颈。从正确性的角度来看,没有看到在一个变量上调用store
来与其他线程进行通信(比如用个flag表示数据的可用性)可能意味着该变量在声明时本应使用而没有使用std::atomic
。
这更多是习惯问题,但是,一定要知道atomic
和volatile
的巨大不同。
std::atomic
用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。volatile
用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。