恭喜你进入多线程学习的下一关!如果说互斥锁和条件变量是“通知机制”(我喊你来做),那么信号量就是“票数机制”(看还有没有票,有票才能做)。
结合环形队列,这是生产者消费者模型的另一种更优雅、更高效的实现方式。
🧠 核心知识点简单讲解
1. 信号量
信号量本质上是一个非负整数计数器,用来衡量“资源的剩余数量”。它只有两个核心操作:
P 操作(wait / acquire):申请资源。如果计数器 > 0,则减 1,线程继续执行;如果计数器 == 0,说明没资源了,线程阻塞等待。
V 操作(post / release):释放资源。计数器加 1,如果有在此等待的线程,唤醒其中一个。
在 C++11 中:标准库没有直接提供信号量(C++20 才引入 std::counting_semaphore),在 Linux 下我们通常使用 POSIX 信号量 <semaphore.h> 中的 sem_wait() 和 sem_post()。
2. 环形队列
就是一个固定大小的数组 + 两个指针(头和尾),当指针走到数组末尾时,通过取模运算 % 绕回到开头,形成一个环。
优势:不需要像 std::queue 那样频繁在堆区分配和释放内存,性能极高,是底层实现缓冲区的首选。
3. 当信号量遇上环形队列(绝配!)
在之前的条件变量写法中,我们需要自己写 while 循环判断满/空。而用信号量,我们可以用两个信号量直接代表“空间数量”和“数据数量”,逻辑极其清晰:
sem_room:代表队列中剩余的空间数量。初始值 = 队列容量。
sem_data:代表队列中现有的数据数量。初始值 = 0。
生产者逻辑:
sem_wait(sem_room):申请一个空间(空间-1)。如果没空间了,自动阻塞。
放入数据。
sem_post(sem_data):释放一个数据(数据+1),唤醒可能在等的消费者。
消费者逻辑:
sem_wait(sem_data):申请一个数据(数据-1)。如果没数据了,自动阻塞。
取出数据。
sem_post(sem_room):释放一个空间(空间+1),唤醒可能在等的生产者。
⚠️ 注意:在这种单生产者、单消费者的场景下,甚至不需要加互斥锁!因为信号量已经保证了生产者和消费者不会操作同一个位置。
#include <iostream> #include <thread> #include <chrono> #include <string> #include <mutex> // linux下的信号量头文件 #include <semaphore.h> // 环形队列的实现 template <typename T> class RingQueue { public: RingQueue(size_t capacity) : capacity_(capacity), head_(0), tail_(0) { if (capacity_ == 0) { throw std::invalid_argument("Capacity must be greater than 0"); } // 动态分配数组 array_ = new T[capacity_]; // 初始化信号量 sem_init(&sem_room_, 0, capacity_); // 初始化可用空间信号量,初始值为capacity_ sem_init(&sem_data_, 0, 0); // 初始化可用数据的信号量,初始值为0 } // 拷贝构造函数 RingQueue(const RingQueue &other) : capacity_(other.capacity_), head_(other.head_), tail_(other.tail_) { array_ = new T[capacity_]; for (size_t i = 0; i < capacity_; ++i) { array_[i] = other.array_[i]; } sem_init(&sem_room_, 0, capacity_); sem_init(&sem_data_, 0, 0); } // 赋值运算符 RingQueue &operator=(const RingQueue &other) { if (this != &other) { delete[] array_; capacity_ = other.capacity_; head_ = other.head_; tail_ = other.tail_; array_ = new T[capacity_]; for (size_t i = 0; i < capacity_; ++i) { array_[i] = other.array_[i]; } sem_destroy(&sem_room_); sem_destroy(&sem_data_); sem_init(&sem_room_, 0, capacity_); sem_init(&sem_data_, 0, 0); } return *this; } ~RingQueue() { delete[] array_; sem_destroy(&sem_room_); sem_destroy(&sem_data_); } // 生产者调用:向队列放入数据 void push(const T &item) { // P操作:申请一个空间,如果满了则自动阻塞等待 sem_wait(&sem_room_); // 加锁保证线程安全 std::lock_guard<std::mutex> lock(mutex_); // 放入数据 array_[tail_] = item; tail_ = (tail_ + 1) % capacity_; // 环形队列的尾指针移动 // V操作:增加一个数据,唤醒可能等待的消费者 sem_post(&sem_data_); } // 消费者调用:从队列里拿出数据 T pop() { // P操作:申请一个数据,如果空了则自动阻塞等待 sem_wait(&sem_data_); // 加锁保证线程安全 std::lock_guard<std::mutex> lock(mutex_); // 取出数据 T item = array_[head_]; head_ = (head_ + 1) % capacity_; // 环形队列的头指针移动 // V操作:增加一个空间,唤醒可能等待的生产者 sem_post(&sem_room_); return item; } private: size_t capacity_; size_t head_; size_t tail_; T *array_; sem_t sem_room_; // 可用空间信号量 sem_t sem_data_; // 可用数据信号量 std::mutex mutex_; // 互斥锁,保证head_和tail_操作的原子性 }; // ========================================== // 2. 生产者与消费者业务逻辑 // ========================================== RingQueue<std::string> ring_queue(5); // 容量为5的环形队列 void chef(int chef_id) { int burger_count = 0; while (true) { std::string burger = "汉堡-R" + std::to_string(chef_id) + "-" + std::to_string(++burger_count); ring_queue.push(burger); std::cout << "👨🍳 厨师" << chef_id << " 做好了 [" << burger << "] 放上出餐台!\n"; std::this_thread::sleep_for(std::chrono::milliseconds(200 + chef_id * 100)); } } void customer(int customer_id) { while (true) { std::string burger = ring_queue.pop(); std::cout << "🤤 顾客" << customer_id << " 拿到了 [" << burger << "] 开始享用!\n"; std::this_thread::sleep_for(std::chrono::milliseconds(500)); } } int main() { // 创建 1 个厨师和 1 个顾客 (注意:信号量+无锁的环形队列适用于单生产者单消费者) // 如果是多生产多消费,需要在push/pop内部再加一层互斥锁保护head_和tail_ std::thread c1(chef, 1); std::thread u1(customer, 1); c1.join(); u1.join(); return 0; }🔍 核心代码深度拆解
tail_ = (tail_ + 1) % capacity_;
这就是“环形”的秘密。当指针走到数组最后一个位置时,加 1 后对容量取模,结果变成 0,指针就瞬间回到了数组开头,就像绕圈跑一样。
为什么这里不需要 mutex?
在之前的 BlockQueue 中,底层是 std::queue,入队和出队都可能修改队列的内部计数器,导致冲突,所以必须加锁。
而在环形队列中,生产者只管往后走(修改 tail_),消费者只管往前走(修改 head_),它们操作的是不同的变量,互不干扰!信号量已经保证了它们不会踩到同一个坑位,所以省去了锁的开销,速度更快。
实现多生产多消费环形队列的核心挑战在于并发环境下的线程安全与数据同步。为了实现高效且无锁(或低锁)的并发访问,通常采用基于原子操作的环形缓冲区设计。以下是具体的实现思路和关键步骤:
1. 数据结构设计
环形队列底层通常基于固定大小的数组实现,并维护两个索引:head(头索引,供消费者读取)和tail(尾索引,供生产者写入)1。为了在无锁环境下正确区分“队列满”和“队列空”的状态,通常会在逻辑上预留一个不存储实际元素的空位,即队列容量为 capacity 时,实际数组大小为 capacity + 11。
2. 无锁并发控制(基于原子操作)
在多生产多消费模型中,多个线程会同时竞争修改 head 或 tail 索引。使用互斥锁会带来较大的性能开销,因此推荐使用原子操作来实现无锁设计1。
生产者写入:生产者首先通过原子操作(如 fetch_add)独占式地申请一个 tail 槽位。申请成功后,该槽位归当前生产者专属,其他生产者会获取到下一个槽位。生产者将数据写入对应槽位后,需要通过原子操作更新队列的有效写入位置,以通知消费者数据已就绪1。
消费者读取:同理,消费者通过原子操作独占式地申请一个 head 槽位,读取数据后,更新有效读取位置,通知生产者该槽位已被释放,可以重新写入1。
3. 队列状态判断
在环形队列中,索引的循环通过取模运算(%)实现。判断队列空满的逻辑如下1:
队列为空:当 head == tail 时,表示队列中没有可消费的数据1。
队列为满:当 (tail + 1) % size == head 时,表示队列已满,生产者需要等待或返回写入失败1。
#include <atomic> #include <vector> template <typename T> class RingQueue { private: std::vector<T> buffer_; size_t capacity_; std::atomic<size_t> head_; // 消费者读取位置 std::atomic<size_t> tail_; // 生产者写入位置 public: RingQueue(size_t capacity) : buffer_(capacity + 1), capacity_(capacity), head_(0), tail_(0) {} bool produce(const T& item) { size_t current_tail = tail_.load(std::memory_order_relaxed); size_t next_tail = (current_tail + 1) % buffer_.size(); // 判断队列是否已满 if (next_tail == head_.load(std::memory_order_acquire)) { return false; // 队列满,写入失败 } buffer_[current_tail] = item; tail_.store(next_tail, std::memory_order_release); // 更新tail,通知消费者 return true; } bool consume(T& item) { size_t current_head = head_.load(std::memory_order_relaxed); // 判断队列是否为空 if (current_head == tail_.load(std::memory_order_acquire)) { return false; // 队列空,读取失败 } item = buffer_[current_head]; size_t next_head = (current_head + 1) % buffer_.size(); head_.store(next_head, std::memory_order_release); // 更新head,通知生产者 return true; } };5. 性能优化建议
缓存行填充:head_ 和 tail_ 在内存中连续存储,多线程高频修改会导致伪共享问题,严重降低性能。应通过填充无用的字节,确保 head_ 和 tail_ 分布在不同的缓存行中1。
内存序优化:在原子操作中,合理使用 std::memory_order_acquire 和 std::memory_order_release 代替默认的 std::memory_order_seq_cst,可以在保证正确性的前提下减少内存屏障带来的性能损耗1。
批量操作:支持批量生产和消费,可以均摊原子操作的开销,显著提升高并发场景下的吞吐量。
1. 什么状态下生产者消费者可以同时进行?
当生产者和消费者没有同时在访问共享的缓冲区时,它们可以同时进行。例如,生产者正在执行“生产一个产品”的局部操作,同时消费者正在执行“消费一个产品”的局部操作,由于这两步不涉及共享变量,因此可以并发执行
2. 什么状态下生产者与消费者需要同步互斥?
需要同步的状态:当缓冲区资源不满足条件时,需要同步。如果缓冲区满了,生产者必须等待消费者取走数据(等待空位);如果缓冲区空了,消费者必须等待生产者放入数据(等待满槽)
需要互斥的状态:当生产者和消费者同时访问共享缓冲区(即进入临界区执行放入或取出操作)时,必须互斥。同一时刻只允许一个进程进入临界区操作共享资源,以防止数据覆盖或读取不一致等竞争条件
3. 先申请信号量还是先加锁?
必须先申请资源信号量,再申请互斥信号量(加锁)
在P操作的顺序中,资源信号量永远排在互斥信号量前面,这是铁律6。如果颠倒顺序,即先 P(mutex) 拿到互斥锁,再 P(empty) 申请资源,当缓冲区满了时会导致死锁:生产者拿到锁后发现没有空位而阻塞等待;而消费者想要取数据释放空位,却因为拿不到锁而无法进入缓冲区。双方互相等待,从而产生死锁
(注:V操作的顺序不影响正确性,但建议先 V(互斥) 再 V(资源),以便早点释放锁让其他进程进入临界区