Linux:基于信号量的环形队列与生产者消费者模型(一)

前言

在 Linux/C++ 后端开发的学习路径中,生产者消费者模型 是必须掌握的核心多线程同步场景,而信号量(Semaphore) 是实现该模型最经典、最直观的方案之一。相比于互斥锁只解决 "临界资源互斥访问",信号量可以直接通过 "计数" 完成线程间的同步控制,完美适配 "生产速度与消费速度不匹配" 的场景。

本文将从零开始,基于 POSIX 信号量、环形队列,封装一套完整的多生产者、多消费者模型,所有代码均可直接编译运行,全程聚焦原理讲解与代码实现,帮助大家彻底吃透信号量的核心用法。

一、核心理论铺垫

1. 什么是生产者消费者模型?

简单来说,生产者消费者模型包含三类角色:

  1. 生产者线程:负责生产数据,放入共享缓冲区;
  2. 消费者线程:负责从共享缓冲区取出数据,进行消费;
  3. 共享缓冲区 :线程间共享的数据容器,本文使用环形队列实现。

该模型要解决两个核心问题:

  • 同步问题:缓冲区为空时,消费者不能消费;缓冲区满时,生产者不能生产;
  • 互斥问题:多个生产者 / 消费者不能同时操作缓冲区的同一块内存。

2. 什么是信号量?

信号量是一种基于计数的同步工具,本质是一个计数器,用于控制对共享资源的访问,核心只有两个操作:

  • P 操作(wait) :计数器-1,如果计数器≤0,线程阻塞等待;
  • V 操作(post) :计数器+1,如果有线程阻塞,唤醒等待的线程。

在本文中,我们使用两个信号量

  • _blank_sem:记录环形队列的空闲空间数量,生产者使用;
  • _data_sem:记录环形队列的有效数据数量,消费者使用。

3. 什么是环形队列?

环形队列是一种固定大小、循环复用内存 的队列结构,通过下标取模实现空间复用,避免普通队列频繁扩容的开销,是生产者消费者模型的最优缓冲区选择。

二、完整代码分模块详解

本文代码共 5 个文件:Sem.hppMutex.hppRingQueue.hppMain.ccMakefile,所有代码严格遵循学习版规范,不考虑冗余健壮性,只聚焦核心逻辑。

1. 信号量封装 Sem.hpp

这是对 POSIX 原生信号量的极简封装,只保留核心的初始化、销毁、P/V 操作:

复制代码
#include <iostream>
#include <semaphore.h>

class Sem
{
public:
    // 初始化信号量,pshared=0表示线程间共享,value为初始计数器
    Sem(unsigned int value = 1)
    {
        sem_init(&_sem, 0, value);
    }

    ~Sem()
    {
        sem_destroy(&_sem); // 释放信号量资源
    }

    void P() // wait:申请资源,计数-1,不足则阻塞
    {
        sem_wait(&_sem);
    }

    void V() // post:释放资源,计数+1,唤醒等待线程
    {
        sem_post(&_sem);
    }

private:
    sem_t _sem; // POSIX原生信号量对象
};

关键细节sem_init第二个参数为 0,代表信号量用于同一进程内的线程间共享,这是多线程编程的标准用法。

2. 互斥锁封装 Mutex.hpp

信号量只解决同步 问题,多线程同时修改队列下标时,必须用互斥锁保证互斥访问,这里使用 RAII 机制自动管理锁:

复制代码
#pragma once
#include <iostream>
#include <pthread.h>

// 原生互斥锁封装
class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    void UnLock()
    {
        pthread_mutex_unlock(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

// RAII风格锁守卫:构造加锁,析构解锁,避免忘记解锁
class MutexGuard
{
public:
    MutexGuard(Mutex& mutex)
        : _mutex(mutex)
    {
        _mutex.Lock();
    }
    ~MutexGuard()
    {
        _mutex.UnLock();
    }
private:
    Mutex& _mutex;
};

作用:保证同一时间只有一个线程修改队列的下标,防止多线程竞争导致的数据错乱。

3. 环形队列封装 RingQueue.hpp

这是整个模型的核心,信号量 + 互斥锁 + 环形队列三者协作的载体:

复制代码
#pragma once
#include "Sem.hpp"
#include <vector>
#include "Mutex.hpp"

template<class T>
class RingQueue
{
public:
    // 初始化队列容量,空闲空间信号量=容量,数据信号量=0
    RingQueue(int cap = 5)
        : _rq(cap)
        , _cap(cap)
        , _blank_sem(cap)
        , _step_c(0)
        , _data_sem(0)
        , _step_p(0)
    {}

    ~RingQueue()
    {}

    // 生产者生产数据
    void push(T in)
    {       
        _blank_sem.P(); // 先申请空闲空间,满则阻塞
        MutexGuard mg(_mutex_p); // 加锁保护下标修改
        _rq[_step_p++] = in;
        _step_p %= _cap; // 下标环形复用
        _data_sem.V(); // 生产完成,通知消费者有新数据
    }

    // 消费者消费数据
    T pop()
    {
        _data_sem.P(); // 先申请有效数据,空则阻塞
        MutexGuard mg(_mutex_p); // 加锁保护下标修改
        T out = _rq[_step_c++];
        _step_c %= _cap; // 下标环形复用
        _blank_sem.V(); // 消费完成,通知生产者有空闲空间
        return out;
    }

private:
    std::vector<T> _rq;      // 队列底层存储
    int _cap;                // 队列最大容量
    Sem _blank_sem;          // 空闲空间信号量
    int _step_p;             // 生产者下标
    Mutex _mutex_p;          // 生产者互斥锁
    Sem _data_sem;           // 有效数据信号量
    int _step_c;             // 消费者下标
    Mutex _mutex_c;          // 消费者互斥锁
};

核心逻辑

  1. 生产者先通过P操作申请空闲空间,再加锁修改队列;
  2. 消费者先通过P操作申请有效数据,再加锁读取队列;
  3. 操作完成后通过V操作释放资源,唤醒对方线程。

4. 主测试文件 Main.cc

创建3 个生产者线程 + 2 个消费者线程,模拟真实多线程场景:

cpp

运行

复制代码
#include "RingQueue.hpp"
#include <pthread.h>
#include <unistd.h>
#include <string>

// 线程参数结构体:传递队列指针+线程名称
struct rq_pthread_name
{
    RingQueue<int>* rq;
    std::string name;
};

// 生产者线程例程
void* routine_p(void* args)
{
    rq_pthread_name* rqn =  (rq_pthread_name*)args;
    std::string name = rqn->name;
    int data = 1;
    while (true)
    {
        sleep(2); // 模拟生产耗时
        std::cout << name << "放入了 : " << data << std::endl;
        rqn->rq->push(data);
        data++;
    }    
}

// 消费者线程例程
void* routine_c(void* args)
{
    rq_pthread_name* rqn =  (rq_pthread_name*)args;
    std::string name = rqn->name;
    while (true)
    {
        sleep(1); // 模拟消费耗时
        int data = rqn->rq->pop();
        std::cout << name << "拿到了 : " << data << std::endl;        
    }
}

int main()
{
    pthread_t c[2]; // 2个消费者
    pthread_t p[3]; // 3个生产者
    RingQueue<int>* rq = new RingQueue<int>();

    // 创建消费者线程
    rq_pthread_name* rqn =  new rq_pthread_name;
    rqn->rq = rq;
    rqn->name = "pthread -> c0"; 
    pthread_create(c, nullptr, routine_c, rqn);

    rqn =  new rq_pthread_name;
    rqn->rq = rq;
    rqn->name = "pthread -> c1";
    pthread_create(c + 1, nullptr, routine_c, rqn);

    // 创建生产者线程
    rqn =  new rq_pthread_name;
    rqn->rq = rq;
    rqn->name = "pthread -> p0";
    pthread_create(p, nullptr, routine_p, rqn);

    rqn =  new rq_pthread_name;
    rqn->rq = rq;
    rqn->name = "pthread -> p1";
    pthread_create(p + 1, nullptr, routine_p, rqn);

    rqn =  new rq_pthread_name;
    rqn->rq = rq;
    rqn->name = "pthread -> p2";
    pthread_create(p + 2, nullptr, routine_p, rqn);

    // 等待所有线程退出
    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;
}

5. 编译脚本 Makefile

复制代码
code : Main.cc
	g++ $^ -o $@ -lpthread -std=c++11
.PHONY : clean
clean : 
	rm -f code

编译命令make,运行:./code

三、运行效果与原理分析

运行后控制台会持续输出如下内容(节选):

复制代码
pthread -> p0放入了 : 1
pthread -> p1放入了 : 1
pthread -> p2放入了 : 1
pthread -> c0拿到了 : 1
pthread -> c1拿到了 : 1
pthread -> c0拿到了 : 1
pthread -> p0放入了 : 2
pthread -> c1拿到了 : 2

效果说明

  1. 3 个生产者每 2 秒生产一个数据,2 个消费者每 1 秒消费一个数据;
  2. 当队列满时,生产者会自动阻塞,不会继续生产;
  3. 当队列空时,消费者会自动阻塞,不会继续消费;
  4. 所有线程安全运行,无数据覆盖、无数据丢失。

四、基础篇总结

通过本文的实现,我们完成了基于信号量的环形队列生产者消费者模型,核心知识点可以总结为 3 点:

  1. 信号量负责同步:通过计数控制生产 / 消费的节奏,解决缓冲区空 / 满的问题;
  2. 互斥锁负责互斥:保护队列下标的修改,解决多线程竞争问题;
  3. 环形队列负责存储:固定大小 + 循环复用,高效适配生产者消费者模型。

对于初学者而言,先理解 "信号量计数和队列资源的对应关系",是掌握该模型的第一步。下一篇进阶博客,我们将深入分析信号量与互斥锁的协作细节,优化代码并讲解多线程场景的核心坑点。

相关推荐
海兰2 小时前
手把手elasticsearch学习增删改查之“增”
运维·jenkins
威桑2 小时前
解决 Qt6 程序 在Linux 环境下无法输入中文的问题
linux·c++·qt
j_xxx404_3 小时前
Linux:文件描述符fd
linux·运维·服务器
未既3 小时前
逻辑卷挂载磁盘操作命令
linux·运维·服务器
那就回到过去3 小时前
拥塞管理和拥塞避免
运维·服务器·网络·网络协议·tcp/ip·ensp
李斯维4 小时前
安装 Arch Linux 到 VMware Workstation 的完全指南
linux
未来之窗软件服务4 小时前
服务器运维(三十六)日志分析nginx日志工具—东方仙盟
运维·服务器·服务器运维·仙盟创梦ide·东方仙盟
香蕉你个不拿拿^5 小时前
Linux粘滞位和文件,目录权限
linux·运维·服务器
木子欢儿5 小时前
Debian挂载飞牛OS创建的RAID分区和Btrfs分区指南
运维·debian