有时候某个特定事件的发生时,一个线程需要告知另外一个异步运行的线程,因为后者需要等待这个事件才能执行。这个事件可能指一个数据结构的初始化,某个计算过程完成,或者一个重要的传感器的数据已经被监测到。这些情况下,什么是线程之间最佳的通信方式呢?
最显而易见的是使用条件变量。我们称呼检测条件变量的线程为检测线程。称呼受影响的线程为处理线程。策略很简单:检测线程通知条件变量,而处理线程会等待这个条件变量事件。
std::condition_variable cv;
std::mutex m;
j检测线程内的代码很简单,简单到这样:
...// 检测事件
cv.notify_one(); // 通知反应线程
如果有多个线程需要发送通知,使用notify_all,但现在假定只有一个。
作用线程的代码更复杂些,因为它要先对条件变量调用wait,先通过std::unique_lock 持有互斥量,(先持有锁,然后再等待条件变量,是线程库典型应用。c++11有API,可以使用std::unique_ptr对互斥量加锁。下面是一种模式实现,
{
std::unique_lock<std::mutex> lk(m); //对m加锁
cv.wait(lk); //等待notify ,这不对!!
... // 处理事件,m在锁定状态
}
//析构函数释放锁
... //继续处理,现在不持有锁了
第一个例子有时被称为不好闻的代码味道:即使它有时可以正常工作,但并不完全正确。在这个例子中,气味来自于使用互斥量。
互斥量可以用来控制对共享数据的访问。但检测线程和处理线程不使用这个机制也是完全可能的。举例来说,检测线程初始化一个数据结构对象,然后给处理线程使用。检测线程初始化完对象后,便不在使用此对象。而处理线程在检测线程显示对象准备好了后再进行访问。两个任务通过程序逻辑避免行驶在别人的车道上。这种情况下不需要互斥量,给条件变量加一个锁,这就散发出值得怀疑的设计气味。
即使你忽略上面情况,你仍然需要注意下面两个情况:
检测任务在处理任务等待之前,就发送了条件变量通知。为了唤醒任务,另外的任务必须先等待条件变量。如果检测任务先发送了通知,处理任务就会错过通知,并且永远等待。
等待过程没有考虑到虚假唤醒的情况。多线程的API(许多语言,不光c++)存在这样一个事实,等待条件变量的线程有可能在没有条件变量通知情况下被唤醒。这称为虚假唤醒。需要有合适的代码来确认等待的条件是否真的满足,这通常是唤醒后第一个动作。c++关于条件变量API使这个检测异乎寻常简单的,它接受一个lamda(或者其他函数对象)来进行等待条件的判断。可以这样来些:
cv.wait(lk, []{事件是否发生;});
为了利用这个优势,处理线程需要判断等待条件为真。但我们考虑的场景是检测线程识别事件。处理线程可能无法判断事件是否发生。毕竟它等待条件变量就是为了这。
有很多用条件变量处理线程通信的应用很好的例子,但这个不是。
对于许多开发者来说,第二条锦囊妙计是使用共享的布尔变量。变量初始化为false,当检测线程检测到事件时,设置true。
std::atomic<bool> flag(false); // 到条款40 atomic
... //检测事件
flag=true; //告诉处理线程
处理线程只是简单的轮询变量值。但检测到值改变,便认为事件发生了。
while(!flag) //轮询
这个方案避免了条件变量的诸多缺点。不用互斥量,检测线程提早改变变量也没有问题,也没有虚假唤醒问题。很好,很好。
处理线程的轮询不那么好了。在任务轮询标记变量的时候,任务仍然在执行,但几乎是阻塞状态。它占有了本来可以为别的任务服务的硬件线程;它在时间开始和结束的时候,带了上下文切换开销。它在核心可以关闭以节省电力时候,仍然保持着核心的运行。真正阻塞的任务不会有上述问题,这就是条件变量的优势,等待条件变量的任务是真正阻塞的。
混合使用条件变量和标记变量的方法也很常见。标记变量表示事件是否发生,而访问标记变量时需要互斥量来同步。因为互斥量已经阻止了对标记变量的并发访问,所以根据条款40不需要再使用std::atomic类型,简单的boo就满足要求。检测任务看起来这样:
std::condition_variable cv; //同上
std::mutex m;
bool flag(false); // 不是原子类型
{
std::unique_lock<std::mutex> lk(m); // 加锁
flag = true; //告诉处理任务(第一部分)
} //解锁
cv.notify_one(); //告诉处理任务(第二部分)
下面是处理线程:
... //准备
{
std::unique_lock<std::mutex> lk(mutex);
cv.wait(lk, []{return flag;}); //使用lamda避免虚假唤醒
... //处理事件,此时持有锁
}
... //继续处理事件,锁已经释放
这个方案避免了我们上面谈到的问题。检测任务在处理线程等待条件变量之前发送通知,可以正常工作。有虚假唤醒时候也可以正常工作。而且也不需要轮询。但还有一点看起来不很好,检测线程必须发送通知去唤醒处理任务,而处理线程唤醒后要先判断下是否真的有通知到来。设置标记变量已经表明事件确定发生了,但检测任务仍然必须发送通知,以便唤醒处理线程检测标记。这个方案虽然可以工作,但是还不够清晰。
另外一种思路是不使用条件变量、互斥量和标记变量,使用future作为检测任务和处理任务之间的通信工具。
std::promise<void> p;
void detect() {
std::thread t([]{
p.get_future().wait(); //阻塞到被唤醒
react();
});
// detect
p.set_value(); //唤醒t线程
t.join();
}
很完美吗? 也不是,在future和promise之间的共享状态变量使用了是动态分配的,所以必须承担基于堆的分配和销毁开销。
而且最大的问题是std::promise只能设置一次。所以你应该只用在单次事件通知上。
注意事项:
简单的条件变量需要一个互斥量,这在检测任务和处理任务之间加入了限制,并且需要处理任务判断事件是否发生。
依靠标记变量的设计避免了上面问题,但设计基于轮询而不是阻塞
将条件变量和标记变量结合起来可以解决问题,但实现起来有点生硬
使用std::future 以及std::promise可以解决问题,但使用了堆上内存,而且限制在一次性通讯。