条件变量中的伪唤醒和唤醒丢失问题
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的函数内部逻辑进行模拟验证,这会更加加深同学们对条件变量的正确使用和合理使用。