【Linux篇】一步步实现高效生产者消费者模型:从POSIX信号量到环形队列

深入理解生产者消费者:信号量与环形队列的完美结合

  • 一. POSIX信号量
    • 1.1 什么是POSIX信号量
    • 1.2 信号量相关接口
      • 1.2.1 sem_init()
      • 1.2.2 sem_wait()
      • 1.2.3 sem_post()
      • 1.2.4 sem_destroy()
  • 二. 基于环形队列的⽣产消费模型
    • 2.1 环形队列
    • 2.2 单生产单消费模型
    • 2.3 多生产多消费模型
  • 三. 最后

POSIX信号量是一种用于多线程或多进程同步的轻量级机制,通过维护一个计数器,控制对共享资源的访问,实现资源申请(P操作)和释放(V操作)。它能有效避免竞态条件,保障线程安全。基于环形队列的生产者消费者模型,将缓冲区设计成固定大小的循环数组,利用信号量分别管理空闲空间与数据数量,实现生产者和消费者的高效协作。该模型通过两个信号量和两把锁实现对生产和消费过程的互斥与同步,确保资源有序利用,提升并发性能,是经典且高效的多线程同步方案。

欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力!
点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!
分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对Linux OS感兴趣的朋友,让我们一起进步!

声明:本文章还是基于生产者消费者模型特点,实现的第二种方法,原则还是遵循 321 \color{OrangeRed}321 321原则。

一. POSIX信号量

1.1 什么是POSIX信号量

POSIX信号量是POSIX标准(Portable Operating System Interface)定义的一种同步机制,用于进程间或线程间的同步和互斥,防止多个线程或进程同时访问共享资源导致的数据竞争问题。

1.2 信号量相关接口

【信号量】本质是一个计数器,表示临界资源的多少。

  • 申请资源,计数器 − − \color{OrangeRed}-- P \color{OrangeRed}P P 操作)
  • 释放资源,计数器 + + \color{OrangeRed}++ ++ V \color{OrangeRed}V V 操作)

1.2.1 sem_init()

  • 功能:

sem_init 是 POSIX 信号量(semaphore)中的一个函数,用于初始化一个未命名的信号量。信号量常用于多线程编程中,用来实现同步,控制对共享资源的访问。

  • 函数原型:

int sem_init(sem_t *sem, int pshared, unsigned int value);

  • 参数:

sem:指向 sem_t 类型的信号量变量的指针。

  • pshared:
  1. 如果为 0,表示这个信号量在进程内部共享,适用于线程间同步。

  2. 如果非 0,表示这个信号量用于多个进程间共享(需要放在共享内存中)。

  3. value:信号量的初始值,通常表示资源的数量或允许访问的线程数。

  • 返回值
  1. 成功返回 0。

  2. 失败返回 -1,且设置 errno 表示错误原因。

1.2.2 sem_wait()

  • 功能:

sem_wait 是 POSIX 信号量(semaphore)操作中的一个函数,用于对信号量执行等待(P操作),即尝试减少信号量的值,若信号量值为0,调用线程将被阻塞,直到信号量大于0。

  • 函数原型:

int sem_wait(sem_t *sem);

  • 参数

sem:指向已经初始化的信号量变量的指针。

  • 返回值

成功返回 0。如果出错,返回 -1,且设置 errno,常见错误如段错误(EINVAL)信号量无效等。

函数解释:该函数会尝试对信号量的值进行减1,如果信号量的值大于0,则直接减1立即返回,线程继续执行后续代码;如果信号量的值小于0,则调用线程进行阻塞,直至有其他线程对该信号量执行V操作,使其值大于0。

1.2.3 sem_post()

  • 功能:

sem_post 是 POSIX 信号量(semaphore)操作中的一个函数,用于释放信号量(V操作),即将信号量的值增加1,表示资源可用数量增加,可能会唤醒一个等待该信号量的阻塞线程。

  • 函数原型:

int sem_post(sem_t *sem);

  • 参数:

sem:指向已初始化的信号量对象的指针。

  • 返回值

成功返回0。失败返回-1,同时设置 errno,比如信号量对象无效(EINVAL)等。

函数解释:该函数会释放资源,并使信号量的值+1,如果有线程因为调用 sem_wait 而在该信号量上阻塞,系统会唤醒其中一个线程,使其能够继续执行。因为信号量的值不小于0,操作系统就会唤醒正在等待的线程。

1.2.4 sem_destroy()

  • 功能:

sem_destroy 是 POSIX 信号量(semaphore)操作中的一个函数,用于销毁已经初始化的信号量,释放其占用的资源。

  • 函数原型:

int sem_destroy(sem_t *sem);

  • 参数:

sem:指向一个已经初始化的信号量对象的指针。

  • 返回值

成功返回 0。失败返回 -1,且设置 errno,比如传入了无效的信号量指针(EINVAL)。

函数解释:sem_destroy 用于销毁信号量,释放系统资源。注意:释放该资源后,不能对该信号量进行初始化,P或V操作。

二. 基于环形队列的⽣产消费模型

冷知识:环形队列可以用数组模拟,通过模运算来模拟。

2.1 环形队列

生产者消费者模型】的交易场所并非只能是阻塞队列,还可以是环形队列,这里的环形队列并非指的是传统意义上的 “队列”,而是用数组通过模运算来模拟的。
【Linux篇】一步步实现高效生产者消费者模型:从POSIX信号量到环形队列_第1张图片
思考一下:环形队列如何判断为满,为空???
可以使用 rear == front来判断两个状态吗?答案是不行,解释:假设数组的容量为5,插入5个数据后,front重新回到了rear的位置,这个位置既表示空,又表示满吗,明显矛盾了。所以该策略行不通。得换想法。
策略1:多开一个空间,rear,front位于同一位置时,表示队列为空;进行插入数据时,当 front 的下一个位置是rear时,表示队列为满。
策略2:可以使用一个计数器count,当count ==0 表示队列为空,当 count == 队列的容量时,表示队列为满。

众所周知:信号量本身就是一个计数器,所以选择策略2合适。

运转模式:

  • 环形队列为空时:消费者进行阻塞,只能由生产者进行生产,生产完商品后,消费者才能消费产品。
  • 环形队列为满时:生产者进行阻塞,只能由消费者进行消费,消费完商品后,生产者才能生产产品。
  • 其他情况:两者可以并发进行生产或消费。

2.2 单生产单消费模型

思考一下:需要什么成员变量

  • 数组:用来模拟交易场所
  • _cap 变量:用来表示数组的容量
  • _blank_sem: 用来表示生产者是否可以生产数据
  • _p_step:用来表示生产者生产完数据后,到了哪里
  • _data_sem:用来表示消费者是否可以消费数据
  • _c_step:用来表示消费者消费完数据后,到了哪里

伪代码如下:

template <typename T>
class RingQueue
{
private:
    std::vector<T> _rq;
    int _cap;

    // 消费者
    Sem _blank_sem;
    int _p_step;
    // 生产者
    Sem _data_sem;
    int _c_step;

    Mutex _cmutex;
    Mutex _pmutex;
};

为了支持处理任意数据,咱们设计成模版。

思考一下:为什么需要两把锁,一把锁可以吗???

答:一把锁可以,但性能显著降低且效率低下,举个例子:当消费者去超市消费商品时,生产者不能生产商品吗,答案是否定的,因为一把锁的存在,生产者必须等消费者消费完数据后,生产者才能申请锁,往超市运输产品,明显效率低下;而两把锁,消费者消费商品的同时,生产者也可以将生产的产品运输至该超市,效率明显大大的提高,为什么可以呢,因为是两把锁,消费者不需要等生产者的锁释放,只需等相同工产释放锁就可以生产了。

现在就需要对成员变量进行初始化了,构造函数就可以完成该工作

伪代码如下:

static const int gcap = 5;
template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = gcap)
        : _cap(cap),
          _rq(cap), //_rq = std::vector(cap); // 初始化列表写法更高效
          _blank_sem(cap),
          _p_step(0),
          _data_sem(0),
          _c_step(0)
    {
    }
};

思考一下:为什么_blank_sem为cap,而_data_sem为0???
刚开始时,生产者没有生产数据,即数据位置为0,因为任意位置没有数据,即空位置为数组容量的大小。

析构函数完成资源的清理,毕竟做任何事有始有终。

伪代码:

template <typename T>
class RingQueue
{
public:
    ~RingQueue() {}
};

思考一下:生产者如何往环形队列中插入数据???
先申请信号量,在哪申请 -> 生产者关注的是空位置,即__blank_sem执行P操作,生产数据将_blank_sem的值-1,将数据放入指定位置的同时维持环形特性,最后释放信号量。
伪代码如下:

template <typename T>
class RingQueue
{
public:
    void Equeue(const T &in)
    {
        // 1. 申请信号量,将信号量减1
        _blank_sem.P();
        {
            LockGuard lockguard(_cmutex);//防止其它生产者生产,造成数据不一致问题
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V(); // 将数据个数+1
    }
};

思考一下:消费者如何消费数据呢???
先申请信号量,在哪申请 -> 消费者关注的是有数据位置,即__data_sem执行P操作,消费数据将_data_sem的值-1,将数据取出的同时维持环形特性,最后释放信号量。
伪代码如下:

template <typename T>
class RingQueue
{
public:
    void Pop(T *out)
    {
        // 消费者
        //  1. 申请信号量,数据信号量
        _data_sem.P(); // 如果没有数据,线程将会在此处被阻塞
        {
            LockGuard lockguard(_pmutex);
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 同上
            _c_step %= _cap;
        }

        _blank_sem.V();
    }
};
  1. 思考一下:这里好像没有用到跟阻塞队列一样使用while循环判断???
    因为P,V操作是原子的,当条件不满足时,会进行自动阻塞,里面就有判断的逻辑。

  2. 最后一个问题:可以把加锁放在申请信号量前吗???
    没必要,举个例子,当你看电影时,是直接去排队还是先买票(这个买票就是先申请信号量),肯定是先买票,然后在排队,你把票全售出了,然后再来排队,当你买的票后,在申请进入电影院看电影,这不大大提高运行效率,假如电影院只有100张票,比如前一种方案100万人全来排队,然后直接买票,导致100万-100的人买不到票只能干等着,什么都做不了;第二种方案100万人中只有100人可以买到票,然后申请进入电影院,而100万-100剩余的人可以去其它的电影院看其他电影,明显提高效率。

2.3 多生产多消费模型

需要改代码吗???不需要,因为锁的存在维持了消费者与消费者之间,生产者与生产者之间的互斥关系。

  • 下面直接赋源码:

Sem.hpp

#pragma once
#include 
#include 
#include 

namespace SemModule
{
    const int defaultvalue = 1;
    class Sem
    {
    public:
        Sem(unsigned int sem_value = defaultvalue) 
        {
            sem_init(&_sem,0,sem_value);
        }
        void P()
        {
            //核心功能是原子性地减少信号量的值,并根据信号量的状态决定是否阻塞当前线程/进程。
            int n = sem_wait(&_sem);//-1原子的
            (void)n;
        }
        void V()
        {
            //功能是原子性地增加信号量的值,并可能唤醒因等待该信号量而阻塞的线程/进程。
            int n = sem_post(&_sem);//+1原子的
            (void)n;
        }
        ~Sem() 
        {
            sem_destroy(&_sem);
        }

    private:
        sem_t _sem;
    };
}

RingQueue.hpp

#pragma once

#include 
#include 
#include "Sem.hpp"
#include "Mutex.hpp"

using namespace SemModule;
using namespace MutexModule;

static const int gcap = 5;

template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = gcap)
        : _cap(cap),
          _rq(cap), //_rq = std::vector(cap); // 初始化列表写法更高效
          _blank_sem(cap),
          _p_step(0),
          _data_sem(0),
          _c_step(0)
    {
    }
    void Equeue(const T &in)
    {
        // 1. 申请信号量,将信号量减1
        _blank_sem.P();
        {
            LockGuard lockguard(_cmutex);
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V(); // 将数据个数+1
    }
    void Pop(T *out)
    {
        // 消费者
        //  1. 申请信号量,数据信号量
        _data_sem.P(); // 如果没有数据,线程将会在此处被阻塞
        {
            LockGuard lockguard(_pmutex);
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 同上
            _c_step %= _cap;
        }

        _blank_sem.V();
    }
    ~RingQueue() {}

private:
    std::vector<T> _rq;
    int _cap;

    // 消费者
    Sem _blank_sem;
    int _p_step;
    // 生产者
    Sem _data_sem;
    int _c_step;

    Mutex _cmutex;
    Mutex _pmutex;
};

Mutex.hpp

#pragma once
#include 
#include 
#include 

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n == 0)
            {
                //std::cout << "加锁成功" << std::endl;
            }
            else
            {
                //std::cout << "加锁失败" << strerror(n) << std::endl;
            }
        }
        void UnLock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n == 0)
            {
                //std::cout << "解锁成功" << std::endl;
            }
            else
            {
                //std::cout << "解锁失败" << strerror(n) << std::endl;
            }
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
        public:
        LockGuard(Mutex &mutex):_mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.UnLock();
        }
        private:
        Mutex &_mutex;
    };
};

测试代码:Main.cc

#include 
#include 
#include 
#include "RingQueue.hpp"

struct threaddata
{
    RingQueue<int> *rq;
    std::string name;
};

int data =1;

void *consumer(void *args)
{
    threaddata *td = static_cast<threaddata*>(args);

    while (true)
    {
        sleep(3);
        // 1. 消费任务
        int t = 0;
        td->rq->Pop(&t);

        // 2. 处理任务 -- 处理任务的时候,这个任务,已经被拿到线程的上下文中了,不属于队列了
        std::cout << td->name << " 消费者拿到了一个数据:  " << t << std::endl;
    }
}

void *productor(void *args)
{
    threaddata *td = static_cast<threaddata*>(args);
    
    while (true)
    {
        sleep(1);
        // 1. 获得任务
        std::cout << td->name << " 生产了一个任务: " << data << std::endl;

        // 2. 生产任务
        td->rq->Equeue(data);

        data++;
    }
}

int main()
{
    RingQueue<int> *rq = new RingQueue<int>();

    pthread_t c[2], p[3];


    threaddata *td = new threaddata();
    td->name = "cthread-1";
    td->rq = rq;
    pthread_create(c, nullptr, consumer, td);

    threaddata *td2 = new threaddata();
    td2->name = "cthread-2";
    td2->rq = rq;
    pthread_create(c + 1, nullptr, consumer, td2);

    threaddata *td3 = new threaddata();
    td3->name = "pthread-3";
    td3->rq = rq;
    pthread_create(p, nullptr, productor, td3);

    threaddata *td4 = new threaddata();
    td4->name = "pthread-4";
    td4->rq = rq;
    pthread_create(p + 1, nullptr, productor, td4);

    threaddata *td5 = new threaddata();
    td5->name = "pthread-5";
    td5->rq = rq;
    pthread_create(p + 2, nullptr, productor, td5);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;

三. 最后

本文介绍了基于POSIX信号量与环形队列实现的生产者-消费者模型。核心内容包括:1)POSIX信号量机制(sem_init/sem_wait/sem_post等接口)及线程/进程间同步原理;2)环形队列实现细节,通过模运算解决空满判断难题,采用双信号量(空白位计数_blank_sem与数据计数_data_sem)控制生产消费节奏;3)双锁机制(_cmutex/_pmutex)设计,通过分离生产者与消费者临界区提升并发性能,对比单锁方案的效率优势;4)给出模板化环形队列的C++实现代码及多线程测试案例,验证模型在并发场景下的稳定性。

你可能感兴趣的:(Linux篇,#,Linux系统篇,linux,POSIX信号量,环形队列)