C++条件变量陷阱:信号丢失与虚假唤醒解析 | 实战经验

C++条件变量陷阱:信号丢失与虚假唤醒解析 #

引言 #

想必大家开发过程中都会用到多线程,用到多线程基本上都会用到条件变量,你理解的条件变量只是简单的 wait 和 notify 吗,最近工作中看同事也都只是简单的使用 wait 和 notify,导致项目出现 bug 却不知如何 fix bug,其实这里面还是有一些坑的,程序喵这里总结给大家。

一、什么是条件变量? #

条件变量是多线程程序中用来实现等待和唤醒逻辑常用的方法。通常有 wait 和 notify 两个动作,wait 用于阻塞挂起线程 A,直到另一个线程 B 通过通过 notify 唤醒线程 A,唤醒后线程 A 会继续运行。

条件变量在多线程中很常用,在有名的生产者和消费者问题中,消费者如何知道生成者是否生产出了可以消费的产品,通过 while 循环不停的去判断是否有可消费的产品?众所周知,死循环极其消耗 CPU 性能,所以需要使用条件变量来阻塞线程,降低 CPU 占用率。

二、条件变量的使用 #

拿生产者和消费者问题举例,看下面这段代码: #

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。

如何解决这个问题呢?可以附加一个判断条件,就可以解决这种 信号丢失 问题,见代码:

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

void Consumer() {
  std::unique_lock<std::mutex> lock(mutex);
  if (vec.empty()) { // 加入此判断条件
      cv.wait(lock);
  }
  std::cout << "consumer " << 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(Consumer);
  t.detach();
  Produce();
  return 0;
}

通过增加附加条件可以解决信号丢失的问题,但这里还有个地方需要注意,消费者线程处于 wait 阻塞状态时,即使没有调用 notify,操作系统也会有一些概率会唤醒处于阻塞的线程,使其继续执行下去,这就是 虚假唤醒 问题,当出现了虚假唤醒后,消费者线程继续执行,还是没有可以消费的数据,出现了 bug。

那怎么解决虚假唤醒的问题呢,可以在线程由阻塞状态被唤醒后继续判断附加条件,看是否满足唤醒的条件,如果满足则继续执行,如果不满足,则继续去等待,体现在代码中,即将 if 判断改为 while 循环判断,见代码:

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

void Consumer() {
  std::unique_lock<std::mutex> lock(mutex);
  while (vec.empty()) { // 将 if 改为 while
      cv.wait(lock);
  }
  std::cout << "consumer " << 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(Consumer);
  t.detach();
  Produce();
  return 0;
}

看到这里相信你已经明白条件变量的使用啦,需要使用 while 循环附加判断条件来解决条件变量的信号丢失和虚假唤醒问题。

三、有没有更简单的"避坑"方式 #

难道我们每次都必须要使用 while 循环和附加条件来操作条件变量吗?这岂不是很麻烦?

NO!

在 C++ 中其实有更好的封装,只需要调用 wait 函数时,在参数中直接添加附加条件就好了,内部已经做好了 while 循环判断,直接使用即可,见代码:

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

void Consumer() {
  std::unique_lock<std::mutex> lock(mutex);
  cv.wait(lock, [&]() { return !vec.empty(); }); // 这里可以直接使用 C++ 的封装
  std::cout << "consumer " << 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(Consumer);
  t.detach();
  Produce();
  return 0;
}

但在 C 语言中就没办法啦,大家只能自己做一层封装啦。

四、为什么条件变量需要和锁配合使用? #

为什么叫条件变量呢?

因为内部是通过判断及修改某个全局变量来决定线程的阻塞与唤醒,多线程操作同一个变量肯定需要加锁来使得线程安全。同时,一个简单的 wait 函数调用内部会很复杂的,有可能线程 A 调用了 wait 函数但是还没有进入到 wait 阻塞等待前,另一个线程 B 在此调用了 notify 函数,此时 notify 的信号就丢失啦,如果加了锁,线程 B 必须等待线程 A 释放了锁并进入了等待状态后才可以调用 notify,继而防止信号丢失。