更多互联网新鲜资讯、工作奇淫技巧关注原创【飞鱼在浪屿】(日更新)
背景介绍
本文假设你对多线程的互斥量/mutex和条件变量/condition variables有所了解,并且用过C++。这里将用现代C++并发写一个无bug的生产消费队列。
mutex一般用在保护共享资源和数据上,同时使多个线程完成其工作。
有时还需要及时同步它们之间的操作,那么使用指示任务是否已完成的标记变量。比如希望线程等到某个条件为真,然后再允许它继续执行。那这个可以用condition variables实现。
为什么我们需要condition variables
想象一下不使用条件变量怎么解决问题。
线程可以循环,不断不断地检查互斥量,直到线程拿到了可以进入关键区代码的互斥。然后结束检查,继续执行。
此方法有几个问题:
- 效率很低,因为循环让 Cpu 保持忙绿
- mutex意味着在一个时间点只有一个线程可以进行操作。
- 其他线程将没有可用资源得到执行
标准库提供了等待发生特定事件的机制 。一个变量与特定事件关联,线程可以让这个变量等待事件发生。在此等待期间,线程已睡眠,即不占用任何 CPU时间片。当事件实际发生时,能够唤醒等待的线程!
条件变量
条件变量可用于阻止(条件变量本身不占用任何资源)一个或多个线程,直到其他线程修改共享变量的状态(受mutex锁保护),然后通知一个或多个阻塞的线程状态已经更改,唤起阻塞的线程。、
条件变量与 配合使用,这意味着您需要同时使用 两者来实现所需的行为。打算对共享数据进行操作的所有线程必须遵循以下流程顺序执行:
- 获取,在用于保护共享变量的同一个mutex,获取std::unique_lock<:mutex>
- 执行 、或在条件变量上执行 。等待操作(等待本身是原子调用,wait,wait_for,wait_until)挂起暂停线程的执行,等待兼具释放mutex的语义。
- 当条件变量被通知、超时过期或发生虚假唤醒时,线程将唤醒,并且以原子方式重新获取mutex。然后,线程应检查条件,如果唤醒是虚假的(后面有说),则继续等待。
生成消费队列模型
为了展示它的用法,假设您有一个固定长度的S队列和两组线程:
- 生成者,仅在队列尚未满时。放入数据到队列
- 使用者,队列存在数据时,从队列中弹出元素。
正如你了解是,生产者可能需要停止工作并等待队列具有一些可用空间,而使用者则可能需要停止,等待队列数据不为空。每当生产者推送到队列中时,它可以通知任何等待的使用者该队列不再为空。同样,每当使用者成功从队列中弹出一个元素时,它可以通知任何生产者该队列不再已满。
我们将编写一个线程安全的类std::queuesafe_queue,来包装上述行为。类包含以下两种方法
- enqueue()
- dequeue()
和四个私有字段:
- max_size是队列的最大长度。
- mutable std::mutex mtx。mutable的作用是让const函数也可以修改mutex。需要mutex是因为在读取队列的时候,其他线程可能同时写入队列。即使我们没有真正修改队列,也可能会修改到队列长度size。
- condition_variable
- std::queue
以下是线程安全队列的整个代码。
template struct safe_queue { safe_queue(const size_t _max_size) : max_size(_max_size), mtx(), condition(), queue() {} size_t empty() const { std::unique_lock<:mutex> l(mtx); return queue.empty(); } size_t size() const { std::unique_lock<:mutex> l(mtx); return queue.size(); } void enqueue(T t) { std::unique_lock<:mutex> l(mtx); while (queue.size() >= max_size) { condition.wait(l); } queue.push(t); l.unlock(); condition.notify_all(); } T dequeue() { std::unique_lock<:mutex> l(mtx); while (queue.empty() ) { condition.wait(l); } T val = queue.front(); queue.pop(); l.unlock(); condition.notify_all(); return val; } private: uint max_size; mutable std::mutex mtx; std::condition_variable condition; std::queue queue;};
代码分析
我们应该注意到的第一件事是,现在我们使用std::unique_lock<:mutex> 作为锁。 (即std::lock_guard)。 std::unique_lock构造的时候加锁, 销毁的时候解锁。这种操作也称为RAII机制
enqueue入队操作
该方法由生产者使用,它非常简单:生产者首先获取mutex(我们需要确保在其他线程读取或写入队列时不修改队列),然后等待,直到队列有空间加入一个元素。此处要记住的关键部分是,当条件变量将线程置于睡眠状态时,将释放队列(condition.wait(l);), 以便其他线程可以在队列上操作。另一个重要事实是,每当线程被唤醒时,它都会确保拥有锁。
wait()也可能被虚假地唤醒线程,这意味着当条件未满足时,线程可以唤醒,即使没有线程发出条件变量信号,线程也可能从等待状态唤醒。这就是为什么以下代码会 嵌入在 while 循环中的原因:
while (queue.size() >= max_size) { condition.wait(l);}
每当线程被唤醒时,它自动获得锁,因此我们确信没有别的线程正在使用队列,可以安全地检查队列是否具有空间容纳新元素,如果没有,则意味着已被虚假通知唤醒,我们再次进入睡眠状态。
最后,当队列有空间使用新元素时,我们可以继续执行下一个指令(我们目前仍然拥有锁),即push一个元素到队列,然后释放锁(此时我们完成了关键部分),并通知可能wait在条件变量上的所有其他线程。
queue.push(t);l.unlock();condition.notify_all();return;
dequeue出队操作
和入队工作方式类似,即首先获取锁,检查我们是否具有先决条件,以便能够从队列中弹出一个元素,如果没有先决条件,使用条件变量进入睡眠状态。
一旦被条件变量环境,我们自然拥有锁,我们可以继续在队列中操作,从而最终弹出一个元素,并通知所有其他线程,队列中数据有变化更新。
还有另一种wait的方式,预测一个判断签名是真的,才会结束wait。
template< class Predicate >void wait( std::unique_lock<:mutex>& lock, Predicate pred );
Predicate返回真或假,一般写成 lambda 函数)这样写等同于一开始的写法
while (!pred()) { wait(lock);}
然后我们可以重写函数enqueue()和dequeue()如下
void enqueue(T t) { std::unique_lock<:mutex> l(mtx); condition.wait(l , [this](){ return queue.size() < max_size; }); queue.push(t); l.unlock(); condition.notify_all(); } T dequeue() { std::unique_lock<:mutex> l(mtx); condition.wait(l , [this](){ return !queue.empty; }); T val = queue.front(); queue.pop(); l.unlock(); condition.notify_all(); return val; }
这里没有了一开始的显式循环写法,减轻了检查虚假唤醒的负担。每当从这个wait返回,我们确信这是真的。
![a4c08beddb4877529a9e94960cb126ba.png](https://i-blog.csdnimg.cn/blog_migrate/058e0ad9f8e93f807fac3395c1d43aaa.jpeg)