一、生产者消费者模型
1 .生产者消费者模型的概念
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题
- 生产者和消费者彼此之间不直接通讯,而通过容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中。
- 消费者也不用找生产者索要数据,而是直接从这个容器中取数据。
- 简单理解为 供应商 → 超市 → 消费者 。
- 容器就类似于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器完成了生产者和消费者之间的解耦
2 .生产者消费者模型的特点
- 三种关系: 生产者和生产者(互斥)、消费者和消费者(互斥)、生产者和消费者(互斥、同步)
- 两种角色: 生产者和消费者(通常由进程或线程承担)
- 一个交易场所: 通常指的是内存中的一段缓冲区(可以自己通过某种方式组织)
问题:生产者和生产者、消费者和消费者、生产者和消费者,它们之间为什么会存在互斥关系?
- 介于生产者和消费者之间的容器可能会被多个执行流同时访问,因此需要将该临界资源用互斥锁保护起来。
- 所以所有生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系
问题:生产者和消费者之间为什么会存在同步关系?
- 若一直让生产者生产,那么当生产者生产的数据装满容器后,生产者再生产数据就会生产失败。
- 如果让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。
- 虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。
- 所以应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。
互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来,两者不冲突
3 . 生产者消费者模型优点
1. 解耦
-
生产者只负责生产数据,消费者只负责消费数据,两者之间互不影响。
-
从代码层面看,生产者线程和消费者线程的代码并不直接互相调用,两者的代码在发生变化不会对对方造成影响。
2. 支持并发,提高效率
- 在消费者从缓冲区拿取数据后的处理数据期间,生产者可以同时进行生产对缓冲区添加数据。
- 如果没有缓冲区,消费者得直接去找生产者要数据,就必须等待生产真产生数据,同理,生产者也需要等待消费者消费数据。
3. 支持忙闲不均
- 在缓冲区未满时,生产者和消费者互不影响,不会产生占用 CPU 时间片的问题;
- 在缓冲区已满时,生产者则不再生产数据。在缓冲区空时,消费者则不再消费数据,使得两者总体处于一种动态平衡的状态。
二、基于BlockingQueue的生产者消费者模型
1 .阻塞队列概念
- 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。
其与普通的队列的区别在于:
- 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素
- 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出
阻塞队列经典的应用场景:管道
2 . 模拟实现基于阻塞队列的生产消费模型
- 根据 C++ 中stl库中的的 queue 容器实现一个阻塞队列。
- 为了方便讲解,实现的是一个单生产者、单消费者的模型。
- 当生产者线程把阻塞队列填满时,通知消费者线程消费;当消费者线程把阻塞队列搬空时,通知生产者线程生产。
- 当然也可以不这么极端,可以设置一个标准线,当阻塞队列中的数据量低于标准线的时候,让生产者生产数据高于标准线的时候,让消费者消费数据。
- 这里就实现 全空 / 全满 这种极端做法演示
- 注:由生产者线程通知消费者线程消费,由消费者线程通知生产者线程生产。
- 当消费者线程发现阻塞队列空了之后,就要通知生产者线程生产数据,然后消费者去指定条件变量处等待。
- 当生产者线程发现阻塞队列满了之后,就要通知消费者线程消费数据,然后生产者去指定条件变量处等待。
#pragma once
#include <iostream>
#include <pthread.h>
#include <string>
#include <queue>
#include <unistd.h>
#include "Task.hpp"
//设置默认容量
const int defaultcap = 5;
template <class T>
class BlockQueue
{
//判断队列是否为空
bool IsFull()
{
return _max_cap == _block_queue.size();
}
//判断队列是否满
bool IsEmpty()
{
return _block_queue.empty();
}
public:
//构造函数,完成相关的初始化,锁的初始化
BlockQueue() : _max_cap(defaultcap)
{
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&_p_cond, nullptr);
pthread_cond_init(&_c_cond, nullptr);
}
// 一个生产者,生产者进行添加任务
void Equeue(const T &in)
{
pthread_mutex_lock(&mutex);
sleep(1);
// 注意这里是用while判断是否为空,不能用if,
// 因为如果多个执行流都在wait处进行等待的话,很可能唤醒线程之后,其他生产者已经生产满了
while (IsFull())
{
pthread_cond_wait(&_p_cond, &mutex);
}
_block_queue.push(in);
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&_c_cond);
}
// 假设:多个消费者
void Pop(T *out)
{
pthread_mutex_lock(&mutex);
sleep(1);
//这里用while与上面生产者类似,防止别唤醒的时候数据被其他线程拿走,队列中依然为空
while (IsEmpty())
{
pthread_cond_wait(&_c_cond, &mutex);
}
*out=_block_queue.front();
_block_queue.pop();
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&_p_cond);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
}
private:
std::queue<T> _block_queue;
int _max_cap;
//容器共用一把锁,因为生产者与生产者,消费者与消费者都是竞争关系。不能同时进入队列(容器)
//而条件变量生产者用生产者的,消费者用消费者的
pthread_mutex_t mutex;
pthread_cond_t _p_cond;
pthread_cond_t _c_cond;
};
生产者消费者完成任务函数:
void *Consumer(void*args)
{
BlockQueue<Task>* bq=static_cast<BlockQueue<Task>*>(args);
while(true)
{
//获取数据
Task task;
bq->Pop(&task);
task();
std::cout<<task.result()<<std::endl;
}
std::cout<<" Consumer excute done "<<std::endl;
return nullptr;
}
void *Productor(void*args)
{
srand((unsigned int)time(nullptr)^getpid());
BlockQueue<Task>*bq=static_cast<BlockQueue<Task>*>(args);
while(true)
{
//制造数据
int x=rand()%1000;
usleep(1000);
int y=rand()%1000;
Task t(x,y);
bq->Equeue(t);
std::cout<<"productor make a task"<<std::endl;
}
std::cout<<" Consumer excute done "<<std::endl;
return nullptr;
}
演示:
由于代码中生产者是每隔一秒生产一个数据,而消费者是每隔一秒消费一个数据,因此运行代码后我们可以看到生产者和消费者的执行节奏是大致一致的
问题:生产者速度快,消费者速度慢会出现什么情况
- 由于生产者生产的很快,运行代码后一瞬间生产者就将阻塞队列装满。
- 此时生产者想要再进行生产就只能在full条件变量下进行等待,直到消费者消费完一个数据后,生产者才会被唤醒进而继续进行生产。
- 生产者生产完一个数据后又会进行等待,因此后续生产者和消费者的节奏又变成一致的了
问题:生产者速度慢,消费者速度快会出现什么情况
- 消费者消费的快,但开始时阻塞队列中是没有数据的,因此消费者只能在empty条件变量下等待。
- 直到生产者生产完一个数据后,消费者才会被唤醒进而进行消费。
- 消费者消费完这一个数据后又会进行等待,因此生产者和消费者的节奏就是一致的。
三、POSIX信号量
1 .信号量概念
- POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
- 可能会被多个执行流同时访问的资源(代码)被称为临界资源,临界资源需要进行保护否则会出现数据不一致等问题
- 当仅用一个互斥锁对临界资源进行保护时,相当于将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问(串行访问)
- 实际上可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,若这些执行流访问的是临界资源的不同区域,那么可以让这些执行流同时访问临界资源的不同区域,此时并不会出现数据不一致等问题
POSIX信号量的本质就是一个计数器,它是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理(PV操作)
- 假如当前有一个可以容纳 10 个人的动物园,那么信号量的初始值就是 100,表示博物馆可提供的资源数(容纳人数)。
- 每当进去一个人时,信号量的值就减 1;每当出去一个人时,信号量的值就加 1。
- 当信号量的值为 0 时,说明博物馆已经不能再进去人了,那么让后面的游客 (线程) 一直等到信号量不为 0。
-
执行流在进入临界区之前,都应该申请信号量(P操作),申请成功后才具备操作临界资源的权限,当操作完毕后也应该释放信号量(V操作)。
- P操作:将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器 -1
- V操作:释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器 +1
信号量的 PV 操作必须是原子的
- 与竞争锁类似。多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,即信号量本质也是临界资源。
- 但信号量本质就是用于保护临界资源的,所以信号量的PV操作必须是原子操作
注意: 内存当中变量的自增、自减操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行自增、自减操作
申请信号量失败被挂起等待
- 当执行流在申请信号量时,可能此时信号量的值为0,即信号量描述的临界资源已全部被申请了,此时该执行流就应该在该信号量的等待队列中进行等待,直到有信号量被释放时被唤醒
注意: 虽然信号量的本质是计数器,但他不意味着仅仅只有计数器,信号量还包括一个等待队列
2 .POSIX 信号量函数
- 信号量的数据类型是
sem_t
,可以使用该类型定义信号量。 - 信号量函数的返回值:调用函数成功时返回 0,失败时返回 - 1。
- 在使用信号量函数之前,应该先使用
#include <semaphore.h>
引入库文件。
初始化信号量
#include <semaphore.h>
int sem_init(
sem_t *sem,
int pshared,
unsigned int value);
//sem:需要初始化的信号量
//pshared:传入0值表示线程间共享,传入非零值表示进程间共享
//value:信号量的初始值(计数器的初始值)
返回值:初始化信号量成功返回0,失败返回-1
- POSIX信号量与System V信号量作用相同,都用于同步操作,达到无冲突的访问共享资源目的,但POSIX信号量可用于线程间同步
销毁信号量
#include <semaphore.h>
int sem_destroy(sem_t *sem);
// sem 表示要销毁的信号量
返回值:销毁信号量成功返回0,失败返回-1
申请信号量 (等待信号量)——P操作
- 申请信号量 (等待信号量) 就是 PV 操作中的 P 操作。
- 注:申请信号量的 P 操作应该在线程争锁之前进行,只有在确定有资源的情况下才让线程去争锁。
#include <semaphore.h>
int sem_wait(sem_t *sem);
// sem 需要等待的信号量
返回值:
- 等待信号量成功返回0,信号量的值减一
#include <semaphore.h> int sem_post(sem_t *sem); // sem 表示线程需要释放的信号量
- 等待信号量失败返回-1,信号量的值保持不变
释放信号量 (发布信号量)——V操作
- 释放信号量 (发布信号量) 就是 PV 操作中的 V 操作。
- 注:释放信号量的 V 操作应该在线程解锁之后进行。
#include <semaphore.h>
int sem_post(sem_t *sem);
// sem 表示线程需要释放的信号量
返回值:
- 发布信号量成功返回0,信号量的值加一
- 发布信号量失败返回-1,信号量的值保持不变
四、基于RingQueue的生产者消费者模型
1 .环形队列的概念
- 环形队列与阻塞队列最大的不同就是,环形队列能够构成一个环。
- 在环形队列中,生产者和消费者一开始可以指向同一个位置,启动之后,让生产者生产数据,消费者跟在后面消费数据。
2 . 空间资源与数据资源
生产者关注的是空间资源,消费者关注的是数据资源
对于生产者与消费者而言,关注的资源是不同的:
- 生产者关注的是环形队列当中是否有空间(space),只要有空间生产者就可以进行生产
- 消费者关注的是环形队列当中是否有数据(data),只要有数据消费者就可以进行消费
3 . 环形队列的规则
1. 生产者和消费者不能访问同一个位置
- 如果生产者和消费者访问的是环形队列中相同的位置,就可能会出现数据不一致的问题。
- 消费者想拿旧数据,生产者新生产出的数据可能会将该位置旧有的数据给覆盖掉,但消费者又不知道自己拿到的是新的数据。
2. 生产者不能超过消费者一圈及以上
- 如果生产者线程跑的太快了,绕一圈回来撞上在后面拿数据的消费者线程,如果生产者此时还不停下来,就可能会访问同一个位置覆盖掉之前的数据,造成数据丢失。
3.消费者不能超过生产者
- 消费者已经将环形队列中的数据消费完了,此时消费者已经追上了生产者。
- 如果消费者想超过生产者继续往前走,前面就不会有数据可供消费者消费了。
- 就算前面有数据,消费者拿到的也是之前用过的旧数据,并不是新数据。
总结:无论是生产者还是消费者,都不应该将对方套一个圈以上
4 . 生产者消费者并发访问环形队列的场景
以下情况会导致生产者和消费者访问同一个位置,不能实现并发访问
-
环形队列为空:消费者消费了一圈追上了生产者,生产者和消费者访问的是同一个位置,此时消费者应该停止消费,让生产者去生产。
-
环形队列为满:生产者生产力一圈追上了消费者,生产者和消费者访问的是同一个位置,此时生产者应该停止生产,让消费者去消费。
生产者和消费者不访问同一位置时,可以实现并发访问
- 生产者在前面生产,消费者在后面追,两者之间的距离小于一圈。
- 这样可以让生产者一直能够获取空间资源,让消费者一直能够获取数据资源。
5 .资源的申请与释放
生产者申请空间资源,释放数据资源
对于生产者来说,生产者每次生产数据前都需要先申请_space_sem:
- 若_space_sem的值不为0,则信号量申请成功,此时生产者可以进行生产操作
- 若_space_sem的值为0,则信号量申请失败,此时生产者需要在_space_sem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒
当生产者生产完数据后,应释放_data_sem:
- 虽然生产者在进行生产前是对_space_sem进行的P操作,但是当生产者生产完数据,应该对_data_sem进行V操作而不是_space_sem
- 生产者在生产数据前申请到的是空间位置,当生产者生产完数据后,该位置当中存储的是生产者生产的数据,在该数据被消费者消费之前,该位置不再是blank位置,而是data位置
- 当生产者生产完数据后,意味着环形队列当中多了一个data位置,因此应该对_data_sem进行V操作
消费者申请数据资源,释放空间资源
对于消费者来说,消费者每次消费数据前都需要先申请_data_sem:
- 若_data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作
- 若_data_sem的值为0,则信号量申请失败,此时消费者需要在_data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒
当消费者消费完数据后,应该释放_space_sem:
- 虽然消费者在进行消费前是对_data_sem进行的P操作,但是当消费者消费完数据,应该对_space_sem进行V操作
- 消费者在消费数据前申请到的是data位置,当消费者消费完数据后,该位置当中的数据已经被消费过了,再次被消费就没有意义了,为了让生产者后续可以在该位置生产新的数据,应该将该位置算作blank位置,而不是data位置
- 当消费者消费完数据后,意味着环形队列当中多了一个blank位置,因此应该对_space_sem进行V操作
6 .模拟实现基于环形队列的生产消费模型
- 想要判断环形队列是否为 空 / 满,可以通过计数器。也可以预留一个位置作为满的状态。
- 在了解了信号量之后,就可以使用信号量作为环形队列中资源数量的计数器。
- 可以定义两个信号量,分别是给生产者用的空间资源信号量,以及给消费者用的数据资源信号量。
- 当前要实现的是让生产者生产者先生产两个随机数并且放在结构体当中,然后让消费者消费这个随机数(做加法),生产者与消费者之间就差一个身位。
- RingQueue就是生产者消费者模型当中的交易场所,可以用C++STL库中的vector进行模拟
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <semaphore.h>
#include <vector>
//默认的最大容量
const int max_cap=5;
template <class T>
class RingQueue
{
//将PV操作封装为函数
void P(sem_t &tmp)
{
sem_wait(&tmp);
}
void V(sem_t &tmp)
{
sem_post(&tmp);
}
public:
//进行环形队列的初始化
RingQueue(int max_cap) :_max_cap(max_cap), _c_step(0), _p_step(0)
{
//初始化数组大小
_ring_queue.resize(_max_cap);
//初始化数据个数,开始为0
sem_init(&_data_sem, 0, 0);
//初始化空间个数,最开始可用空间为最大空间
sem_init(&_space_sem, 0, _max_cap);
//初始化两把锁
pthread_mutex_init(&_c_mutex,nullptr);
pthread_mutex_init(&_p_mutex,nullptr);
}
//生产者从事生产的函数
void Push(T &in)
{
// 先保证有空间再去竞争锁进行生产
P(_space_sem); //——V操作 空间 -1
pthread_mutex_lock(&_p_mutex);//拿到锁
_ring_queue[_p_step++] = in;
_p_step %= _max_cap;
pthread_mutex_unlock(&_p_mutex);//释放锁
V(_data_sem);// V操作 数据 +1
}
//消费者进行消费的函数
void Pop(T *out)
{
//先保证有数据,再去竞争锁去消费
P(_data_sem);//——P操作,数据 -1
pthread_mutex_lock(&_c_mutex);//拿到锁
*out = _ring_queue[_c_step++];
_c_step %= _max_cap;
pthread_mutex_unlock(&_c_mutex);//释放锁
V(_space_sem);//空间 +1
}
//释放锁,信号量等一系列事情
~RingQueue()
{
sem_destroy(&_data_sem);
sem_destroy(&_space_sem);
pthread_mutex_destroy(&_c_mutex);
pthread_mutex_destroy(&_p_mutex);
}
private:
std::vector<T> _ring_queue;
int _max_cap;
//用来表示队头与队尾
int _c_step;
int _p_step;
//定义两个信号量
sem_t _data_sem;
sem_t _space_sem;
//生产消费者各自调用自己的函数,各自用各自的锁
pthread_mutex_t _c_mutex;
pthread_mutex_t _p_mutex;
};
生产函数,与消费函数:
void *Consumer(void*args)
{
RingQueue<Task>* rq=static_cast<RingQueue<Task>*>(args);
while(true)
{
Task task;
rq->Pop(&task);
task();
std::cout<<task.result()<<std::endl;
}
}
void*Productor(void*args)
{
RingQueue<Task>*rq=static_cast<RingQueue<Task>*> (args);
while(true)
{
sleep(1);
int x=(rand()+getpid())%100;
int y=(rand()+(666^getpid()))%100;
Task task(x,y);
rq->Push(task);
std::cout<<"productor make a data"<<std::endl;
}
}
演示:
通过_space_sem和_data_sem两个信号量的保护,该环形队列中不可能会出现数据不一致的问题
只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
- 环形队列为空时
- 环形队列为满时
但在这两种情况下,生产者和消费者不会同时对环形队列进行访问:
- 当环形队列为空的时,消费者一定不能进行消费,因为此时数据资源为0
- 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源为0
结论:
- 当环形队列为空和满时,已经通过信号量保证了生产者和消费者的串行化过程。
- 除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置。
- 因此大部分情况下该环形队列可以让生产者和消费者并发的执行