C++ 条件变量(condition_variable)、notify、wait、互斥量(mutex)、同步过程、BlockingQueue<T>和CountDownLatch
条件变量中有以下两类函数:
唤醒函数(直接参考链接):
- notify_one:只唤醒队列中的第一个线程
- notify_all:所有线程被一个一个唤醒,先抢到锁的先唤醒
等待函数:
- wait( std::unique_lock<std::mutex>& lock ):阻塞直到被唤醒
- wait( std::unique_lock<std::mutex>& lock, Predicate pred ):阻塞,但是被唤醒时,如果函数对象pred返回为false,则继续阻塞
1.生产者消费者模式
生产者消费者模式的代码(以下代码参考链接):
#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <windows.h>
#include <condition_variable>
std::mutex mtx; // 全局互斥锁
std::queue<int> que; // 全局消息队列
std::condition_variable cr; // 全局条件变量
int cnt = 1; // 数据
void producer() {
while(true) {
{
std::unique_lock<std::mutex> lck(mtx);
// 在这里也可以加上wait 防止队列堆积 while(que.size() >= MaxSize) que.wait(); // 等待队列大小小于MaxSize
que.push(cnt);
std::cout << "向队列中添加数据:" << cnt ++ << std::endl;
// 这里用大括号括起来了 为了避免出现虚假唤醒的情况 所以先unlock 再去唤醒(这句话应该是错的,
// 我觉得大括号并不能避免虚假唤醒,使用大括号应该只是为了把没有必要用锁进行保护的
// cr.notify_all()放到外面,并且让处于wait线程被唤醒时,直接就能得到锁。所以大括号的目的应
// 该是防止被唤醒线程还需要等待锁才能运行)
// lck是处于大括号内的局部变量,所以在出了大括号时,lck的析构函数会被调用,
// 在lck析构函数中会进行unlock操作来解锁。
// 故unique_lock这种资源管理对象,管理着mtx,要想提前使用unique_lock的析构函数来释放资源,只需要将unique_lock的作用范围用大括号括起来就行
}
cr.notify_all(); // 唤醒所有wait
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lck(mtx);
while (que.size() == 0) { // 这里防止出现虚假唤醒 所以在唤醒后再判断一次
cr.wait(lck);
}
int tmp = que.front();
std::cout << "从队列中取出数据:" << tmp << std::endl;
que.pop();
}
}
int main()
{
std::thread thd1[2], thd2[2];
for (int i = 0; i < 2; i++) {
thd1[i] = std::thread(producer);
thd2[i] = std::thread(consumer);
thd1[i].join();
thd2[i].join();
}
return 0;
}
互斥锁mtx的作用(避免唤醒操作被错过):还未阻塞就收到唤醒操作,从而导致唤醒操作被错过。如果没有互斥锁将会出现以下情况:
wait线程判断完que.size() == 0
不满足,但在阻塞之前notify线程修改了que并执行了notify,wait线程随后才被阻塞,这样wait线程就错过了这次唤醒,这也就是所谓的Lost wakeup问题。
while (que.size() == 0)
的作用(防止虚假唤醒):while (que.size() == 0)
不能替换为if(que.size() == 0)
,这是为了防止虚假唤醒。如果while (que.size() == 0)
替换为if(que.size() == 0)
可能会出现如下结果:
- th2[0]拿完了队列里最后一个产品正在处理,此时队列为空。
- th2[1]想去队列里拿发现已经空了,所以停在了wait上。
- th1[0]拿到mtx后,往队列添加了一个产品,并执行了notify_one通知处于等待状态的消费者。
- 由于收到了notify,th2[1]准备要被调度,但是th2[0]此时恰好处理完了手头的任务,并进行了下一轮循环,抢在th2[1]之前拿到了mtx并取走了th1[0]刚放进去的产品,此时th2[1]被阻塞,随后th2[0]释放了mtx。
- th2[0]释放了mtx后,th2[1]终于拿到了mtx却发现队列又是空的,这就是一次虚假唤醒,对于这种情况th2[1]需要继续wait。要想实现“继续wait”,就需要使用
while (que.size() == 0)
,而不是if(que.size() == 0)
虚假唤醒:被唤醒了,但是资源却被其他线程先抢走了
wait()的第二个形参可用于代替while (que.size() == 0)
:
#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <windows.h>
#include <condition_variable>
std::mutex mtx; // 全局互斥锁
std::queue<int> que; // 全局消息队列
std::condition_variable cr; // 全局条件变量
int cnt = 1; // 数据
void producer() {
while(true) {
{
std::unique_lock<std::mutex> lck(mtx);
// 在这里也可以加上wait 防止队列堆积 while(que.size() >= MaxSize) que.wait();
que.push(cnt);
std::cout << "向队列中添加数据:" << cnt ++ << std::endl;
// 这里用大括号括起来了 为了避免出现虚假唤醒的情况 所以先unlock 再去唤醒
}
cr.notify_all(); // 唤醒所有wait
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lck(mtx);
cr.wait(lck,[]{return que.size() > 0;}); // wait被唤醒以后,如果判断return为假时,继续调用wait
int tmp = que.front();
std::cout << "从队列中取出数据:" << tmp << std::endl;
que.pop();
}
}
int main()
{
std::thread thd1[2], thd2[2];
for (int i = 0; i < 2; i++) {
thd1[i] = std::thread(producer);
thd2[i] = std::thread(consumer);
thd1[i].join();
thd2[i].join();
}
return 0;
}
整个同步(参考自:链接):
实际上,condition_variable变量内部也有一个mutex,用于保护等待列表的修改,假设我们定义的mutex为m_a,cv内部的mutex为m_b,那么所以整个同步过程其实是
wait线程:
获取m_a,判断condition,发现不满足。
调用wait:
(1)获取m_b,获取后notify线程的notify也会被阻塞。
(2)释放m_a。
(3)线程挂到等待列表里。 释放m_b后进行等待。
(4)被唤醒后重新获取m_a进行后续操作。
notify线程:获取m_a,修改condition。
调用notify :
(1)获取m_b 。
(2)释放m_b后,通知等待列表内的线程唤醒。
释放m_a。//这一步可以在notify之前做,也就是notify不需要hold 外部mutex
核心就是wait线程会带着外部的锁来获取等待队列锁,这把队列锁用于:wait线程push线程到等待队列和notify线程进行notify操作。而notify线程修改condition之前也需要获得外部锁,因此只要wait线程先拿到外部锁可以确保也会先拿到等待队列锁,确保了wait线程在确定需要wait但真正push到等待列表之前的这段时间notify线程没法进行notify,避免错过唤醒。
参考自:链接。
2.BlockingQueue<T>
和CountDownLatch
条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如BlockingQueue<T>
或CountDownLatch
。
倒计时(CountDownLatch)是一种常用且易用的同步手段。它主要有两种用途:
-
主线程发起多个子线程,等这些子线程各自都完成一定的任务之后,主线程才继续执行。通常用于主线程等待多个子线程完成初化。
-
主线程发起多个子线程,子线程都等待主线程,主线程完成其他一些任务之后通知所有子线程开始执行。通常用于多个子线程等待主线程发出“起跑”命令
CountDownLatch
的实现如下(参考:链接):
#pragma once
#include<assert.h>
#include<mutex>
#include<atomic>
#include<condition_variable>
//一次性的屏障,必须有count个线程到达(countdown),才将await放行;
class CountDownLatch {
public:
CountDownLatch(int count):count(count){
assert(count > 0);
}
//等待,直至count减为0
void await() {
if (count == 0) return;
std::unique_lock<std::mutex>lock(mx);
cond.wait(lock, [&]() {return count == 0; });
}
//count减1,(最小为0)
void countDown() {
int old_c = count.load();
while (old_c > 0) {
if (count.compare_exchange_strong(old_c, old_c - 1)) {
if (old_c == 1) {//唤醒等待的线程
std::unique_lock<std::mutex>lock(mx);
cond.notify_all();
}
break;
}
old_c = count.load();
}
}
int getCount() {
return count;
}
private:
//计数
std::atomic<int> count;
std::mutex mx;
std::condition_variable cond;
};
参考了muduo的BlockingQueue的实现,用C++11改写(参考链接):
#ifndef _BLOCKINGQUEUE_H_
#define _BLOCKINGQUEUE_H_
#include <mutex>
#include <condition_variable>
#include <deque>
#include <assert.h>
template <typename T>
class BlockingQueue {
public:
using MutexLockGuard = std::lock_guard<std::mutex>;
BlockingQueue()
: _mutex(),
_notEmpty(),
_queue()
{
}
BlockingQueue(const BlockingQueue &) = delete;
BlockingQueue& operator=(const BlockingQueue &) = delete;
void put(const T &x)
{
{
MutexLockGuard lock(_mutex);
_queue.push_back(x);
}
_notEmpty.notify_one();
}
void put(T &&x)
{
{
MutexLockGuard lock(_mutex);
_queue.push_back(std::move(x));
}
_notEmpty.notify_one();
}
T take()
{
std::unique_lock<std::mutex> lock(_mutex);
_notEmpty.wait(lock, [this]{ return !this->_queue.empty(); });
assert(!_queue.empty());
T front(std::move(_queue.front()));
_queue.pop_front();
return front;
}
size_t size() const
{
MutexLockGuard lock(_mutex);
return _queue.size();
}
private:
mutable std::mutex _mutex;
std::condition_variable _notEmpty;
std::deque<T> _queue;
};
#endif // _BLOCKINGQUEUE_H_
3.使用BlockingQueue实现生产者和消费者模型
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <deque>
#include <assert.h>
static BlockingQueue<int> queue;
int cnt = 1; // 数据
void producer() {
while(true) {
queue.put(cnt++);
}
}
void consumer() {
while (true) {
queue.take();
}
}
int main()
{
std::thread thd1[2], thd2[2];
for (int i = 0; i < 2; i++) {
thd1[i] = std::thread(producer);
thd2[i] = std::thread(consumer);
thd1[i].join();
thd2[i].join();
}
return 0;
}
put函数每次添加元素都会调用notify_one(),如果更改为只在队列大小由0到1是调用notify_one(),由于生产者线程一直在生产产品,那么极端情况下队列可能一直不为空,则不会唤醒其他消费者线程消费产品,而是一直使用同一个消费者线程消费产品。这就会导致有一个消费者线程一直在消费产品,而其他的消费者线程永远无法消费产品。