多线程中的虚假唤醒

当一个正在等待条件变量的线程由于条件变量被触发而唤醒时,却发现它等待的条件(共享数据)没有满足。

避免虚假唤醒,就不应该采用 if 条件判断,而应该采用 while 循环判断。

这样,即便生产者唤醒所有消费者,由于消费者这边采用 while 循环判断,确保wait方法会在唤醒后重新检查条件,哪怕 g_deque 中已经没有可消费对象,也不会导致这边出现虚假唤醒。

如果消费者这边采用 if 条件判断,由于生产者唤醒,消费者接收到信号不重新检查g_deque中是否还有可消费对象(有可能已经被其它消费者消费),导致可能出现虚假唤醒。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 虚假唤醒

if (g_deque.empty())
{
g_cond.wait(lck);
}

// 避免虚假唤醒

while (g_deque.empty())
{
g_cond.wait(lck);
}

还有通过Lambda表达式,同样可以避免虚假唤醒。

即在wait方法的第二个参数提供Lambda表达式,如果返回值为true就获取锁往下执行代码。这种代码就不必像前面那样显示地 while 循环来检查条件,从而使代码更加简洁和安全。它确保在条件不满足时继续等待,减少逻辑错误。

1
g_cond.wait(lock, []{ return !g_deque.empty(); });

如上两种写法的产生,就是C++11提供wait的两种方法,只是参数列表不同。

1
2
3
4
void wait (unique_lock<mutex>& lck);

template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);

第一个 wait 方法只有被唤醒才会解除阻塞,但我们通常不会用,因为无法应对虚假唤醒。

第二个 wait 方法的第二个参数 pred 代表一个可调用的对象或函数,它不接受任何参数,并返回一个可以计算为 bool 的值。当 bool 值为 true 的时候,才会解除阻塞。这句话我需要再重申一下,信号来唤醒是无法真正解除阻塞的,真正能让其解除阻塞的是 pred 返回值为 true的时候,正因为如此,它才可以解决虚假唤醒问题。信号可以被忽视,pred 是否为 true 才是唯一判定标准。

我用下面这个例子举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
void SafeQueue<T>::push(T data) {
std::lock_guard<std::mutex> lg(m_mtx);
m_queue.push(std::move(data));
m_have.notify_one();
}

template<typename T>
void SafeQueue<T>::wait_pop(T &data) {
std::unique_lock<std::mutex> ul(m_mtx);
m_have.wait(ul,[this](){
return !m_queue.empty();
}); // m_queue have data
data = std::move(m_queue.front());
m_queue.pop();
}

如果你提前创建 5 个线程 执行 push 方法,等到 队列中数据之后,再创建 5 个线程 执行 wait_pop 方法。你觉得会 wait_pop 成功吗?因为 push 早就执行完成,并且 notify_one。那后面的 push 会因为 没有再也收不到 notify_one 的信号而阻塞吗?

不会!当你调用 wait_pop 的时候,尽管没有收到信号,但是队列不为空,那么 pred 为 true,解除阻塞,继续往下执行。

那你就疑惑了,还要这个信号提醒有啥用?void wait (unique_lock<mutex>& lck, Predicate pred)虽然可以解决虚假唤醒问题,但我们还是应该建立正确的唤醒机制,这边我们是主动去调用 wait_pop ,但是有时候我们建立逻辑关系是被唤醒才会执行这里的 wait_pop。因为你不可能每次都是主动 push,再去主动 wait_pop 吧?往往是 push 成功就 notify_one,然后这边的 wait_pop 被唤醒,检查 pred 情况。push 接口提供给外界,wait_pop 在一个循环中被调用,如果出现虚假唤醒也不会有影响,因为有 pred 做保障。