文章目录
- 【Linux下】 线程同步 生产者与消费者模型
- 线程同步
- 同步概念与竞态条件
- 条件变量
- 条件变量本质
- 操作条件变量
- 初始化和销毁条件变量
- 等待
- 唤醒
- 通过条件变量实现的简单线程同步例子
- 为什么pthread_cond_wait需要互斥锁
- 条件变量使用规范
- 生产者与消费者模型
- 生活中的生产者与消费者模型: 消费者 -- 超市--厂商
- 321 理解生产者与消费者模型
- 基于堵塞队列实现生产者与消费者模型
【Linux下】 线程同步 生产者与消费者模型
线程同步
同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步;同步也可以理解为解决线程饥饿问题的一种机制
**竞态条件:**因为时序问题,而导致程序异常,我们称之为竞态条件。
条件变量
理解:
条件变量就是某种临界资源所处状态的一种数据化表示 ,通过条件变量,就可知道该临界资源是否就绪
举个例子帮助理解条件变量:
例:
小王知道最新的iphone手机出来了,就打算去iphone手机店看看有没有货;于是小王就跑到手机店去问柜员,而柜员告诉他没有到货,小王很想要新出的iphone,所以小王要想知道iphone什么时候到货,很明显有俩种方法:1. 每天跑到手机店去询问柜员 2. 将自己的电话留给柜员,等货到了,就让柜员通知小王
显而易见,肯定是第二种方法更加省时省力;而第一种情况就像只有互斥锁的情况下,线程获取临界区资源状态的方法(即,每次线程都得获取互斥锁,然后进入临界区,访问临界资源,查看临界资源是否就绪,不就绪又将互斥锁释放,离开临界区);第二种方法就是,使用条件变量后的线程获取临界资源状态的方法,即线程进入临界区后,检测到条件未就绪时,就直接在对应的条件变量处进行等待(本质是将自己挂起),而当临界区资源就绪了即条件满足了,该线程就会被唤醒–而临界资源肯定不可能是自己就绪的,一定是其他线程对临界资源进行操作,使得条件满足了,而后以某种方式唤醒了等待的线程
我们再将例子扩张一下,我们知道想要iphone手机的肯定不止小王一人,所以肯定有很多人给柜台留了“电话”–(也就是会有很多线程在条件变量下等待),而柜台为了每个人都能买到iphone手机,所以就不可能在手机到货时只给一个人打电话,而是尽量给每个人打电话,让每个人都买得到iphone手机
而从上面我们大概能知晓,条件变量实现线程间同步的原理–通过条件变量,一次通知一个线程,让每个线程都能享受到临界资源,而不是让一个线程或者几个线程霸占临界资源–也即,不管线程的竞争锁能力强还是弱,先进入临界区访问临界资源的线程(竞争锁能力强),检测到临界资源状态不就绪,就会先到对应的条件变量下等待,后进入临界区的线程,也是如此;即从一开始通过竞争锁能力来争取资源,变成了集体在条件变量下等待资源,也即形成了线程间同步
注:查询临界资源状态本质上也是一种访问临界资源的行为 ,因为只有进入临界区之后,我们才能知道临界资源的状态,
条件变量本质
条件变量底层简化理解,其实可以理解为c语言中的一个结构体
status:为1 则表示临界资源状态就绪 ,为0即表示临界资源状态为就绪(逻辑理解)
即信号变量里面实际上维护了一个等待队列的,等待该条件变量就绪的线程的控制块就会在该队列里;所以通过条件变量实现线程间同步的本质就是,线程通过在条件变量中等待资源,而不是通过竞争锁能力强获取资源
所以条件变量是有俩种行为的:1. 等待 – 即线程等待指定条件变量(实质是在该条件变量中的等待队列里) 2. 唤醒 --(条件就绪,等待队列里的线程被唤醒)
小结:
- 通过条件变量可以得知临界资源的就绪状态
- 条件变量是有俩种行为的:等待,和通知(唤醒)
- 条件变量也是临界资源 ,线程只有进入了临界区才能访问到
操作条件变量
初始化和销毁条件变量
初始化和销毁
phread_cond_init
int pthread_cond_init(pthread_cond_t *__restrict__ __cond, const pthread_condattr_t *__restrict__ __cond_attr)
**作用:**初始化条件变量
参数:
cond: 条件变量名
attr: 基础属性,一般设为空即可
pthread_cond_destroy
int pthread_cond_destroy(pthread_cond_t *__cond)
**作用:**销毁信号变量
参数:
- cond:所需要销毁的信号量名
等待
pthread_cond_wait
int pthread_cond_wait(pthread_cond_t *__restrict__ __cond, pthread_mutex_t *__restrict__ __mutex)
作用:
1.调用该函数会自动将锁释放掉,而后将自己挂起,允许其他线程抢夺锁,等待被其他线程唤醒(当条件满足时)–自动释放锁的原因是:如果不释放锁将自己挂起,就会造成死锁问题,导致其他线程一直在等不可能得到的资源
2.重新抢夺到锁之后,该函数才返回–线程本身是在临界区中被挂起的,所以醒来时就在临界区里,所以该线程不持有互斥锁显然是不合理的
参数:
- cond: 所需等待的条件变量
- mutex: 访问临界资源的互斥锁
返回值:
成功返回0,失败返回错误码
唤醒
pthread_cond_signal
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *__cond)
作用: pthread_cond_signal的作用是唤醒正在对应条件变量的等待队列里的第一个线程;pthread_cond_broadcast的作用是唤醒等待队列里的所有线程 --本质都是将对应的信号量中的状态由0置1
参数
- cond: 就绪状态的条件变量
返回值:
成功返回0,失败返回错误码
通过条件变量实现的简单线程同步例子
例:实现一个boss控制3个员工工作的代码,员工等待老板的发号进行工作
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
pthread_cond_t cond;
pthread_mutex_t mtx;
#define NUM 3
void * ctlworker(void * args)
{
while(1)
{
cout<<"worker begin working"<<endl;
pthread_cond_signal(&cond);
sleep(1);
}
}
void * work(void *args)
{
int id=*(int *)args;
delete (int*)args;
while(1)
{
pthread_cond_wait(&cond,&mtx);
cout<<"worker["<<id<<"] are working ..."<<endl;
sleep(1);
}
}
int main()
{
pthread_cond_init(&cond,nullptr);
pthread_mutex_init(&mtx,nullptr);
pthread_t boss;
pthread_t worker[NUM];
pthread_create(&boss,nullptr,ctlworker,nullptr);
for(int i=0; i< NUM ; i++)
{
int* id=new int (i);
pthread_create(&worker[i],nullptr,work,(void*)id);
}
//线程等待
pthread_join(boss,nullptr);
for (int i=0 ;i< NUM ;i++)
{
pthread_join(worker[i],nullptr);
}
pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mtx);
return 0;
}
运行结果:
我们发现员工工作的次序是固定的即0-2-1 ;本质上就是因为条件变量中的等待对列,而一个员工完成一次打印之后,是会继续到该队列的队尾继续进行等待的
为什么pthread_cond_wait需要互斥锁
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作**,改变共享变量,使原先不满足的条件变得满足**,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据
- 线程进入临界区是需要获取互斥量的,进入到临界区之后,才能查询临界区资源就绪状态,而查询到该资源未就绪,就会在该条件变量下挂起等待,而此时我们的线程是带着锁的,如果不将锁释放掉就会造成死锁问题
- 所以phread_cond_wait的作用还有,调用该函数的线程在被挂起之前,会自动将锁释放掉;
- 而当该函数返回时,因为线程是在临界区被挂起的,所以线程被唤醒时是需要携带锁的,因此pthread_cond_wait函数只有在获得锁之后才会返回
总结:
- 条件变量是用来完成线程间同步的,而互斥量是完成线程间互斥的,条件变量需要配合互斥量来使用
条件变量使用规范
错误示范
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作。调用解锁之后,pthread_ cond_ wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_ cond_ wait将错过这个信号,可能会导致线程永远阻塞在这个pthread_ cond_ wait。所以解锁和等待必须是一个原子操作。
- int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量释放掉,允许其他线程申请互斥量,而后将当前线程挂起,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样(使当前线程再次携带互斥量)。
条件变量使用规范
- 等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
- 给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
小结:
- 将条件变量的”等待“设置在临界区(加锁和解锁的代码之间)之外,可能会导致线程错过条件变量的就绪情况,错过这么一次被**”唤醒“的机会**
- 条件变量需要配合互斥锁使用,“唤醒”和“等待”的代码逻辑都应该在临界区内
生产者与消费者模型
生活中的生产者与消费者模型: 消费者 – 超市–厂商
在现实生活中,我们普通消费者购买商品都是去超市和商店(或者是网上超市);而为什么不是跑到生产商品的工厂去购买商品呢? --这是人类社会的进步,早期的主流肯定是消费者和生产商直接进行交互的;而消费者和生产商直接交互的效率太低了(例如:我们现在为了买几根火腿肠然后跑到生产火腿肠的工厂去购买,这合理吗,这显然是不合理的,再者,工厂一般都分布在比较偏远的地方,消费者直接跑到工厂的成本太大),于是商店就诞生了;商店可以更高效的服务消费者,更好协调生产商和消费者之间的关系(商店收集用户需求,然后向对应的厂商拿货,并且一般的商店都会有存货,即使生产商的生产某些步骤出了问题,商店短时间内也不会受到影响,消费者短时间内的消费需求也不会受到影响);
也就是说超市实际上是社会发展的产物,是用来提高消费者和生产商之间交互的效率的…;
超市的好处:
- 将生产和消费的过程进行解耦:即消费者并不需要关系生产者的生产情况,只需购物就好了,生产者并不需要关心消费者的消费情况,因为有超市做了隔离层(理想情况下)
- 收集消费者的需求,为消费者提供更好的服务
321 理解生产者与消费者模型
而我们也知道,计算机上的代码实际上就是我们现实生活中的映射,计算机就是为了提高人们的生活质量而在不断发展的;
计算机世界里的生产者与消费者模型
人的角色转化到计算机世界里,可以看作是一个一个线程(CPU的最小调度单位),所以在计算机世界里的生产者和消费者实际上就是一个个线程
逻辑图理解:
转化到计算机世界的角度:
消费者的角色就由一个个消费资源的线程来充当,生产者的角色就由一个个生产“某些资源”的线程充当,而超市实际上就是磁盘中的一段内存(而内存呈现的方式不一(以不同的数据结构呈现),就形成不同场景下的生产消费模型例如:以队列的方式:堵塞队列,环形队列,以链表的方式呈现…)
而我们知道该**“超市”一定是能被所有的生产者和消费者所看到的,所以“超市”就一定是临界资源**
而我们也知道线程和线程之间是存在互斥和同步…等关系,所以计算机世界中的消费者和消费者,生产者和生产者,以及生产者和消费者之间肯定是存在一定的关系的
- 消费者和消费者的关系:(现实当中的竞争关系)–线程之间的互斥关系; 解释:因为超市是临界资源,为了保证线程安全问题,一次只能有一个消费线程到“超市”进行消费,所以消费线程之间是互斥的
- 生产者与生产者的关系:(现实当中的竞争关系)–线程之间的互斥关系;解释:同上面一样,每次只允许一个生产线程进入临界区放置商品,所以生产线程之间是互斥的
- 生产者与消费者的关系:线程之间的同步与互斥关系 ;解释:互斥–生产者和消费者本质上都是线程,在访问临界资源时,俩者肯定时存在互斥关系(如果一个消费线程在临界区进行消费的同时,另一个生产线程正在临界区进行生产的工作,就可能会出现线程安全问题);同步– 只有生产线程生产出资源了,消费线程才有资源消费;而通常“超市”都是有大小限制的,即生产线程将“超市”塞满了,生产线程继续生产就没有意义了,所以需要生产线程停下来等待消费线程进行消费,只有消费者线程将资源消费了,生产线程继续生产才有意义,不然就是资源浪费,所以生产线程与消费线程之间需要同步关系。
而将生产者与消费者模型抽象出来,其实可以使用‘321’去帮我们理解和记忆它:
- 3种关系:
- 消费者与消费者:互斥
- 生产者与生产者:互斥
- 生产者与消费者:互斥与同步
- 俩种角色:
- 生产者
- 消费者
- 一个“交易场所”(“超市”)
- 通常是以一段内存的形式的呈现
基于堵塞队列实现生产者与消费者模型
实现思路:
- 使用互斥锁和信号变量维护三种关系
注:下面只展示了单生产者与单消费者模型,多生产者与多消费者模型只需增加生产和消费线程即可
运行主逻辑代码:
#include "BlockQueue.hpp"
#include <unistd.h>
#include <ctime>
#include <iostream>
using namespace Lsh_CP;
void *comsumer(void *bq)
{
//comsumer producer 处必须使用指针 才能保证俩个函数访问的队列是同一对列
BlockQueue<int>* c=(BlockQueue<int>*)bq;
while(1)
{
int data;
c->Pop(&data);
std::cout<<"买家正在消费商品"<<data<<std::endl;
//sleep(1);
}
}
void *producer(void *bq)
{
BlockQueue<int>* p=(BlockQueue<int>*)bq;
while(1)
{
int good=rand()%20+1;
p->Push(good);
std::cout<<"生产者生产了商品"<<good<<std::endl;
sleep(1);
}
}
int main()
{
srand((unsigned)time(nullptr));
BlockQueue<int>* queue=new BlockQueue<int>;
pthread_t c,p;
//创建线程
pthread_create(&c,nullptr,comsumer,(void*)queue);
pthread_create(&p,nullptr,producer,(void*)queue);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete queue;
return 0;
}
堵塞队列实现代码:
#pragma once
#include <queue>
#include <iostream>
#include <pthread.h>
namespace Lsh_CP
{
//const T& 输入型参数 T* 输出型参数
const int de_cap=5;
template<class T>
class BlockQueue
{
private:
std::queue<T> _bq;
pthread_cond_t is_full; //容量为满时,就是消费者苏醒的信号
pthread_cond_t is_empty; //容量为空时,就是生产者苏醒工作的时候
pthread_mutex_t mutex; //互斥量,保证临界资源的原子性,
int _cap; //当前容量
private:
bool Is_full()
{
return _bq.size()==_cap;
}
bool Is_empty()
{
return _bq.size()==0;
}
void wake_producer()
{
pthread_cond_signal(&is_full);
}
void wake_comsumer()
{
pthread_cond_signal(&is_empty);
}
public:
BlockQueue(int capacity=de_cap):_cap(capacity)
{
pthread_mutex_init(&mutex,nullptr);
pthread_cond_init(&is_full,nullptr);
pthread_cond_init(&is_empty,nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&is_full);
pthread_cond_destroy(&is_empty);
}
void Push(const T& in)
{
//临界区
pthread_mutex_lock(&mutex);
if(Is_full()) //判断是否需要生产者生产
{
pthread_cond_wait(&is_full,&mutex);
}
_bq.push(in);
//唤醒消费者
if(_bq.size()>_cap/2) wake_comsumer();
pthread_mutex_unlock(&mutex);
}
void Pop(T *out)
{
pthread_mutex_lock(&mutex);
if(Is_empty())
{
pthread_cond_wait(&is_empty,&mutex);
}
*out=_bq.front();
_bq.pop();
//唤醒生产者
if(_bq.size()<_cap/2) wake_producer();
pthread_mutex_unlock(&mutex);
}
};
}
运行结果:
几点说明:
-
唤醒消费者或生产者的代码,放在临界区外和临界区内都可
- 放在unlock()之前,被唤醒的线程也得先竞争锁,即需要等当前线程退出临界区将锁释放后,被唤醒的线程重新竞争到锁,才会真正地在临界区中苏醒,所以不会有线程安全问题
- 放在unlock()之后,即当前线程是先退出临界区将锁释放后,才唤醒的线程,但被唤醒的线程依旧需要重新竞争到锁,才会真正地在临界区中苏醒,所以也不会有线程安全问题
- 即俩者的区别实际上就是:被唤醒的线程是在等待锁释放后去竞争锁,还是锁已经被释放了再去竞争锁;就效率而言,第一种方法可能效率会更高
-
控制消费者和生产者之间的协同关系,只需控制主逻辑中的comsumer和producer方法即可:
- 生产者快,消费者慢:comsumer代码逻辑中不用使用sleep() ,producer代码逻辑中使用sleep();就会出现,生产者在等待消费者消费的这样一种协同机制
其他场景类似,只需要控制comsumer和producer中的代码逻辑即可
而其实我们之前所学进程间通信的管道其实就是典型的生产者与消费者模型;
区外和临界区内都可
-
放在unlock()之前,被唤醒的线程也得先竞争锁,即需要等当前线程退出临界区将锁释放后,被唤醒的线程重新竞争到锁,才会真正地在临界区中苏醒,所以不会有线程安全问题
-
放在unlock()之后,即当前线程是先退出临界区将锁释放后,才唤醒的线程,但被唤醒的线程依旧需要重新竞争到锁,才会真正地在临界区中苏醒,所以也不会有线程安全问题
-
即俩者的区别实际上就是:被唤醒的线程是在等待锁释放后去竞争锁,还是锁已经被释放了再去竞争锁;就效率而言,第一种方法可能效率会更高
-
控制消费者和生产者之间的协同关系,只需控制主逻辑中的comsumer和producer方法即可:
- 生产者快,消费者慢:comsumer代码逻辑中不用使用sleep() ,producer代码逻辑中使用sleep();就会出现,生产者在等待消费者消费的这样一种协同机制
其他场景类似,只需要控制comsumer和producer中的代码逻辑即可
而其实我们之前所学进程间通信的管道其实就是典型的生产者与消费者模型;