【Linux】生产者消费者模型 -- RingQueue

文章目录

  • 1. 信号量
    • 1.1 信号量的引入
    • 1.2 信号量的概念
    • 1.3 信号量函数
  • 2. 二元信号量模拟实现互斥功能
  • 3. 基于环形队列的生产消费模型
    • 3.1 空间资源和数据资源
    • 3.2 生产者和消费者申请和释放资源
    • 3.3 必须遵守的两个规则
    • 3.4 代码实现
    • 3.5 信号量保护环形队列的原理

1. 信号量

1.1 信号量的引入

  • 我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致的问题。
  • 当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
  • 但实际我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致的问题。

如果要让执行流同时访问临界资源的不同区域的话,就需要引入信号量了。

1.2 信号量的概念

信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度地对临界资源进行管理。

每个执行流在进入临界区之前先申请信号量,申请成功就有了操作临界资源的权限,当操作完毕后就应该释放信号量。
【Linux】生产者消费者模型 -- RingQueue_第1张图片
信号量的PV操作

  • P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作本质就是让计数器减一。
  • V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目应该加一,因此V操作的本质就是让计数器加一。

1.3 信号量函数

信号量的初始化函数

【Linux】生产者消费者模型 -- RingQueue_第2张图片
参数说明:

  • sem:需要初始化的信号量
  • pshared:传入0值表示线程间共享,传入非0值表示进程间共享
  • value:信号量的初识值(计数器的初始值)

返回值说明:

  • 初始化信号量返回0,失败返回-1

注意:POSIX信号量和System V信号量作用相同,都是用于同步操作,达到无冲突地访问共享资源的目的,但是POSIX信号量可以用于线程间同步。

信号量的销毁函数

【Linux】生产者消费者模型 -- RingQueue_第3张图片
参数说明:

  • sem:需要销毁的信号量

返回值说明:

  • 销毁信号量成功返回0,失败返回-1。

等待信号量(申请信号量)

【Linux】生产者消费者模型 -- RingQueue_第4张图片
参数说明:

  • sem:需要等待的信号量

返回值说明:

  • 等待信号量成功返回0,信号量的值减一
  • 等待信号量失败返回-1,信号量的值保持不变

发布信号量(释放信号量)

【Linux】生产者消费者模型 -- RingQueue_第5张图片
参数说明:

  • sem:需要发布的信号量

返回值说明:

  • 发布信号量成功返回0,信号量的值加一
  • 发布信号量失败返回-1,信号量的值保持不变

2. 二元信号量模拟实现互斥功能

信号量本质是一个计数器,如果将信号量的初始值设置为1,那么此时该信号量叫做二元信号量

信号量的初识值为1,说明信号量所描述的临界资源只有一份,此时信号量的作用基本等价于互斥锁。

例如,下面我们实现一个多线程抢票系统,其中我们用二元信号量模拟实现多线程互斥。

#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;
};

Sem sem(1); // 二元信号量
int tickets = 1000;

void* ticketGet(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(nullptr);
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, ticketGet, (void*)"thread 1");
    pthread_create(&t2, nullptr, ticketGet, (void*)"thread 2");
    pthread_create(&t3, nullptr, ticketGet, (void*)"thread 3");
    pthread_create(&t4, nullptr, ticketGet, (void*)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

运行结果如下:
【Linux】生产者消费者模型 -- RingQueue_第6张图片

3. 基于环形队列的生产消费模型

【Linux】生产者消费者模型 -- RingQueue_第7张图片

3.1 空间资源和数据资源

生产者关注的是空间资源,消费者关注的是数据资源

对于生产者和消费者来说,它们关注的资源是不同的:

  • 生产者关注的是环形队列当中是否有空间(blank),只要有空间生产者就可以进行生产
  • 消费者关注的是环形队列中是否有数据(data),只要有数据就可以进行消费。

blank_sem和data_sem的初始值设置

现在我们用信号量来描述环形队列中的空间资源(blank_sem)和数据资源(data_sem),在我们初识化信号量时给它们设置的初始值是不同的;

  • blank_sem的初始值我们应该设置为环形队列的容量,因为刚开始时环形队列当中全是空间。
  • data_sem的初始值我们应该设置为0,因为刚开始时环形队列当中没有数据。

3.2 生产者和消费者申请和释放资源

生产者申请空间资源,释放数据资源

对于生产者来说,生产者每次生产数据前都需要先申请blank_sem:

  • 如果blank_sem的值不为0,则信号量申请成功,此时生产者可以进行生产操作。
  • 如果blank_sem的值为0,则信号量申请失败,此时生产者需要在blank_sem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒。

当生产者生产完数据之后,应该释放data_sem:

  • 虽然生产者在进行生产前是对blank_sem进行的P操作,但是当生产者生产完数据之后,应该对data_sem进行V操作而不是blank_sem
  • 生产者在生产数据前申请到的是blank位置,当生产者生产完数据之后,该位置当中存储的是生产者生产的数据,在该数据被消费者消费之前,该位置不再是blank位置,而是data位置。
  • 当生产者生产完数据之后,意味着环形队列当中多了一个data位置,因此我们应该对data_sem进行V操作。

消费者申请数据资源,释放空间资源

对于消费者来说,消费者每次消费数据前都需要先申请data_sem:

  • 如果data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作
  • 如果data_sem的值为0,则信号量申请失败,此时消费者需要在data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒。

当消费者消费完数据之后,应该释放blank_sem:

  • 虽然消费者进行消费前是对data_sem进行的P操作,但是当消费者消费完数据之后,应该对blank_sem进行V操作而不是data_sem
  • 消费者在消费数据前申请到的是data位置,当消费者消费完数据后,该位置当中的数据已经被消费过了,再次被消费就没有意义了,为了让生产者后续可以在该位置生产新的数,我们应该将该位置算作blank位置,而不是data位置。
  • 当消费者消费完数据之后,意味着环形队列中多了一个blank位置,因此我们应该对blank_sem进行V操作。

3.3 必须遵守的两个规则

第一个规则:生产者和消费者不能对同一个位置进行访问

生产者和消费者在访问环形队列时:

  • 如果生产者和消费者访问的是环形队列当中的同一个位置,那么此时生产者和消费者就相当于同时对着一块临界资源进行了访问,这当然是不允许的。
  • 而如果生产者和消费者访问的是环形队列当中的不同位置,那么此时生产者和消费者是可以同时进行生产和消费的,此时不会出现数据不一致的问题。

第二个规则:无论是生产者还是消费者,都不能超过对方一圈以上

  • 生产者从消费者的位置开始一直按顺时针的方向进行生产,如果生产者生产的速度比消费者消费的数据快,那么当生产者绕着消费者生产了一圈数据后再次遇到消费者,此时生产者就不应该继续生产了,因为在生产就会覆盖还未被消费的数据。
  • 同理,消费者从生产者的位置开始一直按顺时针方向进行消费,如果消费的速度比生产者生产的速度还快,那么当消费者绕着生产者消费量一圈数据后再次遇到生产者,此时消费者就不应该再继续消费了,因为再消费就会消费到之前保存的数据了。

3.4 代码实现

ringQeueu的代码实现如下:

#pragma once

#include 
#include 
#include 
#include 
#include 

#define NUM 8

template <class T>
class ringQueue
{
private:
    // P操作
    void P(sem_t& s)
    {
        sem_wait(&s);
    }

    // V操作
    void V(sem_t& s)
    {
        sem_post(&s);
    }

public:
    ringQueue(int cap = NUM)
        : _cap(cap), _p_pos(0), _c_pos(0)
    {
        _q.resize(_cap);
        sem_init(&_blank_sem, 0, _cap); // blank_sem的初始值为环形队列的容量
        sem_init(&_data_sem, 0, 0); // data_sem的初始值设置为0
    }

    ~ringQueue()
    {
        sem_destroy(&_blank_sem);
        sem_destroy(&_data_sem);
    }

    void push(const T& data)
    {
        P(_blank_sem); // 申请blank信号量
        _q[_p_pos] = data; // 生产数据
        V(_data_sem); // 释放data信号量

        // 更新下一次生产的位置
        _p_pos++;
        _p_pos %= _cap;
    }

    void pop(T& out)
    {
        P(_data_sem); // 申请data信号量
        out = _q[_c_pos]; // 消费数据 
        V(_blank_sem); // 释放blank信号量

        // 更新下一次消费的位置
        _c_pos++;
        _c_pos %= _cap; 
    }

private:
    std::vector<T> _q; // 环形队列
    int _cap; // 环形队列的容量上限
    int _p_pos; // 生产位置
    int _c_pos; // 消费位置
    sem_t _blank_sem; // 描述数据资源
    sem_t _data_sem; // 描述数据资源
};

为了方便理解,我们实现单生产者单消费者的模型。在主函数中创建一个生产者线程和一个消费者线程,生产者线程不断将数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费。

#include "ringQueue.hpp"

void* productor(void* arg)
{
    ringQueue<int>* rq = (ringQueue<int>*)arg;
    while (1) 
    {
        sleep(1);
        int data = rand() % 100 + 1;
        rq->push(data);
        std::cout << "productor: " << data << std::endl;
    }
}

void* consumer(void* arg)
{
    ringQueue<int>* rq = (ringQueue<int>*)arg;
    while (1)
    {
        int data = 0;
        rq->pop(data);
        std::cout << "consumer: " << data << std::endl;
    }
}

int main()
{
    srand((unsigned int)time(nullptr));
    pthread_t p, c;
    ringQueue<int>* rq = new ringQueue<int>();
    pthread_create(&p, nullptr, productor, rq);
    pthread_create(&c, nullptr, consumer, rq);

    pthread_join(p, nullptr);
    pthread_join(c, nullptr);

    delete rq;

    return 0;
}

运行结果如下:
【Linux】生产者消费者模型 -- RingQueue_第8张图片
在我们自己所写的代码中,我们虽然只让生产者一秒生产一次,而没让消费者的消费速度受限制,但是它们最终的步调是一致的,这正是信号量的作用所在。

3.5 信号量保护环形队列的原理

在blank_sem和data_sem两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题。

因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一位置:

环形队列为空时
环形队列为满时

但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:

  • 当环形队列为空时,消费者一定不能进行消费,因为此时数据资源为0。
  • 当环形队列为满时,生产者一定不能进行生产,因为此时空间资源为0。

也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一位置,因此大部分情况下该环形队列可以让生产者和消费者并发地执行。

你可能感兴趣的:(linux)