Linux系统编程——多线程[下]:生产消费模型&信号量&线程池

0.关注博主有更多知识

操作系统入门知识合集

目录

1.生产者消费者模型

1.1基于阻塞队列的生产者消费者模型

1.2生产消费模型的效率问题

2.信号量

2.1信号量

2.2基于环形队列的生产者消费者模型

2.3环形队列的生产消费模型的效率问题

3.线程池

3.1线程池的实现

1.生产者消费者模型

生产者消费者模型在多线程编程中是一个很重要的模型,因为它通过一个容器(缓冲区)来解决生产者和消费者之间的强耦合问题,使得生产者与消费者之间做到不直接通讯,而是通过缓冲区来进行通讯,使其达到解耦的效果。所以因为解耦,所以生产者生产的数据不需要直接交给消费者,而是扔到缓冲区里面去,消费者同样不需要向生产者索要数据,而是直接从缓冲区里面拿,这样就平衡了生产者和消费者的处理能力。

抛开生产者消费者模型不谈,我们每天都在使用生产者和消费者的身份在编程,这个每天都在进行的动作就是函数调用。例如main()函数调用func()函数,那么main()作为调用方需要将数据作为实参传递给func()函数的形参,这就是一次生成数据的行为,所以main()函数就是一个生产者;func()作为被调用方需要接收来自调用方的实参,然后在内部做一些处理,这种行为我们称为消费行为,所以func()函数就是一个消费者。那么main()函数与func()函数之间的关系是什么样的呢?是强耦合关系。当main()函数调用func()函数时,main()必须阻塞式的等待func()函数执行结束,而func()函数必须在main()函数没有调用自己时而保持阻塞(虽然这是不对的,但是例子很形象,哈哈哈)。那么我们可以给main()函数和func()函数之间添加一个缓冲区,main()函数将实参不直接传递给func()函数,而是直接扔到缓冲区里面去;func()函数需要的数据不是接收main()函数的实参,而是从缓冲区里面拿:

那么在这里例子当中,就是一个简单的生产者消费者模型,我们难挖掘出main()函数和func()函数之间做到了解耦,所以他们两个是独立的函数,所以可以并发执行,并且main()函数的生产速率可以和func()函数的消费速率不保持一致,所以生产者消费者模型有如下几个优点

  1.生产者与消费者做到了解耦

  2.生成者与消费者之间可以并发 

  3.生产者与消费者的执行速率可以不一样

那么在多线程场景当中,生产者不止一个,消费者不止一个,甚至有可能缓冲区还不止一个。那么我们要保证生产者消费者模型的正确执行我们要做好以下工作

  1.保证三种关系

    1)保证生产者与生产者之间的互斥:当多个生产者线程存在时,必须要保证他们之间访问缓冲区的互斥关系。即当一个线程正在向缓冲区写入数据时,其他缓冲区必须阻塞等待,因为在计算机当中数据是可以被覆盖的,如果生产者之间没有互斥关系,并且缓冲区只有一个有效位置,那么写入的数据一定是最后一个生产者写入的数据,这就是造成了数据不一致问题。

    2)保证消费者与消费者之间的互斥:与上面同理,当一个消费者线程正在从缓冲区读取数据时,其他消费者不能同时读取数据,必须阻塞等待。不然会造成数据不完整,就类似于去超市买最后一根火腿肠时,去买这个火腿肠的并不是一个人,那么多个人要买一根火腿肠的时候只能一个人买到,不然多个人就要抢这个火腿肠,最终导致火腿肠"四分五裂"。

    3)保证生产者与消费者之间的互斥与同步关系:生产者和消费者的行为都是向缓冲区存取数据,所以要保证他们之间的互斥关机,即在同一时刻只允许一个生产者或一个消费者访问缓冲区,否则和可能发生生产者数据写到一半的时候消费者便把数据读走了,这就会导致数据不完整问题。生产者和消费者之间也必须存在同步关系,如果没有同步关系,那么很可能由于一些优先级的关系,对互斥锁或者信号量的竞争力特别强,导致一直在重复访问缓冲区而不给其他生产者和消费者访问的机会,进而操作饥饿问题导致模型不合理工作,所以必须规定出一个规则,例如生产者生产一个消费者消费一个,或者消费者只有在缓冲区不为空时读取数据,生产者只能在缓冲区不为满时生产数据等等类似这样的规则来保证生产者和消费者之间的同步关系。

  2.保证两种角色:即一个正常的生产者消费者模型,必须存在至少一个生产者线程和至少一个消费者线程

  3.保证一个缓冲区:生产者和消费者模型将生产者和消费者进行了解耦,那么解耦的本质就是通过缓冲区作为通信介质,所以模型当中必须在生产者和消费者之间存在一个缓冲区。

  生产者消费者模型速记口诀:三二一

1.1基于阻塞队列的生产者消费者模型

我们以阻塞队列来作为生产消费模型当中的缓冲区,那么阻塞队列作为空间大小有限的数据结构,我们可以规定以下生产者和消费者同步规则:

  1.当阻塞队列的容量达到上限时,生产者不能再向阻塞队列当中生产数据,即生产者陷入阻塞

  2.当阻塞队列的为空时,消费者不能再从阻塞队列当中拿数据,即消费者陷入阻塞

  3.只有当阻塞队列既不为空又不为满时,生产者和消费者才能互斥的访问阻塞队列

我们先编写一份上层调用逻辑较为简单的生产消费模型的代码:

// consumerProductor.cpp

#include <iostream>
#include <memory>
#include <ctime>
#include <string>
using namespace std;
#include <pthread.h>
#include "blockQueue.hpp"
using namespace blockqueue;
#include <unistd.h>

/*消费者就是从阻塞队列当中拿数据*/
void *consumerDemo(void *args)
{
    blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);
    while (true)
    {
        int data;
        bq->pop(&data);
        cout << "consumer consumed a data: " << data << endl;
        sleep(1);/*让消费者消费的稍微慢一些*/
    }
    pthread_exit(nullptr);
}

/*生产者就是向阻塞队列当中写数据*/
void *productorDemo(void *args)
{
   blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);

    while (true)
    {
        int data = rand() % 100;
        bq->push(data);
        cout << "productor producted a data: " << data << endl;
    }
    pthread_exit(nullptr);
}

int main()
{
    srand((unsigned int)time(nullptr));
    blockQueue<int> *bq = new blockQueue<int>();

    pthread_t productor,consumer;
    pthread_create(&productor,nullptr,productorDemo,bq);
    pthread_create(&consumer,nullptr,consumerDemo,bq);

    pthread_join(productor,nullptr);
    pthread_join(consumer,nullptr);
    return 0;
}
// blockQueue.hpp
#pragma once

#include <queue>
#include <pthread.h>
namespace blockqueue
{
    using namespace std;

    template <class T>
    class blockQueue
    {
    private:
#define MAXCAP 5 /*缓冲区上限大小,随时可变*/
    public:
        blockQueue(const size_t &cap = MAXCAP) : _cap(cap)
        {
            pthread_mutex_init(&_mutex, nullptr);
            pthread_cond_init(&_productorCond, nullptr);
            pthread_cond_init(&_consumerCond, nullptr);
        }
        ~blockQueue()
        {
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_productorCond);
            pthread_cond_destroy(&_consumerCond);
        }

        /*向阻塞队列(缓冲区)生产数据*/
        void push(const T &in)
        {
            pthread_mutex_lock(&_mutex);

            /*如果阻塞队列为满,生产者不能生产,进入自己的条件变量等待*/
            while(isFull())
            {/*这里必须使用while而不是if*/
                /*进入条件变量相当于发生一次线程切换
                 *然是要将锁释放,让其他线程拥有锁
                 *这就是为什么需要传入锁的原因*/
                pthread_cond_wait(&_productorCond,&_mutex);
            }
            _q.push(in);

            /*生产者生产一个,说明阻塞队列就多一个数据
             *所以此时可以唤醒消费者消费*/
            pthread_cond_signal(&_consumerCond);
            pthread_mutex_unlock(&_mutex);
        }

        /*从阻塞队列(缓冲区)拿数据
         *使用输出型参数*/
        void pop(T *out)
        {
            pthread_mutex_lock(&_mutex);
            while(isEmpty())
            {
                pthread_cond_wait(&_consumerCond,&_mutex);
            }
            *out = _q.front();
            _q.pop();
            pthread_cond_signal(&_productorCond);
            pthread_mutex_unlock(&_mutex);
        }

    private:
        bool isFull()
        {
            return _q.size() == _cap;
        }
        bool isEmpty()
        {
            return _q.size() == 0;
        }
    private:
        /*以一个队列作为缓冲区*/
        queue<T> _q;

        /*生产者与消费者之间存在互斥与同步关系
         *故定义一把锁、生产者条件变量、消费者条件变量*/
        pthread_mutex_t _mutex;
        pthread_cond_t _productorCond;
        pthread_cond_t _consumerCond;

        /*缓冲区的大小*/
        size_t _cap;
    };
} /*namespace blockqueue ends here*/

无论是生产者、消费者谁先被调度,都将遵守一个同步规则,即阻塞队列为满生产者不能生产,阻塞队列为空消费者不能消费。可以看我们的输出结果是正常的,因为一开始的阻塞队列为空所以消费者不能消费,所以这些消费者都会进入自己的条件变量当中等待;那么此时只能由生产者生产数据,并且唤醒正在阻塞的消费者。但可能因为锁的竞争原因,生产者刚释放锁的时候,消费者被唤醒准备去竞争锁,但是生产者距离锁最近,所以生产者一直得到锁,一直得到锁就会一直生产数据直到把阻塞队列写满,写满之后自己陷入阻塞,进而消费者过来消费。然后消费一个数据之后就会导致阻塞队列多出来一个空位,此时就会唤醒一个生产者,并且由于我们上面的代码强行让消费者的消费速率慢一些,所以最后的现象就是消费者消费一个,生产者生产一个。那么对于上面的头文件当中就是生产消费模型的实现,其中编码的过程中存在两个细节问题

  1.为什么pthread_cond_wait()需要将互斥锁作为参数传递?因为我们之前介绍pthread_cond_wait()接口的作用,即哪个线程调用该接口哪个线程就去条件变量下的阻塞队列当中阻塞,直到被其他线程调用pthread_cond_signal()或pthread_cond_broadcast()唤醒。调用pthread_cond_wait()就相当于发生一次线程切换(由运行态切换到阻塞态,虽然不准确,但是可以这么理解),但是我们也说过线程切换时不会释放锁,所以在线程切换的过程中其他线程依然无法获取锁,依然无法进入临界区,但是pthread_cond_wait()不一样,它必须释放锁,也就是进入条件变量阻塞的线程必须释放锁,以便其他线程获取锁然后进入临界区;当在条件变量的线程被唤醒时,pthread_cond_wait()不会马上退出然后线程继续向下执行,pthread_cond_wait()必须保证线被唤醒的线程拥有锁,然后再退出,然后再向下执行,也就是说线程去条件变量下阻塞、释放锁的过程在pthread_cond_wait()内部完成,处于条件变量下阻塞的线程被唤醒,然后要竞争锁的过程也要在pthread_cond_wait()内部完成

  2.为什么在判断阻塞队列为满或为空时需要使用while语句而不是if语句?其实就是为了避免伪唤醒的情况。pthread_cond_wait()一旦退出,那么就说明线程被唤醒并且拥有锁,那么它就可以直接向下执行。如果判断阻塞队列为空或为满的语句为if时,那么当有多个生产者线程向阻塞队列当中写数据,那么如果最后一个线程被唤醒,但是此时的阻塞队列已经满了,但是由于使用的是if语句,那么这个线程不会再做检查,而是直接向下执行,最终造成缓冲区越界:

  所以我们需要使用while语句来判断阻塞队列是否为满,即让每一个从pthread_cond_wait()醒来的线程都判断一次阻塞队列是否为满或为空,如果确实为空或为满,那么就继续去条件变量下等待:

  还有一个原因就是万一当pthread_cond_wait()执行错误的时候,即pthread_cond_wait()不正常退出的时候(有可能是释放锁失败),线程依然会向下执行,进而导致缓冲区越界等等问题。那么我们使用while语句就能避免这个问题,原因是只有阻塞队列为满或为空的时候生产者或消费者才会进入条件变量阻塞,那么如果pthread_cond_wait()函数出错时不让线程继续向下执行,而是循环地再次执行pthread_cond_wait(),这样也能避免一些意想不到地问题。

当然,上面的Domo只是简单的向阻塞队列当中写一个整数、取一个整数。我们现在要对它进行升级,即生产者向阻塞队列当中生产任务,消费者从阻塞队列当中取任务然后执行,这也是为什么阻塞队列的实现要使用模板的原因。我们来看升级之后的效果:

// Task.hpp
#pragma once
#include <iostream>
#include <string>
using namespace std;


/*负责计算+、
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小龙向钱进

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值