初识Linux · 编写生产消费模型(1)

目录

前言:

阻塞队列


前言:

前文我们花了大量篇幅介绍了线程同步的概念,同时引出了条件变量,认识了相应的接口,并且快速编写了一个简单的测试用例见识了一下条件变量的使用,并且有意思的是,在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);
    }
}

此时,对于阻塞队列部分我们就了解的差不多了,当然了,我们不仅可以使用类,我们甚至可以使用包装器包装的函数对象都是可以的。

那么由上面的代码,我们清楚了生产消费模型的并发,解耦,但是效率高从哪里来呢?

实际上效率高代表的,处理任务,分配任务,你想,多消费多生产中,放数据和拿数据是我们刚才实现的,可是难道处理任务和分配任务不需要时间吗?当然需要,所以虽然锁只能由其中的一个持有,但是该锁持有的过程,别的线程就可以处理数据,或者是分配数据,这难道不是一种高效率吗?

好了,我们应该适当的引出我们下篇文章要编写的模型了,这里我们使用了锁,使用锁都是因为我们需要访问临界资源,怕多线程造成了错误,可是我们是将队列看成了一个整体使用,也就是我们使用之前必须判断这个整体是否满足条件,可是在买电影票的时候,我们可不是将电影看做一个整体买票的,我们是看电影院里面有没有空坐,如果没有,我们买票,这个过程是什么?

不就是信号量的申请吗??如果有了信号量,我们就不需要加锁了,因为信号量本身就可以帮我们判断是否满足条件了,那么如何使用信号量完成并发操作呢?

请看下文的环形队列借助信号量实现生产消费模型


感谢阅读!

相关推荐
山顶望月1 小时前
ISO20000与IT运维和运营的关系
运维·it运营·iso20000
杰锅就是爱情3 小时前
OpenObserve Ubuntu部署
linux·运维·ubuntu
lllsure4 小时前
【Docker】容器
运维·docker·容器
Jtti6 小时前
新加坡服务器连接速度变慢应该做哪些检查
运维·服务器
郝亚军6 小时前
websocket 服务器往客户端发送的数据要加掩码覆盖吗?
服务器·网络·websocket
DoWhatUWant6 小时前
域格YM310 X09移芯CAT1模组HTTPS连接服务器
服务器·网络协议·https
huangjiazhi_6 小时前
在Linux上无法访问usb视频设备
linux·运维·服务器
xixingzhe26 小时前
jenkins脚本触发部署
运维·jenkins
TTGGGFF6 小时前
云端服务器使用指南:如何跨机传输较大文件(通过windows自带工具远程桌面连接 非常方便)
运维·服务器
躲在云朵里`7 小时前
ElasticSearch复习指南:从零搭建一个商品搜索案例
运维·jenkins