《C++ 并发编程实战 第二版》:条件变量唤醒丢失与虚假唤醒


本文主要是对《C++ 并发编程实战 第二版》第 4 章中条件变量部分做进一步探究,主要内容为使用条件变量时可能会碰到的两个坑:唤醒丢失与虚假唤醒

目录

  • ​​《C++ 并发编程实战 第二版》:条件变量唤醒丢失与虚假唤醒​​
  • ​​推荐阅读​​
  • ​​唤醒丢失​​
  • ​​唤醒丢失情况1:缺少条件​​
  • ​​唤醒丢失情况2:没有搭配锁​​
  • ​​虚假唤醒​​
  • ​​什么是虚假唤醒?​​
  • ​​虚假唤醒情况 1:notify_one 但是多线程争抢​​
  • ​​虚假唤醒情况 2 : 系统原因​​
  • ​​小结​​
  • ​​参考资料​​

唤醒丢失

唤醒丢失情况1:缺少条件

例子:来自参考资料2

std::mutex mutex;
std::condition_variable cv;
std::vector<int> vec;

void Consume() {
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock);
std::cout << "consume " << vec.size() << "\n";
}

void Produce() {
std::unique_lock<std::mutex> lock(mutex);
vec.push_back(1);
cv.notify_all();
std::cout << "produce \n";
}

int main() {
std::thread t(Consume);
t.detach();
Produce();
return 0;
}

如果先执行的Produce(),后执行的Consume(),生产者提前生产出了数据,去通知消费者,但是此时消费者线程如果还没有执行到wait语句,即线程还没有处于挂起等待状态,线程没有等待此条件变量上,那通知的信号就丢失了,后面Consume()中才执行wait处于等待状态,但此时生产者已经不会再触发notify,那消费者线程就会始终阻塞下去,出现bug。
---- 程序喵大人

唤醒信号是一次性的且存在丢失可能,因此我们需要根据实际情况决定是否要等待

  • 如果先执行的Produce(),后执行的Consume(),队列中已经有东西了,那么就不用等待了
  • 否则,队列开始的那么还是要等待

因此我们需要给等待加上条件(下面的代码是有问题的!!!)

std::mutex mutex;
std::condition_variable cv;
std::vector<int> vec;

void Consume() {
std::unique_lock<std::mutex> lock(mutex);
if (vec.empty()) { // 加入此判断条件,但这样虚假唤醒的问题!!!
cv.wait(lock);
}
std::cout << "consume " << vec.size() << "\n";
}

void Produce() {
std::unique_lock<std::mutex> lock(mutex);
vec.push_back(1);
cv.notify_all();
std::cout << "produce \n";
}

int main() {
std::thread t(Consume);
t.detach();
Produce();
return 0;
}

这样就能解决信号丢失的问题,但是使用 ​​if​​来进行条件判断虚假唤醒的问题,这个在后面虚假唤醒的部分解决。建议暂时跳过唤醒丢失情况 2:没有搭配锁 这部分内容,先去看​虚假唤醒​这部分内容

唤醒丢失情况2:没有搭配锁

例子:来自参考资料4

class Foo {
condition_variable cv;
mutex mtx;
int k = 0;
public:
void first(function<void()> printFirst) {
printFirst();
k = 1;
cv.notify_all(); // 通知其他所有在等待唤醒队列中的线程
}

void second(function<void()> printSecond) {
unique_lock<mutex> lock(mtx); // lock mtx
cv.wait(lock, [this](){ return k == 1; }); // unlock mtx,并阻塞等待唤醒通知,需要满足 k == 1 才能继续运行
printSecond();
k = 2;
cv.notify_one(); // 随机通知一个(unspecified)在等待唤醒队列中的线程
}

void third(function<void()> printThird) {
unique_lock<mutex> lock(mtx); // lock mtx
cv.wait(lock, [this](){ return k == 2; }); // unlock mtx,并阻塞等待唤醒通知,需要满足 k == 2 才能继续运行
printThird();
}
};


作者:zintrulcre
链接:https://leetcode.cn/problems/print-in-order/solution/c-hu-chi-suo-tiao-jian-bian-liang-xin-hao-liang-yi/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

上面是力扣多线程题目 1114. 按序打印 的一片题解的条件变量解法部分,评论区中有人指出这个做法存在问题。
考虑下面的情况

《C++ 并发编程实战 第二版》:条件变量唤醒丢失与虚假唤醒_条件变量


由于在修改 k 的共享内存的时候没有加锁,导致线程 2 检查条件 k == 1 发现结果为 false 然后决定等待的过程中 k 的值发生改变。条件变量开始等待之前 ​​k = 1​​(检查条件失效) 并且唤醒信号已经被发送(唤醒丢失)

class Foo {
mutex mtx;
condition_variable cv;
int k = 0;

public:
Foo() {
}

void first(function<void()> printFirst) {
// printFirst() outputs "first". Do not change or remove this line.
lock_guard<mutex> lock(mtx);
printFirst();
k = 1;
cv.notify_all();
}

void second(function<void()> printSecond) {

// printSecond() outputs "second". Do not change or remove this line.
unique_lock<mutex> lock(mtx);
cv.wait(lock, [this](){ return k == 1;});
printSecond();
k = 2;
cv.notify_one();

}

void third(function<void()> printThird) {

// printThird() outputs "third". Do not change or remove this line.
unique_lock<mutex> lock(mtx);
cv.wait(lock, [this](){ return k == 2;});
printThird();
}
};

虚假唤醒

什么是虚假唤醒?

参考资料 5(机翻了一下建议看原文)
当线程从等待已发出信号的条件变量中醒来,却发现它等待的条件未得到满足时,就会发生虚假唤醒。之所以称为虚假,是因为该线程似乎无缘无故地被唤醒了。但是虚假唤醒不会无缘无故发生:它们通常是因为在发出条件变量信号和等待线程最终运行之间,另一个线程运行并更改了条件。线程之间存在竞争条件,典型的结果是有时,在条件变量上唤醒的线程首先运行,赢得竞争,有时它运行第二,失去竞争。
在许多系统上,尤其是多处理器系统上,虚假唤醒的问题更加严重,因为如果有多个线程在条件变量发出信号时等待它,系统可能会决定将它们全部唤醒,将每个signal( )唤醒一个线程视为broadcast( )唤醒所有这些,从而打破了信号和唤醒之间任何可能预期的 1:1 关系。如果有 10 个线程在等待,那么只有一个会获胜,另外 9 个会经历虚假唤醒。
为了让实现在处理操作系统内部的错误条件和竞争时具有灵活性,即使没有发出信号,也可以允许条件变量从等待中返回,尽管目前尚不清楚有多少实现实际上这样做了。在条件变量的 Solaris 实现中,如果进程发出信号,则可能发生虚假唤醒而没有发出条件信号;等待系统调用中止并返回EINTR。条件变量的 Linux p-thread 实现保证它不会那样做。
因为只要有竞争甚至可能在没有竞争或信号的情况下都可能发生虚假唤醒,因此当线程在条件变量上唤醒时,它应该始终检查它所寻求的条件是否得到满足。如果不是,它应该回到条件变量上睡觉,等待另一个机会。

虚假唤醒情况 1:notify_one 但是多线程争抢

例子:来自参考资料1

std::condition_variable cv;
std::mutex mx;

void thread1()
{
while (true) {
// do some work ...
std::unique_lock<std::mutex> lock(mx);
cv.notify_one(); // wake other thread
}
}

void thread2()
{
while (true) {
std::unique_lock<std::mutex> lock(mx);
cv.wait(lock); // might block forever
// do work ...
}
}

在这里,如果有其他thread消费者thread1的通知,thread2会永久等待

虚假唤醒情况 2 : 系统原因

有些操作系统为了在处理内部的错误条件和竞争时具有灵活性,即使没有发出信号,也可以允许条件变量从等待中返回。因此下面的代码在某些操作系统会存在问题(也就是唤醒丢失情况 1 当中的改进代码)

linux 系统提供的 pthread 保证不会发生这种情况的虚假唤醒

void Consume() {
std::unique_lock<std::mutex> lock(mutex);
if (vec.empty()) { // 加入此判断条件,但这样虚假唤醒的问题!!!
cv.wait(lock);
}
std::cout << "consume " << vec.size() << "\n";
}

void Produce() {
std::unique_lock<std::mutex> lock(mutex);
vec.push_back(1);
cv.notify_all();
std::cout << "produce \n";
}

我们应当使用 ​​while​​​ 而不是 ​​if​​ 来判断条件

void Consume() {
std::unique_lock<std::mutex> lock(mutex);
while (vec.empty()) { // 使用 while 来判断条件
cv.wait(lock);
}
std::cout << "consume " << vec.size() << "\n";
}

对于 C++ 我们可以直接将条件通过 lambda 的方式传递给条件变量,C++ 内部会自动使用 ​​while​​进行判断

while (vec.empty()) { // 使用 while 来判断条件
cv.wait(lock);
}
// 和下面的等效
cv.wait(lock, [](){ return vec.empty();} );

小结

  • 条件变量必须搭配互斥锁使用
  • 尽可能使用 C++ 提供的带条件的条件变量形式
  • 如果直接使用系统底层的条件变量,要注意唤醒丢失和虚假唤醒这两个坑,最好能进行封装后再使用

参考资料

  1. ​CppCoreGuidelines CP.42 dont-wait-without-a-condition​​ 唤醒丢失情况 1
  2. ​使用条件变量的坑你知道吗​​ 比较全面的介绍,可以看看
  3. ​虚假唤醒​​ 对参考资料1的补充
  4. ​力扣多线程 1114. 按序打印题解​​ 条件变量的解法有唤醒丢失的问题
  5. ​维基百科 虚假唤醒​