本文意译自 C++ Core Guidelines: Be Aware of the Traps of Condition Variables
之前在学习 C++11 的 condition_variable 时,看到了很多博客对其基本知识的介绍,对于其概念及使用有了一定的了解。参考文后链接,在此对各作者表示感谢。
但是看完文章后,对于所谓的虚假唤醒(Spurious wakeup),还是不明就里。直到看到本文,仔细研究之后才明白来龙去脉,特此将文章采用自己的语言表达出来,作为以后回忆的载体。
背景知识
condition_variable 的使用场景比较单一:
一个线程做一些准备工作,然后发送通知告知另一个正在等待的线程。
The C++ core guideline 的第 42 条中提到:不要在没有条件的情况下等待(Don't wait without a condition)。这条准则是什么意思呢?
这要从 condition_variable 的 wait 方法说起。wait 方法有两个变种:
1)void wait (unique_lock<mutex>& lck);
2)template <class Predicate> void wait (unique_lock<mutex>& lck, Predicate pred);
这里说的不要在没有条件的情况下等待就是说不要直接使用方法 1,要么使用方法 2,指定 pred;要么使用 while (!pred()) wait(lck); 的方式。当然,二者其实是等价的。
正确的使用方法
这里,首先将正确的使用方法(这里使用方法 2)给出来:
// conditionVariables.cpp
#include <condition_variable>
#include <iostream>
#include <thread>
std::mutex mutex_;
std::condition_variable condVar;
bool dataReady{false};
void waitingForWork(){
std::cout << 'Waiting ' << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady; }); // (4)
std::cout << 'Running ' << std::endl;
}
void setDataReady(){
{
std::lock_guard<std::mutex> lck(mutex_);
dataReady = true;
}
std::cout << 'Data prepared' << std::endl;
condVar.notify_one(); // (3)
}
int main(){
std::cout << std::endl;
std::thread t1(waitingForWork); // (1)
std::thread t2(setDataReady); // (2)
t1.join();
t2.join();
std::cout << std::endl;
}这里的同步机制是如何工作的呢?
1)main 线程首先启动两个线程,具体工作分别在 waitingForWork 和 setDataRead 中定义(1、2 行)。
2)setDataRead 线程首先在 mutex 的保护下,将 dataReady 置 true,然后使用函数 notify_noe 发送通知给另外一个等待的线程。
3)waitingForWork 线程获取锁,并等待通知。
这里注意一下使用了两种不同的锁,setDataRead 函数中使用的是 std::lock_guard,因为它的效率相比 std::unique_lock 更高,且这里只需要 1 次加锁和 1 次解锁。而 waitingForWork 中使用了 std::unique_lock,是因为这里需要多次对 mutex 进行加锁和解锁操作(原因在 wait 的运行机制中解释)。
程序可能的运行结果如下:
$ ./a.out
Waiting
Running
Data prepared唤醒丢失(Lost Wakeup)和虚假唤醒(Spurious Wakeup)
看到这儿,你也许会有疑惑,我们明明拥有一个只需要一个参数的 wait 函数,为什么在这里需要 predict?增加这个判断,使得本来异常简洁的过程变得稍微有点复杂。这里涉及到两个概念:唤醒丢失和虚假唤醒。
唤醒丢失:在等待线程进入 wait 状态之前,发送线程就发送了通知。这种情况下,等待线程的 wait 再也收不到通知,只能死等。
虚假唤醒:在某些平台(譬如 POSIX 或者 Windows 上),会产生虚假唤醒(原因未深究)。就是说,并不是发送线程发送的通知,等待线程确得到了通知。此时等待线程不再等待,而是继续执行下面的代码。我们前面说过,发送线程一般会做一些准备工作,这些准备工作是等待线程能够工作的前提条件。发送线程还没有发送通知,说明这些准备工作没有完成,此时等待线程就会面临无事可干的情况。
Wait 函数的深层机制
前面有提到,wait (lck, pred); 其实等价于 while (!pred()) wait(lck);,它的运行机制如下:
1)线程获取 mutex 的锁,然后对 predicate 的结果进行检查:
true:线程继续往下执行;
false: condVar.wait() 解锁 mutex,然后线程进入等待(阻塞)状态。
2)假如 condVar 已经在等待状态,此时得到通知(不管发送线程发送,还是虚假唤醒):
线程进入非阻塞状态,然后重新获取 mutex 的锁。
线程检查 predicate 的结果:
true:线程继续往下执行;
false:condVar.wait() 解锁 mutex,然后线程进入等待(阻塞)状态。
那么上面的代码是如何解决唤醒丢失和虚假唤醒问题的呢?
1)唤醒丢失:也就是发送线程执行完了所有代码,然后发送通知之后,等待线程才走流程。此时发现 dataReady 已经为 true,等待线程可以继续往下执行。
2)虚假唤醒:假如先执行等待线程,并且等待线程检查 dataReady 为 false,则会进入阻塞状态。假设此时有虚假唤醒,等待线程首先进入非阻塞状态,重新获取 mutex 的锁,接下来它会去检查 dataReady 是否为 true。由于是虚假唤醒,说明发送线程并没有执行,dataReady 还是 false,此时 condVar.wait() 解锁 mutex,然后线程进入新一轮的等待(阻塞)状态,直至真正的唤醒到来。
可以写下面这样的一份错误代码,来验证上面的分析。
// conditionVariableWithoutPredicate.cpp
#include <condition_variable>
#include <iostream>
#include <thread>
std::mutex mutex_;
std::condition_variable condVar;
void waitingForWork(){
std::cout << 'Waiting ' << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck); // (1)
std::cout << 'Running ' << std::endl;
}
void setDataReady(){
std::cout << 'Data prepared' << std::endl;
condVar.notify_one(); // (2)
}
int main(){
std::cout << std::endl;
std::thread t1(waitingForWork);
std::thread t2(setDataReady);
t1.join();
t2.join();
std::cout << std::endl;
}运行结果如下:
$ taskset -c 0 ./a.out
Data prepared
Waiting
(并不会结束)出现上面的结果,就是因为在等待线程在进入等待状态之前,发送线程已经将通知发出。等待线程无法等到通知,就会一直处于等待状态,无法往下继续执行。
使用atomic
也许你已经注意到了,变量 dataReady 就是一个简单的 bool 型变量。我们能否让它变成 atomic 的变量,从而去除发送线程中的 mutex 呢?
代码如下:
// conditionVariableAtomic.cpp
#include <atomic>
#include <condition_variable>
#include <iostream>
#include <thread>
std::mutex mutex_;
std::condition_variable condVar;
std::atomic<bool> dataReady{false};
void waitingForWork(){
std::cout << 'Waiting ' << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady.load(); }); // (1)
std::cout << 'Running ' << std::endl;
}
void setDataReady(){
dataReady = true;
std::cout << 'Data prepared' << std::endl;
condVar.notify_one();
}
int main(){
std::cout << std::endl;
std::thread t1(waitingForWork);
std::thread t2(setDataReady);
t1.join();
t2.join();
std::cout << std::endl;
}上面的代码看起来像是避免了一次加锁,对性能有一定的优化。但是事实上,还是存在竞争条件(race condition),从而导致死锁。上面代码的行 1 等价与如下代码:
std::unique_lock<std::mutex> lck(mutex_);
while ( ![]{ return dataReady.load(); }() {
// time window (1)
condVar.wait(lck);
}这里分析一下有 mutex 和没有 mutex 的保护的不同:
1)没有 mutex:在上述代码中标注的 // time window (1) 处,也就是 condVar 还没有进入等待状态前,发送线程是可以执行完的,假如此时发送通知,通知会丢失。
2)有 mutex:在 // time window (1) 时刻,等待线程持有 mutex 的锁,发送线程此时不能够获取锁,也就无法执行到 notify_one 函数。当等待线程进入等待状态后,线程释放锁,此时发送线程可以获取锁,修改 dataReady 的值并且调用 notify_one 函数发送通知。
本文只是我为了更好地理解文章而写(能够写出来说明理解得差不多了),强烈建议大家学习原文,不仅可以获得知识,也可以学习方家的写作技巧。
参考:
std::condition_variable::wait
C++11 Multithreading – Part 7: Condition Variables Explained
C++11 并发指南五(std::condition_variable 详解)
——————————————————————————
BY:HW-LH





































苏公安网备 32011402010933号