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

前言

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

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

一、核心理论铺垫

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

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

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

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

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

2. 什么是信号量?

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

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

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

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

3. 什么是环形队列?

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

二、完整代码分模块详解

本文代码共 5 个文件:Sem.hpp、Mutex.hpp、RingQueue.hpp、Main.cc、Makefile,所有代码严格遵循学习版规范,不考虑冗余健壮性,只聚焦核心逻辑。

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. 环形队列负责存储:固定大小 + 循环复用,高效适配生产者消费者模型。

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

相关推荐
xiaoye-duck2 分钟前
《Linux 网络编程》从 0 手写 Reactor 反应堆(上):拆解 Reactor 架构 —— 连接抽象、Poller 封装与事件派发核心
linux·网络
脚踏实地,坚持不懈!3 分钟前
Android 电池健康度检测全流程(驱动 → 内核 → HAL → Framework)
android·linux
IT技术分享社区6 分钟前
在 Windows 上跑未改动的 Linux 程序:微软 LiteBox v0.1 把这件事变简单了
linux·运维·windows·microsoft·开源
Saniffer_SH7 分钟前
【高清视频】一块 SSD,两条 PCIe 链路:Dual-Port NVMe SSD测试环境怎么搭?(一)
服务器·ssd测试·dual-port ssd·企业级ssd测试·dualport·ssd测试环境·dualport ssd测试
迪康妍妍14 分钟前
终端安全实战:用迪康终端安全管理系统实现U盘四分档管控与全量审计
android·运维·网络·安全·电脑
一只旭宝17 分钟前
【网络精讲1】TCP 三次握手:为什么是三次,第三次丢了怎么办
服务器·网络·tcp/ip
Qt云程序员19 分钟前
按钮是图片、系统没接口?我用找图加OCR把它自动化了
运维·自动化·ocr
C++ 老炮儿的技术栈31 分钟前
C++ const 的3个核心作用
服务器·c语言·c++·算法·c
进击的荆棘31 分钟前
Linux系统——库的原理与制作之制作和使用动静态库
linux·动静态库
论文复现现场32 分钟前
8卡 A100 服务器哪里租?NVLink、RDMA 与 NCCL 验收指南
linux·服务器·php·分布式训练·rdma·nvlink·nccl