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(),由于生产者线程一直在生产产品,那么极端情况下队列可能一直不为空,则不会唤醒其他消费者线程消费产品,而是一直使用同一个消费者线程消费产品。这就会导致有一个消费者线程一直在消费产品,而其他的消费者线程永远无法消费产品。

posted @ 2022-10-06 20:44  好人~  阅读(2208)  评论(0编辑  收藏  举报