前言
在 Linux/C++ 后端开发的学习路径中,生产者消费者模型 是必须掌握的核心多线程同步场景,而信号量(Semaphore) 是实现该模型最经典、最直观的方案之一。相比于互斥锁只解决 "临界资源互斥访问",信号量可以直接通过 "计数" 完成线程间的同步控制,完美适配 "生产速度与消费速度不匹配" 的场景。
本文将从零开始,基于 POSIX 信号量、环形队列,封装一套完整的多生产者、多消费者模型,所有代码均可直接编译运行,全程聚焦原理讲解与代码实现,帮助大家彻底吃透信号量的核心用法。
一、核心理论铺垫
1. 什么是生产者消费者模型?
简单来说,生产者消费者模型包含三类角色:
- 生产者线程:负责生产数据,放入共享缓冲区;
- 消费者线程:负责从共享缓冲区取出数据,进行消费;
- 共享缓冲区 :线程间共享的数据容器,本文使用环形队列实现。
该模型要解决两个核心问题:
- 同步问题:缓冲区为空时,消费者不能消费;缓冲区满时,生产者不能生产;
- 互斥问题:多个生产者 / 消费者不能同时操作缓冲区的同一块内存。
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; // 消费者互斥锁
};
核心逻辑:
- 生产者先通过
P操作申请空闲空间,再加锁修改队列; - 消费者先通过
P操作申请有效数据,再加锁读取队列; - 操作完成后通过
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
效果说明:
- 3 个生产者每 2 秒生产一个数据,2 个消费者每 1 秒消费一个数据;
- 当队列满时,生产者会自动阻塞,不会继续生产;
- 当队列空时,消费者会自动阻塞,不会继续消费;
- 所有线程安全运行,无数据覆盖、无数据丢失。
四、基础篇总结
通过本文的实现,我们完成了基于信号量的环形队列生产者消费者模型,核心知识点可以总结为 3 点:
- 信号量负责同步:通过计数控制生产 / 消费的节奏,解决缓冲区空 / 满的问题;
- 互斥锁负责互斥:保护队列下标的修改,解决多线程竞争问题;
- 环形队列负责存储:固定大小 + 循环复用,高效适配生产者消费者模型。
对于初学者而言,先理解 "信号量计数和队列资源的对应关系",是掌握该模型的第一步。下一篇进阶博客,我们将深入分析信号量与互斥锁的协作细节,优化代码并讲解多线程场景的核心坑点。