条件变量中的伪唤醒和唤醒丢失问题

  C++11标准库的条件变量为我们实现多线程直接通信带来的变量,如果对其提供的函数使用不当会给程序带来隐藏的问题。比如:伪唤醒和唤醒丢失问题。

一、什么是伪唤醒和唤醒丢失

  先看代码如何使用条件变量:

 1  std::condition_variable cv;
 2  std::mutex gMtx;
 3  
 4 void Sender()
 5 {
 6      std::cout << "Ready Send notification." << std::endl;
 7      cv.notify_one();   // 发送通知
 8  }
 9  
10 void Receiver()
11 {
12      std::cout << "Wait for notification." << std::endl;
13      std::unique_lock<std::mutex> lck(gMtx);
14      cv.wait(lck);    // 等待通知并唤醒继续执行下面的指令
15      std::cout << "Process." << std::endl;
16 }
17  
18  int main() 
19  {
20      std::thread sender(Sender);
21      std::thread receiver(Receiver);
22      sender.join();
23      receiver.join();     
24      return 0;
25 }

  我们在主线程中开启了两个线程,分别是:通知线程和接收线程。一般情况下,接收线程在调用条件变量的wait函数时解锁并让线程挂起,当通知线程调用条件变量的notify_once或notify_all函数时,等待线程会被唤醒并自动上锁,继续执行后面的指令。看似没有毛病的代码逻辑,却存在严重的隐患。其中一种是线程随机启动导致的唤醒丢失,即:通信线程先启动并调用通知函数,但是接收线程还没有开始执行等待函数,如果不再次调用函数通知,等待会一直持续下去。这个是最容易发现和验证的问题,上面的主线程中启动线程的顺序就会概率性出现唤醒丢失的问题。我们可以模拟丢失情况(只需要让接收线程阻塞下)验证如下:

      

  伪唤醒顾名思义就是:通知线程还没有调用通知函数前,接收线程就从等待中唤醒了,继续执行后面的指令,导致业务逻辑出现问题。由于这个伪唤醒并不是代码编写的逻辑导致,所以实际很难出现。我们可以使用条件变量提供的wait_for函数模拟:

 1 void Sender()
 2 {
 3     // 阻塞10秒,后才发通知
 4     std::this_thread::sleep_for(std::chrono::seconds(10));
 5     std::cout << "Ready Send notification." << std::endl;
 6     cv.notify_one();
 7 }
 8 
 9 void Receiver()
10 {
11     std::cout << "Wait for notification." << std::endl;
12     std::unique_lock<std::mutex> lck(gMtx);
13     cv.wait_for(lck, std::chrono::seconds(2));  // 模拟假唤醒
14     std::cout << "Process." << std::endl;
15 }

  验证效果如下:

       

二、如何解决伪唤醒和唤醒丢失问题

  C++标准库的条件变量总共提供了三个等待唤醒函数:wait、wait_for和wait_until,都分别提供带判断式的重载函数。

  1.wait函数

  上面的代码已经验证wait的非判断式版本无法解决上面的问题,所以wait_for和wait_until的非判断式版本也无法正常解决问,仅仅不会让等待一直持续,但是这样会导致逻辑出现问题。

  wait的判断式可以完美解决上面的问题,为什么?

        要想弄清楚为什么,需要知道wait判断式处理逻辑是什么。这里不进行深入探讨,直接给出结论:

 调用wait判断式函数的时候,进行如下逻辑处理:
 1.如果判断式返回真,直接返回wait函数;否则挂起当前线程进入等待并解锁,等待其他通知线程通知
 2.接收到其他通知线程发送的通知,再次执行步骤1.

  验证:

  1.判断式为真,不需要通知线程,结束等待:

 1 void Sender()
 2 {
 3     std::this_thread::sleep_for(std::chrono::seconds(5));
 4     std::cout << "Ready Send notification." << std::endl;
 5     cv.notify_one();
 6 }
 7 
 8 void Receiver()
 9 {
10     std::cout << "Wait for notification." << std::endl;
11     std::unique_lock<std::mutex> lck(gMtx);
12     cv.wait(lck, []() {return true; }); 
13 
14     std::cout << "Process." << std::endl;
15 }

  

  2.接收线程等待过程中锁是解开的:

 1 void Sender()
 2 {
 3     std::this_thread::sleep_for(std::chrono::seconds(5));
 4     std::unique_lock<std::mutex> lck(gMtx);
 5     std::cout << "Ready Send notification." << std::endl;
 6     cv.notify_one();
 7 }
 8 
 9 void Receiver()
10 {
11     std::unique_lock<std::mutex> lck(gMtx);
12     std::cout << "Wait for notification." << std::endl;
13     cv.wait(lck, []() {return false; });  // 会一直阻塞下去
14 
15     std::cout << "Process." << std::endl;
16 }

  

  3.接收到通知线程通知,但是判断式为假,继续阻塞:

 1 void Sender()
 2 {
 3     std::unique_lock<std::mutex> lck(gMtx);
 4     std::cout << "Ready Send notification." << std::endl;
 5     cv.notify_one();
 6 }
 7 
 8 void Receiver()
 9 {
10     std::unique_lock<std::mutex> lck(gMtx);
11     std::cout << "Wait for notification." << std::endl;
12     cv.wait(lck, []() {return send; });   // send未设置为true,一直阻塞
13 
14     std::cout << "Process." << std::endl;
15 }

  

  4.接收到通知线程通知,判断式为真,结束等待,加锁:

 1 void Sender()
 2 {
 3     std::unique_lock<std::mutex> lck(gMtx);
 4     std::cout << "Ready Send notification." << std::endl;
 5     send = true;
 6     cv.notify_one();
 7 }
 8 
 9 void Receiver()
10 {
11     std::unique_lock<std::mutex> lck(gMtx);
12     std::cout << "Wait for notification." << std::endl;
13     cv.wait(lck, []() {return send; });
14     try{
15         lck.lock();                      // 验证已经加锁了.
16     }
17     catch (const std::exception& e){
18         std::cout << "locker is locked. e <" << e.what() << ">" << std::endl;
19     }
20     std::cout << "Process." << std::endl;
21 }

  

   通过上面的验证,说明了wait带判断式函数的处理逻辑是正确的。

  解决伪唤醒:如果通知线程没有发生通知前,发生伪唤醒的时候,wait函数会再次检查判断式是否为真,如果为真,就认为通知线程发送了通知;否则继续等待通知;

  解决唤醒丢失:如果通知线程先发生了通知,接收线程后执行wait函数时,会检查判断式是否为真,如果为真,就认为通知线程发送了通知;否则继续等待通知;

  所以,判断式内部实现很重要且线程安全的。

  2.wait_for函数

  上面的对wait函数的介绍已经可以知道wait_for非判断式函数是不能解决上面的问题,下面是wait_for判断式函数解决上面的问题:

 1 void Sender()
 2 {
 3     std::unique_lock<std::mutex> lck(gMtx);
 4     std::cout << "Ready Send notification." << std::endl;
 5     send = true;
 6     cv.notify_one();
 7 }
 8 
 9 void Receiver()
10 {
11     std::unique_lock<std::mutex> lck(gMtx);
12     std::cout << "Wait for notification." << std::endl;
13     while (!cv.wait_for(lck, std::chrono::seconds(1), []() {return send; })) { // 这里设置超时等待时间,如果超时继续并且send=false,继续等待.
14         std::cout << "wait timeout." << std::endl;
15     }
16     std::cout << "Process." << std::endl;
17 }

  通过分析也可以解决上面的问题,但是相对于wait带判断式函数的处理方式,性能不够好,代码比较冗余。

  3.wait_unitl函数

  wait_unitl函数和wait_for函数类似,wait_until是等待时间点,wait_for等待时间段。所以wait_until的非判断式函数无法解决上面的问题,wait_until的判断式函数可以,就是循环判断超时的时间点一定要同步更新,类似wait_for函数的处理方式,但是实现逻辑更加复杂,性能更加不好。

三、总结

  通过研究条件变量的伪唤醒和唤醒丢失问题的同时,也把条件变量相关的函数熟悉了一遍,尤其是对判断式wait的函数内部逻辑进行模拟验证,这会更加加深同学们对条件变量的正确使用和合理使用。

参考Condition Variables - ModernesCpp.com

posted @ 2021-07-03 21:51  blackstar666  阅读(3052)  评论(0编辑  收藏  举报