目录
一、生产者消费者模型的概念
二、生产者消费者模型的特点
三、生产者消费者模型优点
四、基于BlockingQueue的生产者消费者模型
4.1 基本认识
4.2 模拟实现
五、POSIX信号量
5.1 信号量概念
5.2 信号量函数
5.2.1 初始化信号量
5.2.2 销毁信号量
5.2.3 等待信号量
5.2.4 发布信号量
六、二元信号量模拟互斥功能
七、基于RingQueue的生产者消费者模型
7.1 空间资源与数据资源
7.2 资源的申请与释放
7.3 两个规则
7.4 代码实现
7.5 信号量维护环形队列的原理
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题
生产者和消费者彼此之间不直接通讯,而通过容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中;消费者也不用找生产者索要数据,而是直接从这个容器中取数据。容器就类似于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器完成了生产者和消费者之间的解耦
生产者和生产者、消费者和消费者、生产者和消费者,它们之间为什么会存在互斥关系?
介于生产者和消费者之间的容器可能会被多个执行流同时访问,因此需要将该临界资源用互斥锁保护起来。所以所有生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系
生产者和消费者之间为什么会存在同步关系?
若一直让生产者生产,那么当生产者生产的数据装满容器后,生产者再生产数据就会生产失败。
反之,让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。
虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。
注意: 互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来
若在主函数中调用某一函数,那么必须等该函数体执行完后才继续执行主函数的后续代码,因此函数调用本质上是一种紧耦合。对应到生产者消费者模型中,函数传参实际上就是生产者生产的过程,而执行函数体实际上就是消费者消费的过程,但生产者只负责生产数据,消费者只负责消费数据,在消费者消费期间生产者可以同时进行生产,因此生产者消费者模型本质是一种松耦合
在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者消费者模型的数据结构
其与普通的队列的区别在于:
阻塞队列最经典的应用场景:管道
下面以单生产者、单消费者为例进行讲解与实现
#include
#include
#include
template
class BlockQueue
{
public:
BlockQueue(size_t capacity = 4) : _capacity(capacity)
{
pthread_mutex_init(&_mutex,nullptr);
pthread_cond_init(&_full,nullptr);
pthread_cond_init(&_empty,nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_full);
pthread_cond_destroy(&_empty);
}
void push(const T& data)
{
pthread_mutex_lock(&_mutex);
while (IsFull()) {//不能进行生产,直到阻塞队列可以容纳新的数据
pthread_cond_wait(&_full, &_mutex);
}
_queue.push(data);
std::cout << "Producer: " << data << std::endl;
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_empty); //唤醒在empty条件变量下等待的消费者线程
}
void pop(T& data)
{
pthread_mutex_lock(&_mutex);
while (IsEmpty()) {//不能进行消费,直到阻塞队列有新的数据
pthread_cond_wait(&_empty, &_mutex);
}
data = _queue.front();
_queue.pop();
std::cout << "Consumer: " << data << std::endl;
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_full); //唤醒在full条件变量下等待的生产者线程
}
private:
bool IsFull() { return _queue.size() == _capacity; }
bool IsEmpty() { return _queue.empty(); }
private:
std::queue _queue;
size_t _capacity;
pthread_mutex_t _mutex;
pthread_cond_t _full;
pthread_cond_t _empty;
};
判断是否满足生产消费条件时不能用if,而应该用while:
pthread_cond_wait函数有可能调用失败,调用失败后该执行流就会继续往后执行。为了避免出现上述情况,就要让线程被唤醒后再次进行判断,确认是否真的满足生产消费条件,因此这里必须要用while进行判断
生产者消费者步调一致
#include
#include "BlockQueue.hpp"
void* Producer(void* arg)
{
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //生产者不断进行生产
sleep(1);
int data = rand() % 100 + 1;
bq->push(data);
}
}
void* Consumer(void* arg)
{
int data = 0;
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //消费者不断进行消费
sleep(1);
bq->pop(data);
}
}
int main()
{
pthread_t producer,consumer;
BlockQueue* bq = new BlockQueue;
pthread_create(&producer,nullptr,Producer,(void*)bq);
pthread_create(&consumer,nullptr,Consumer,(void*)bq);
pthread_join(producer,nullptr);
pthread_join(consumer,nullptr);
delete bq;
return 0;
}
由于代码中生产者是每隔一秒生产一个数据,而消费者是每隔一秒消费一个数据,因此运行代码后我们可以看到生产者和消费者的执行步调是一致的
生产者速度快,消费者速度慢
void* Producer(void* arg)
{
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //生产者不断进行生产
int data = rand() % 100 + 1;
bq->push(data);
}
}
void* Consumer(void* arg)
{
int data = 0;
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //消费者不断进行消费
sleep(1);
bq->pop(data);
}
}
此时由于生产者生产的很快,运行代码后一瞬间生产者就将阻塞队列装满。此时生产者想要再进行生产就只能在full条件变量下进行等待,直到消费者消费完一个数据后,生产者才会被唤醒进而继续进行生产,生产者生产完一个数据后又会进行等待,因此后续生产者和消费者的步调又变成一致的了
生产者速度慢,消费者速度快
void* Producer(void* arg)
{
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //生产者不断进行生产
sleep(1);
int data = rand() % 100 + 1;
bq->push(data);
}
}
void* Consumer(void* arg)
{
int data = 0;
BlockQueue* bq = (BlockQueue*)arg;
while (true) { //消费者不断进行消费
bq->pop(data);
}
}
虽然消费者消费的快,但开始时阻塞队列中是没有数据的,因此消费者只能在empty条件变量下等待,直到生产者生产完一个数据后,消费者才会被唤醒进而进行消费,消费者消费完这一个数据后又会进行等待,因此生产者和消费者的步调就是一致的
设置唤醒策略
可以设置一些策略。譬如,下面当阻塞队列当中存储的数据大于队列容量的一半时,再唤醒消费者线程进行消费;当阻塞队列当中存储的数据小于队列容器的一半时,再唤醒生产者线程进行生产
void push(const T &data)
{
pthread_mutex_lock(&_mutex);
while (IsFull()) { // 不能进行生产,直到阻塞队列可以容纳新的数据
pthread_cond_wait(&_full, &_mutex);
}
_queue.push(data);
std::cout << "Producer: " << data << std::endl;
if (_queue.size() >= _capacity / 2) {
pthread_cond_signal(&_empty); // 唤醒在empty条件变量下等待的消费者线程
}
pthread_mutex_unlock(&_mutex);
}
void pop(T &data)
{
pthread_mutex_lock(&_mutex);
while (IsEmpty()) { // 不能进行消费,直到阻塞队列有新的数据
pthread_cond_wait(&_empty, &_mutex);
}
data = _queue.front();
_queue.pop();
std::cout << "Consumer: " << data << std::endl;
if (_queue.size() <= _capacity / 2) {
pthread_cond_signal(&_full); // 唤醒在full条件变量下等待的生产者线程
}
pthread_mutex_unlock(&_mutex);
}
仍然让生产者生产快,消费者消费慢。运行代码后生产者还是一瞬间将阻塞队列装满后进行等待,但此时不是消费者消费一个数据就唤醒生产者线程,而是当阻塞队列当中的数据小于等于队列容器的一半时,才会唤醒生产者线程进行生产
基于任务的生产者消费者模型
实际使用生产者消费者模型时可不是简单的让生产者生产一个数字让消费者进行打印而已,前面的代码只是为了理解生产者消费者模型而已。
编写BlockingQueue时当中存储的数据就进行了模板化,那么就可以让BlockingQueue当中存储其他类型的数据。
譬如编写一个Task类(其中包含需要执行的任务),BlockingQueue中就存储Task对象。此时生产者放入阻塞队列的数据就是Task对象,而消费者从阻塞队列拿到Task对象后,就可以用该对象调用Run成员函数进行数据处理。
总之,根据需要进行编写即可
信号量本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理
每个执行流在进入临界区之前都应先申请信号量,申请成功就有了操作临界资源的权限,当操作完毕后就应该释放信号量
PV操作为原子操作
多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,即信号量本质也是临界资源。但信号量本质就是用于保护临界资源的,所以信号量的PV操作必须是原子操作
注意: 内存当中变量的自增、自减操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行自增、自减操作
申请信号量失败被挂起等待
当执行流在申请信号量时,可能此时信号量的值为0,即信号量描述的临界资源已全部被申请了,此时该执行流就应该在该信号量的等待队列中进行等待,直到有信号量被释放时被唤醒
注意: 信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列
int sem_init(sem_t *sem, int pshared, unsigned int value);
返回值:初始化信号量成功返回0,失败返回-1
注意: POSIX信号量与System V信号量作用相同,都用于同步操作,达到无冲突的访问共享资源目的,但POSIX信号量可用于线程间同步
int sem_destroy(sem_t *sem);
参数sem:需要销毁的信号量
返回值:销毁信号量成功返回0,失败返回-1
int sem_wait(sem_t *sem);
参数sem:需要等待的信号量
返回值:(P操作)
int sem_post(sem_t *sem);
参数sem:需要发布的信号量
返回值:(V操作)
信号量本质是计数器,若将信号量的初始值设置为1,那么此时该信号量被称为二元信号量。信号量的初始值为1,说明信号量所描述的临界资源只有一份,此时信号量的作用基本等价于互斥锁
在主线程中创建五个新线程,让这五个新线程执行抢票逻辑,并且每次抢完票后打印输出此时剩余的票数。用全局变量tickets记录当前剩余的票数,此时tickets是会被多个执行流同时访问的临界资源,在下面的代码中并没有对tickets进行任何保护操作
#include
#include
#include
#include
int tickets = 2000;
void* TicketGrabbing(void* arg)
{
std::string name = (char*)arg;
while (true){
if (tickets > 0){
usleep(1000);
std::cout << name << " get a ticket, tickets left: " << --tickets << std::endl;
}
else{
break;
}
}
std::cout << name << " quit..." << std::endl;
pthread_exit((void*)0);
}
int main()
{
pthread_t tid[5];
pthread_create(tid, nullptr, TicketGrabbing, (void*)"thread 1");
pthread_create(tid + 1, nullptr, TicketGrabbing, (void*)"thread 2");
pthread_create(tid + 2, nullptr, TicketGrabbing, (void*)"thread 3");
pthread_create(tid + 3, nullptr, TicketGrabbing, (void*)"thread 4");
pthread_create(tid + 4, nullptr, TicketGrabbing, (void*)"thread 5");
pthread_join(tid[0], nullptr);
pthread_join(tid[1], nullptr);
pthread_join(tid[2], nullptr);
pthread_join(tid[3], nullptr);
pthread_join(tid[4], nullptr);
return 0;
}
运行代码后发现,线程打印输出剩余票数时出现了票数剩余为负数的情况,这并不符合预期
下面在抢票逻辑中加入二元信号量,让每个线程在访问全局变量tickets之前先申请信号量,访问完毕后再释放信号量,此时就达到了互斥的效果
#include
#include
#include
#include
#include
class Sem{
public:
Sem(int num) { sem_init(&_sem, 0, num); }
~Sem() { sem_destroy(&_sem); }
void P() { sem_wait(&_sem); }
void V() { sem_post(&_sem); }
private:
sem_t _sem;
};
static Sem sem(1);
int tickets = 2000;
void* TicketGrabbing(void* arg)
{
std::string name = (char*)arg;
while (true){
sem.P();
if (tickets > 0){
usleep(1000);
std::cout << name << " get a ticket, tickets left: " << --tickets << std::endl;
sem.V();
}
else{
sem.V();
break;
}
}
std::cout << name << " quit..." << std::endl;
pthread_exit((void*)0);
}
int main()
{
pthread_t tid[5];
pthread_create(tid, nullptr, TicketGrabbing, (void*)"thread 1");
pthread_create(tid + 1, nullptr, TicketGrabbing, (void*)"thread 2");
pthread_create(tid + 2, nullptr, TicketGrabbing, (void*)"thread 3");
pthread_create(tid + 3, nullptr, TicketGrabbing, (void*)"thread 4");
pthread_create(tid + 4, nullptr, TicketGrabbing, (void*)"thread 5");
pthread_join(tid[0], nullptr);
pthread_join(tid[1], nullptr);
pthread_join(tid[2], nullptr);
pthread_join(tid[3], nullptr);
pthread_join(tid[4], nullptr);
return 0;
}
运行代码后就不会出现剩余票数为负的情况了,同一时刻只会有一个执行流对全局变量tickets进行访问,不会出现数据不一致的问题
生产者关注的是空间资源,消费者关注的是数据资源
对于生产者和消费者而言,关注的资源是不同的:
_space_sem和_data_sem的初始值设置
用信号量来描述环形队列当中的空间资源(_space_sem)和数据资源(_data_sem),在初始信号量时设置的初始值是有所不同的:
生产者申请空间资源,释放数据资源
对于生产者来说,生产者每次生产数据前都需要先申请_space_sem:
当生产者生产完数据后,应释放_data_sem:
消费者申请数据资源,释放空间资源
对于消费者来说,消费者每次消费数据前都需要先申请_data_sem:
当消费者消费完数据后,应该释放_space_sem:
规则一:生产者和消费者不能对同一个位置进行访问
规则二:无论是生产者还是消费者,都不应该将对方套一个圈以上
RingQueue就是生产者消费者模型当中的交易场所,可以用C++STL库中的vector进行模拟
#ifndef _RING_QUEUE_HPP_
#define _RING_QUEUE_HPP_
#include
#include
#include
#include "sem.hpp"
const size_t g_default_num = 5;
template
class RingQueue
{
public:
RingQueue(size_t default_num = g_default_num)
:_ring_queue(default_num),_num(default_num),_producer_index(0),_consumer_index(0)
,_space_sem(default_num),_data_sem(0) {
pthread_mutex_init(&_producer_mtx,nullptr);
pthread_mutex_init(&_consumer_mtx,nullptr);
}
~RingQueue() {
pthread_mutex_destroy(&_producer_mtx);
pthread_mutex_destroy(&_consumer_mtx);
}
void Push(const T& in)
{
//先申请访问临界资源的权限,再申请锁
//减小锁的粒度
//若资源有多个(信号量 > 1),在因为没抢占到锁而阻塞之前,就可以申请到信号量
_space_sem.P();
pthread_mutex_lock(&_producer_mtx);
_ring_queue[_producer_index++] = in;
_producer_index %= _num;
pthread_mutex_unlock(&_producer_mtx);
_data_sem.V();
}
void Pop(T* out)
{
_data_sem.P();
pthread_mutex_lock(&_consumer_mtx);
*out = _ring_queue[_consumer_index++];
_consumer_index %= _num;
pthread_mutex_unlock(&_consumer_mtx);
_space_sem.V();
}
void Debug() {
std::cerr << "size:" << _ring_queue.size() << std::endl;
std::cerr << "num:" << _num << std::endl;
}
private:
std::vector _ring_queue;
int _num;
int _producer_index;
int _consumer_index;
Sem _space_sem;
Sem _data_sem;
pthread_mutex_t _producer_mtx;
pthread_mutex_t _consumer_mtx;
};
#endif
生产者线程不断生产数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费
#include "ringQueue.hpp"
#include
#include
#include
#include
void* Producer(void* arg)
{
RingQueue* rq = (RingQueue*)arg;
while(true)
{
sleep(1);
//构建数据或任务对象(一般从外部获取)
int date = rand() % 100 + 1;
//Push入环形队列
rq->Push(date);
std::cout << "生产:" << date << "[" << pthread_self() << "]" <* rq = (RingQueue*)arg;
while(true)
{
sleep(1);
//从环形队列中读取数据或任务
int date = 0;
rq->Pop(&date);
//处理数据或任务
std::cout << "消费:" << date << "[" << pthread_self() << "]" <* rq = new RingQueue();
// rq->Debug();
pthread_t producer[3],consumer[2];
pthread_create(producer,nullptr,Producer,rq);
pthread_create(producer + 1,nullptr,Producer,rq);
pthread_create(producer + 2,nullptr,Producer,rq);
pthread_create(consumer,nullptr,Consumer,rq);
pthread_create(consumer + 1,nullptr,Consumer,rq);
for(int i = 0;i < 3; ++i) pthread_join(producer[i],nullptr);
for(int i = 0;i < 2; ++i) pthread_join(consumer[i],nullptr);
return 0;
}
将信号量进行封装
#ifndef _SEM_HPP
#define _SEM_HPP
#include
#include
class Sem
{
public:
Sem(size_t value) { sem_init(&_sem,0,value); }
~Sem() { sem_destroy(&_sem); }
void P() { sem_wait(&_sem); }
void V() { sem_post(&_sem); }
private:
sem_t _sem;
};
#endif
在_space_sem和_data_sem两个信号量的保护,该环形队列中不可能会出现数据不一致的问题
只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
但在这两种情况下,生产者和消费者不会同时对环形队列进行访问:
即当环形队列为空和满时,已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行