目录
前言:
前文我们花了大量篇幅介绍了线程同步的概念,同时引出了条件变量,认识了相应的接口,并且快速编写了一个简单的测试用例见识了一下条件变量的使用,并且有意思的是,在Ubuntu环境下,man不了条件变量对应的接口,所以想要查询对应的接口可以使用对应的centos系统。
介绍完线程同步之后,我们花了不少的篇幅介绍了生产消费模型,引出了321原则,即一个交易场所,两个角色,三种关系,其中的重点是三种关系,我们编写生产消费模型的时候,主要是要注重关系。
其中,对于生产消费模型,第一种生产消费模型的编写我们使用阻塞队列的方式来编写。
为什么是阻塞队列? 我们将厂家,超市,消费者看着一个整体,其中的超市,作为交易场所,如果满了,那么厂家就不能生产了,如果空了,那么消费者就不能消费了,这本质就是一种阻塞的方式,而我们可以借助数据结构->队列来完成对应的编写。
阻塞队列
对于阻塞队列来说,我们先来简单思考一下运作模式:
首先需要一个队列,作为交易场所,所以queue类型的变量是少不了的,还需要两个角色,分别执行两种方法,一个方法是消费,一个方法是生产,最后是三个关系,三个关系具体是体现在了消费和生产这个关系里面。
对于阻塞队列来说,我们现在已知需要一个队列,那么队列作为交易场所来说的话,超市需要一个容量吧?所以我们需要一个变量用来判断队列的值是否满了,如果满了我们应该采取什么措施,如果空了我们需要采取什么措施。
当然了,既然我们是基于条件变量来编写的,所以对于生产者和消费者的条件变量自然也是少不了的,有了以上的总结,就可以有如下代码:
cpp
template<typename T>
class BlockQueue
{
public:
private:
int _max_cap; // 最大容量
pthread_cond_t _c_cond; // 消费者条件变量
pthread_cond_t _p_cond; // 生产者条件变量
std::queue<T> _blockqueue; // 临界资源
pthread_mutex_t _mutex; // 锁
};
有了如上的分析,既然是需要保证三种关系,锁肯定是需要的,但是**为什么只需要一把锁呢?我们实现的是单生产单消费模型没错,但是如果我们要实现多生产多消费呢?是否需要再加两把锁呢?**这里的问题就留到后面解决了。
接下来我们再编写一下构造函数,对于构造函数来说,我们可以自己设置一个最大容量,也可以构造的时候再设置都可以,都无伤大雅,对于两个条件变量和锁而言,因为是局部的锁,所以我们肯定要用到对应的函数,析构的时候同理:
cpp
const int _default_cap = 5;
BlockQueue(int max_cap = _default_cap) :_max_cap(max_cap)
{
pthread_cond_init(&_c_cond,nullptr);
pthread_cond_init(&_p_cond,nullptr);
pthread_mutex_init(&_mutex,nullptr);
}
~BlockQueue()
{
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
pthread_mutex_destroy(&_mutex);
}
基本的初始化和析构已经完成了,接下来需要做的事儿就是对于放数据和处理数据了。
对于放数据来说,也就是生产者要办的事儿,如果队列满了,那么生产者就只能在条件变量下等待,直到条件变量将它唤醒,问题来了,应该让谁来唤醒它?这个点先留着,后面细谈。至少现在我们清楚的知道,我们需要一个函数用来判断队列是否满了:
cpp
bool IsFull()
{
return _blockqueue.size() == _max_cap;
}
有了该函数,对于Push函数来说,我们肯定要加锁的,因为涉及了对于临界资源的访问,所以需要加锁,那么加锁之后,判断队列的条件是否满足,如果满足的话,就直接push数据,如果不满足,那么就要让生产者在这里等,等待被唤醒。
cpp
void Push(const T& in)
{
pthread_mutex_lock(&_mutex);
if(IsFull())
{
pthread_cond_wait(&_p_cond,&_mutex);
}
_blockqueue.push(in);
pthread_mutex_unlock(&_mutex);
// 唤醒消费者
pthread_cond_signal(&_c_cond);
}
我们暂时先不讨论这个函数的具体细节,这个函数既然这么简单,我们不如直接将另外一个函数写了?另外一个函数用来消费数据的,那么肯定要判断队列是否为空?同上面的函数一样,我们将其设置为私有:
cpp
bool Isempty()
{
return _blockqueue.empty();
}
对于Pop函数,我们肯定也是要加锁的,加锁之后判断是否为空,不为空我们就消费,为空我们就让消费者将等待,直到被唤醒:
cpp
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
if(Isempty())
{
pthread_cond_wait(&_c_cond,&_mutex);
}
out = _blockqueue.front();
_blockqueue.pop();
pthread_cond_unlock(&_mutex);
pthread_cond_signal(&_p_cond);
}
好了,现在我们来讨论几个问题。
pthread_cond_wait的第二个参数的作用是什么?
对于pthread_cond_wait的第二个参数,我们知道一个点,从始至终,我们都是只定义了一把锁,也就是不管是消费者还是生产者对于锁只能有一个人拥有,那么当其中某一位角色申请到了一把锁之后,刚想进行对应的某种操作,结果发现不满足条件,那么它就只能等待了吧?可是等待的过程中,如果锁不被归还,它又一直等待,那么不就陷入了死循环吗?
既然是陷入了死循环,所以肯定需要解决方法,解决方法就是,当等待的时候,解锁,让其他人来获取锁,自己等待。所以当该函数返回的时候,依旧会参与锁的竞争,直到重新获得锁,就往下走,如果不参与锁的竞争,那么不就很流氓了吗?
所以对于该函数来说,第二个参数是用来防止别人访问不到临界区资源的。
两种角色分别由谁来唤醒?其实在代码里面就已经说明了,对于生产者来说,消费者消费了一下,肯定是数据减法了,那么就由消费者来唤醒生产者,同理,生产者应该唤醒消费者。
那么对于main函数的编写显的就比较简单了:
cpp
void* Consumer(void* args)
{
BlockQueue<int>* c = static_cast<BlockQueue<int>*>(args);
while(true)
{
int out = 0;
c->Pop(&out);
std::cout << "Consumer pop ->" << out << std::endl;
sleep(1);
}
}
void* Productor(void* args)
{
srand(time(nullptr) ^ getpid());
BlockQueue<int>* p = static_cast<BlockQueue<int>*>(args);
while(true)
{
int in = rand() % 10 + 1;
p->Push(in);
std::cout << "productor push ->" << in << std::endl;
sleep(1);
}
}
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t c,p;
pthread_create(&c,nullptr,Consumer,bq);
pthread_create(&p,nullptr,Productor,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
return 0;
}
效果为:
其中函数中的sleep加的位置体现的方式也不同,可以自行尝试。
当然了,如果发生了打印混乱的情况是因为我们没有往函数里面的打印加锁,这个其实影响不是很大。
但是这里有问题了:
cpp
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
if(Isempty())
{
pthread_cond_wait(&_c_cond,&_mutex);
}
*out = _blockqueue.front();
_blockqueue.pop();
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_p_cond);
}
那这段代码举例,我们使用的应该是if吗?换句话说,使用if会不会出现什么问题呢?
假设存在两个消费者A B:
A和B同时竞争锁,A竞争到了锁,但是不满足条件,在里面等待,此时B竞争到了锁,也进来等待了,现在A B都是在条件变量这里等待的对吧?
那么因为是多生产多消费的情况,生产者将A B都唤醒了,此时只有一把锁,那么A获取到了锁, B呢?B因为已经被唤醒了,已经出函数了,还是在临界区,并且它不具备锁,此时不就出问题了吗?换句话说,条件没有满足,但是线程被唤醒了,就需要让线程重新休眠,这种情况叫做:伪唤醒!
解决方法就是使用while,出函数了重新判断是否满足条件,不满足继续休眠,虽然在单生产单消费模式中是很难出现这种情况的,但是为了增加代码的鲁棒性,我们应该这样写。
所以使用while之后,即便是多生产多消费也能过去:
cpp
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t c1,c2,c3,p1,p2;
pthread_create(&c1,nullptr,Consumer,bq);
pthread_create(&c2,nullptr,Consumer,bq);
pthread_create(&c3,nullptr,Consumer,bq);
pthread_create(&p1,nullptr,Productor,bq);
pthread_create(&p2,nullptr,Productor,bq);
pthread_join(c1,nullptr);
pthread_join(c2,nullptr);
pthread_join(c3,nullptr);
pthread_join(p1,nullptr);
pthread_join(p2,nullptr);
return 0;
}
可是实际上,我们并不会传int,既然是阻塞队列,我们可以传所谓的任务过去吗?比如传一个类,完成某种方法?
话不多说,我们先构建一个类:
cpp
class Task
{
public:
Task()
{}
Task(int x, int y)
:_x(x),_y(y)
{}
void Excute()
{
_result = _x + _y;
}
void operator ()()
{
Excute();
}
std::string debug()
{
std::string msg = std::to_string(_x) + "+" + std::to_string(_y) + "=?";
return msg;
}
std::string result()
{
std::string msg = std::to_string(_x) + "+" + std::to_string(_y) + "=" + std::to_string(_result);
return msg;
}
private:
int _x;
int _y;
int _result;
};
cpp
void *Consumer(void *args)
{
BlockQueue<Task> *c = static_cast<BlockQueue<Task> *>(args);
while (true)
{
Task t;
c->Pop(&t);
std::cout << t.result() << std::endl;
// int out = 0;
// c->Pop(&out);
// std::cout << "Consumer pop ->" << out << std::endl;
sleep(1);
}
}
void *Productor(void *args)
{
srand(time(nullptr) ^ getpid());
BlockQueue<Task> *p = static_cast<BlockQueue<Task> *>(args);
while (true)
{
int x = rand() % 100 + 1;
int y = rand() % 10 + 1;
Task t(x, y);
p->Push(t);
printf("productor push Task(x+y)->%d + %d\n", x, y);
// int in = rand() % 10 + 1;
// p->Push(in);
// std::cout << "productor push ->" << in << std::endl;
sleep(1);
}
}
此时,对于阻塞队列部分我们就了解的差不多了,当然了,我们不仅可以使用类,我们甚至可以使用包装器包装的函数对象都是可以的。
那么由上面的代码,我们清楚了生产消费模型的并发,解耦,但是效率高从哪里来呢?
实际上效率高代表的,处理任务,分配任务,你想,多消费多生产中,放数据和拿数据是我们刚才实现的,可是难道处理任务和分配任务不需要时间吗?当然需要,所以虽然锁只能由其中的一个持有,但是该锁持有的过程,别的线程就可以处理数据,或者是分配数据,这难道不是一种高效率吗?
好了,我们应该适当的引出我们下篇文章要编写的模型了,这里我们使用了锁,使用锁都是因为我们需要访问临界资源,怕多线程造成了错误,可是我们是将队列看成了一个整体使用,也就是我们使用之前必须判断这个整体是否满足条件,可是在买电影票的时候,我们可不是将电影看做一个整体买票的,我们是看电影院里面有没有空坐,如果没有,我们买票,这个过程是什么?
不就是信号量的申请吗??如果有了信号量,我们就不需要加锁了,因为信号量本身就可以帮我们判断是否满足条件了,那么如何使用信号量完成并发操作呢?
请看下文的环形队列借助信号量实现生产消费模型。
感谢阅读!