C++ 提供了condition_variable条件变量。标准使用模式是要配合mutex和predicate使用。
核心要点:
- 等待方:拿着
unique_lock<mutex>,用cv.wait(lock, predicate)等待 - 通知方:先修改共享状态(同一把 mutex 保护),再
notify_one/notify_all - 永远用 predicate 或 while 循环防止“虚假唤醒”(spurious wakeup)
标准使用模式
条件变量可以解决经典的 生产-消费 模型
1 |
|
wait 函数
在调用wait函数之前,必须要把mutex上锁,否则无法执行后续的释放锁操作。
例如,把设置智能锁的操作改成std::unique_lock ulock(mt,std::defer_lock);,这个有概率会拿到没有上锁的锁,进而导致在wait中报错。
wait 会做三件事(原子地配合条件变量):
- 检查条件(后面的 lambda/predicate)
- 如果条件不满足:
- 释放互斥锁** **
**lk** - 让线程睡眠等待通知(
notify_one/notify_all或者虚假唤醒)
- 释放互斥锁** **
- 被唤醒后:
- 重新加锁
**lk** - 再次检查条件
- 条件满足才返回,否则继续睡
- 重新加锁
wait函数的原型有
1 | void wait(unique_lock<mutex>& _Lck) noexcept |
常用第二个,第二个的实现方式就是:
1 | template <class _Predicate> |
给定一个条件函数,防止出现虚假唤醒。
wait函数还有其他类型,可以设置等待时间的,其用法和mutex的一致。
notify
唤醒函数有两种:
notify_one随机唤醒一个线程notify_all唤醒所有线程
唤醒是指唤醒哪些调用wait函数后挂起的线程,具体使用哪个函数要根据代码设计。一般notify_one适合产生了一个任务需要处理,而notify_all适合当全部线程需要更改状态的情况。
在上面的代码中,我们将lock_guard放在一对大括号内,这里使用了RAII机制,离开大括号自动解除锁,让调用notify_one之前解锁互斥锁, 即在线程觉醒前解锁互斥,算是微小的性能改进。
需要注意的是,ontify_one 在操作系统上并不保证一定只唤醒一个线程,也有可能多个,这个就是伪唤醒
伪唤醒
有些情况下,如果线程重新获得互斥,并且查验条件,而这一行为却不是直接响应线程乙的通知,这个就是伪唤醒
根据C++标准,这种伪唤醒出现的数量和频率都是不确定的,每次唤醒都会执行一次判断函数来检验条件,若这个判断函数有其他的副作用,那么不建议选择它来作为判断条件。(例如判断函数会提高线程优先级,多次意外唤醒会导致线程的优先级非常高)
避免伪唤醒的办法就是添加测试循环,也就是上面wait函数第二种定义,内部实现就含有测试循环。
唤醒丢失
首先我们不能将条件变量的notify_one当成一个事件或者信号。
唤醒丢失是在生产者线程notify_one时,消费者线程还没有进入wait函数进行等待,这个就会造成唤醒丢失。
下面代码就会造成唤醒丢失:
1 | void worker() { |
这种情况很难避免,但是我们可以通过一个谓词来进行判断生产者是否完成了生产。
将wait那句改成 cv.wait(lock, [&]() {return done; });即可。这样即使在worker在等待的10秒中出现了唤醒丢失,程序执行到wait时发现条件符合也会直接获取到锁。