恭喜你进入多线程学习的下一关如果说互斥锁和条件变量是“通知机制”我喊你来做那么信号量就是“票数机制”看还有没有票有票才能做。结合环形队列这是生产者消费者模型的另一种更优雅、更高效的实现方式。 核心知识点简单讲解1. 信号量信号量本质上是一个非负整数计数器用来衡量“资源的剩余数量”。它只有两个核心操作P 操作wait / acquire申请资源。如果计数器 0则减 1线程继续执行如果计数器 0说明没资源了线程阻塞等待。V 操作post / release释放资源。计数器加 1如果有在此等待的线程唤醒其中一个。在 C11 中标准库没有直接提供信号量C20 才引入 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(amp;sem_room_, 0, capacity_); // 初始化可用空间信号量初始值为capacity_ sem_init(amp;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_guardlt;std::mutexgt; lock(mutex_); // 放入数据 array_[tail_] item; tail_ (tail_ 1) % capacity_; // 环形队列的尾指针移动 // V操作增加一个数据唤醒可能等待的消费者 sem_post(amp;sem_data_); } // 消费者调用从队列里拿出数据 T pop() { // P操作申请一个数据如果空了则自动阻塞等待 sem_wait(sem_data_); // 加锁保证线程安全 std::lock_guardlt;std::mutexgt; lock(mutex_); // 取出数据 T item array_[head_]; head_ (head_ 1) % capacity_; // 环形队列的头指针移动 // V操作增加一个空间唤醒可能等待的生产者 sem_post(amp;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. 生产者与消费者业务逻辑 // RingQueuestd::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 lt;lt; 厨师 lt;lt; chef_id lt;lt; 做好了 [ lt;lt; burger lt;lt; ] 放上出餐台\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::vectorT buffer_; size_t capacity_; std::atomicsize_t head_; // 消费者读取位置 std::atomicsize_t tail_; // 生产者写入位置 public: RingQueue(size_t capacity) : buffer_(capacity 1), capacity_(capacity), head_(0), tail_(0) {} bool produce(const Tamp; 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(Tamp; 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(资源)以便早点释放锁让其他进程进入临界区
阅读完成 · 觉得有帮助?