文档管理中心

C++11中condition_variable的陷阱


本文意译自 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

点赞
收藏
回复
6
分享
举报
浏览3642 编辑于2019-10-22 01:48未知归属地
全部评论
最多点赞
最新发布
最早发布
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
  • 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
  • 如您发布的内容为转载内容,请注明内容来源。

我要发帖子

了解社区公约,与您携手共创和谐专业的开发者社区。