资讯详情

《从零入门Linux系统篇(五十七):线程篇·十——生产者消费者模型进阶:从环形缓冲区到POSIX信号量》

📅 2026/10/5 2:30:52 | 华诺云谱 👁 阅读
《从零入门Linux系统篇(五十七):线程篇·十——生产者消费者模型进阶:从环形缓冲区到POSIX信号量》
本文要做的是围绕环形缓冲区把生产者消费者模型真正跑起来。先从环形缓冲区的核心逻辑讲起看看这个“首尾相接”的结构是怎么转的生产者和消费者又是如何在同一个圈里并发访问、互不踩脚的。接着深入POSIX信号量与P/V操作把并发控制和同步互斥背后的原理一层层拆开。理论打底之后动手完成整套模型的代码实现。最后再借几个特殊场景看看信号量在多线程协作里到底能玩出多少花样。目录一、环形缓冲区生产者消费者模型从原理到同步机制1.1 环形缓冲区的结构与核心逻辑1.2 并发访问中的同步与互斥问题1.2.1 什么情况下可以并发访问1.2.2 什么情况下必须同步与互斥1.3 用POSIX信号量实现P/V同步1.3.1 信号量如何表示共享资源1.3.2 生产者与消费者分别如何执行1.4 特殊情况二元信号量与单缓冲区N1二、POSIX信号量核心接口与基本操作2.1 初始化信号量2.2 销毁信号量2.3 等待信号量P操作2.4 发布信号量V操作三、环形缓冲生产者消费者模型源码实现与分析3.1 RingQueue.hpp环形队列的核心实现3.2 Sem.hppPOSIX信号量封装3.3 Task.hpp任务对象定义3.4 Mutex.hpp互斥锁封装3.5 Main.cc程序运行入口四、代码问题排查与实现优化4.1 致命问题RandTask()为何反复创建TaskManager4.2 锁的粒度为什么V操作应该移到锁外4.3 手动加解锁的问题为什么应该使用RAII锁4.4 内存泄漏为什么delete[]可能永远执行不到4.5 rand()的线程安全问题4.6 Pop()中的T data为什么依赖默认构造4.7 优化后的RingQueue核心实现4.8 对整体实现的评价4.9 P操作与Lock操作谁应该先执行4.9.1 为什么这样才能保证正确性4.9.2 高效性对比用“买电影票”理解锁与信号量一、环形缓冲区生产者消费者模型从原理到同步机制1.1 环形缓冲区的结构与核心逻辑在并发编程里基于环形缓冲Ring Buffer的生产者消费者模型算得上是一种极其高效的数据同步结构。它跟传统的“单队列加锁”模式不一样环形缓冲区靠的是固定大小的数组容量为N加上POSIX信号量在条件允许的时候能让生产和消费真正并行起来。POSIX标准下的信号量比起老派的System V信号量更轻量也更高效。要想让环形缓冲区在多线程环境里既安全又正确有四条核心约束我们得先立好约定 1缓冲区空了生产者得先动起来。约定 2缓冲区满了消费者得先动手。约定 3生产者不能把消费者“套圈”不能超出一整圈。本质上就是防止它覆盖掉上一轮还没被消费的数据。约定 4消费者不能越过生产者。本质上就是防止它读到还没生产出来的无效数据。不妨把环形缓冲区想象成一张大圆桌桌上摆着一圈盘子也就是空格子。生产者往盘子里放数据消费者从盘子里取数据。一放一取桌子就转起来了。1.2 并发访问中的同步与互斥问题想把环形缓冲区吃透关键就一件事弄明白生产者与消费者什么时候能并排跑什么时候又必须一个等一个。1.2.1 什么情况下可以并发访问只要两边不往同一个槽位里伸手就能同时干活。当环形队列既不满、也不空的时候生产者指针tail/p_step和消费者指针head/c_step各占各的格子谁也碰不着谁。生产归生产消费归消费互不干扰。这时候才是真正的并发系统吞吐量也跟着往上窜。1.2.2 什么情况下必须同步与互斥一旦生产者和消费者瞄上了同一个槽位线程之间就开始抢资源了。这时候互斥与同步必须登场。缓冲区为空时两个指针又碰头了。没数据可消费消费者只能靠边站。互斥加同步先把生产者推上去等它填好数据再把消费者叫醒。缓冲区为满时两个指针再次重合生产者套了一圈追上消费者了。空格子没了生产者只能干等。互斥加同步先让消费者上场腾出空间再回头唤醒生产者。1.3 用POSIX信号量实现P/V同步要把前面那四条约定在代码层面焊死得请出POSIX信号量提供的原子P/V操作。它就是管理计数资源的那把钥匙。1.3.1 信号量如何表示共享资源环形缓冲区里的资源一分为二各有人盯生产者盯着的空槽位数量记作信号量sem_blank初始值就是N。整个缓冲区开局全是空的。消费者盯着的数据槽位数量记作信号量sem_data初始值为0。开局空桌没东西可吃。P操作就是申请资源天生带原子性资源计数大于0减一放行资源计数为0申请线程当场挂起等着。V操作就是释放资源计数加一顺手叫醒一个正在等它的线程。1.3.2 生产者与消费者分别如何执行生产者这边动作分四步申请空位P(sem_blank)也就是sem_blank--。写数据在p_step指向的位置把数据放进去。挪指针p_step (p_step 1) % N转到下一个格子。释放数据资源V(sem_data)也就是sem_data把可能正堵着的消费者叫醒。消费者这边正好反过来申请数据P(sem_data)也就是sem_data--。读数据在c_step指向的位置把数据取出来消费掉。挪指针c_step (c_step 1) % N转到下一个格子。释放空位资源V(sem_blank)也就是sem_blank把可能正堵着的生产者叫醒。一来一回环环相扣生产者用V(sem_data) 去激活消费者的P(sem_data)消费者用V(sem_blank)去激活生产者的P(sem_blank)。一个放一个取一个空出位子一个填补空位。两边互相唤醒节奏咬得严丝合缝一个精密的交替闭环就这么转起来了。1.4 特殊情况二元信号量与单缓冲区N1当环形缓冲区的容量缩到N 1时环形队列就退化成了只有一个格子的单缓冲区。这时候sem_blank从1出发sem_data从0起步。两个信号量都只剩下0和1两种状态这正是二元信号量的本色。于是系统换了一副全新的同步互斥面孔生产者和消费者围着这唯一的格子你放我取轮流上阵。两个二元信号量一卡天然就实现了对同一临界资源的严格同步与互斥访问。二、POSIX信号量核心接口与基本操作用POSIX信号量之前头文件得先请进来semaphore.h。2.1 初始化信号量#include semaphore.h int sem_init(sem_t *sem, int pshared, unsigned int value);参数一个个看sem指向要初始化的那个信号量对象。pshared0表示线程间共享非零表示进程间共享。一个值决定了这把“信号量”的势力范围划在哪儿。value信号量的初始值也就是一开始有多少可用资源。2.2 销毁信号量int sem_destroy(sem_t *sem);用来释放信号量占用的系统资源。但动手销毁之前得先确认一件事没有线程还在等它。有线程堵着你这边把信号量拆了那边就悬在半空了。2.3 等待信号量P操作int sem_wait(sem_t *sem); // P操作行为很干脆信号量的值大于0就减一立刻返回资源到手信号量的值等于0调用线程当场阻塞一直等到有人把信号量的值抬起来为止。2.4 发布信号量V操作int sem_post(sem_t *sem); // V操作发布信号量表示资源用完了该还回去了。动作就是把信号量的值加一。三、环形缓冲生产者消费者模型源码实现与分析先把整套代码拆开看。这个模型由五个文件组成各司其职。3.1 RingQueue.hpp环形队列的核心实现#pragma once #include unistd.h #include cstdio #include vector #include Mutex.hpp #include Sem.hpp using namespace MySem; using namespace MyMutex; const size_t DEFULT_SIZE 5; namespace ProducerAndConsumerProblemByRingQueue { template typename T class RingQueue { public: RingQueue(size_t N DEFULT_SIZE) : _capacity(N), _blank_sem(N), _data_sem(0), _c_step(0), _p_step(0) { _RingQueue.resize(_capacity); } void Equeue(const T args) { //Producer _blank_sem.P(); { _p_mutex.Lock(); _RingQueue[_p_step] args ; _p_step; _p_step % _capacity ; _data_sem.V(); _p_mutex.UnLock(); } } T Pop() { //Consumer T data ; _data_sem.P(); { _c_mutex.Lock(); data _RingQueue[_c_step]; _c_step; _c_step % _capacity ; _blank_sem.V(); _c_mutex.UnLock(); } return data; } ~RingQueue() {} private: std::vectorT _RingQueue; size_t _capacity; Sem _blank_sem; Sem _data_sem; size_t _c_step; size_t _p_step; Mutex _c_mutex; Mutex _p_mutex; }; }核心思路_blank_sem管空位_data_sem管数据。生产者先申请空位拿到就往里放放完把数据资源释放消费者先申请数据拿到就取走取完把空位释放。两把锁_p_mutex和_c_mutex 分别管生产者和消费者之间的竞争。生产者和消费者之间靠信号量天然错开槽位不需要额外互斥。3.2 Sem.hppPOSIX信号量封装#pragma once #include semaphore.h namespace MySem { class Sem { public: Sem(size_t size) { sem_init(_sem, 0, size); } void P() { sem_wait(_sem); } void V() { sem_post(_sem); } ~Sem() { sem_destroy(_sem); } private: sem_t _sem; }; }3.3 Task.hpp任务对象定义#include functional #include iostream #include vector using task_t std::functionvoid(void); const size_t TASK_NUM 3; void MemoryProblem() { std::cout This is a Memory Problem std::endl; } void SQLProblem() { std::cout This is a SQL Problem std::endl; } void InternetProblem() { std::cout This is a Internet Problem std::endl; } class TaskManager { public: TaskManager() default; ~TaskManager() {} void Register(task_t task) { _TaskCollection.push_back(task); } task_t operator[](size_t i) { return _TaskCollection[i]; } private: std::vectortask_t _TaskCollection; };task_t是std::functionvoid(void)任务被抽象成可调用对象。TaskManager负责注册任务用下标访问随机取一个就能派发。3.4 Mutex.hpp互斥锁封装#pragma once #include pthread.h namespace MyMutex { class Mutex { public: Mutex() { pthread_mutex_init(_mutex, nullptr); } void Lock() { pthread_mutex_lock(_mutex); } void UnLock() { pthread_mutex_unlock(_mutex); } ~Mutex() { pthread_mutex_destroy(_mutex); } private: pthread_mutex_t _mutex; }; }3.5 Main.cc程序运行入口#include RingQueue.hpp #include Task.hpp #include ctime using namespace ProducerAndConsumerProblemByRingQueue; const size_t THREAD_NUM 5; class ThreadData { public: ThreadData(RingQueuetask_t* ringqueue, char* name) : _ringqueue(ringqueue), _name(name) {} RingQueuetask_t* _ringqueue; char* _name; }; task_t RandTask() { TaskManager tmang; tmang.Register(MemoryProblem); tmang.Register(SQLProblem); tmang.Register(InternetProblem); return tmang[rand() % TASK_NUM]; } void* Producer(void* args) { char* name static_castThreadData*(args)-_name; RingQueuetask_t* ringqueue static_castThreadData*(args)-_ringqueue; while (true) { std::cout name 生产一个任务 std::endl; ringqueue-Equeue(RandTask()); } delete[](static_castThreadData*(args)-_name); } void* Consumer(void* args) { char* name static_castThreadData*(args)-_name; RingQueuetask_t* ringqueue static_castThreadData*(args)-_ringqueue; while (true) { std::cout name 消费一个任务 std::endl; task_t task ringqueue-Pop(); task(); } delete[](static_castThreadData*(args)-_name); } int main() { srand((unsigned int)time(NULL)); std::vectorpthread_t p_thread; std::vectorpthread_t c_thread; RingQueuetask_t* ringqueue new RingQueuetask_t(); // 生产者们 for (int i 0; i THREAD_NUM; i) { char* name new char[64]; int n snprintf(name, 64, ProducerThread-%d, i); (void)n; ThreadData* data new ThreadData(ringqueue, name); pthread_t tid; pthread_create(tid, nullptr, Producer, data); p_thread.push_back(tid); } // 消费者们 for (int i 0; i THREAD_NUM; i) { char* name new char[64]; int n snprintf(name, 64, ConsumerThread-%d, i); (void)n; ThreadData* data new ThreadData(ringqueue, name); pthread_t tid; pthread_create(tid, nullptr, Consumer, data); c_thread.push_back(tid); } for (auto e : p_thread) pthread_join(e, nullptr); for (auto e : c_thread) pthread_join(e, nullptr); return 0; }主程序创建 5 个生产者和 5 个消费者生产者随机选一个任务往环形队列里丢消费者从队列里取任务并执行。整体框架选型是对的信号量管资源计数两把锁分别保护生产者和消费者指针的竞争。但代码里有几处值得优化的地方。四、代码问题排查与实现优化框架没问题但代码里有几个坑有的影响性能有的是隐藏的Bug。4.1 致命问题RandTask()为何反复创建TaskManagertask_t RandTask() { TaskManager tmang; // 每次调用都新建 tmang.Register(MemoryProblem); tmang.Register(SQLProblem); tmang.Register(InternetProblem); return tmang[rand() % TASK_NUM]; }每生产一个任务都要构造一个TaskManager往vector里塞三个std::function返回一个拷贝然后对象销毁。生产频繁时这是纯纯的浪费。改成静态对象一次构造终身复用task_t RandTask() { static TaskManager tmang [] { TaskManager tm; tm.Register(MemoryProblem); tm.Register(SQLProblem); tm.Register(InternetProblem); return tm; }(); return tmang[rand() % TASK_NUM]; }4.2 锁的粒度为什么V操作应该移到锁外_p_mutex.Lock(); _RingQueue[_p_step] args; _p_step (_p_step 1) % _capacity; _data_sem.V(); // 在锁内 _p_mutex.UnLock();V操作本身不会死锁但它在锁内会延长持锁时间。唤醒的线程如果立刻去抢同一把锁还会多一次无谓的上下文切换。挪到锁外_p_mutex.Lock(); _RingQueue[_p_step] args; _p_step (_p_step 1) % _capacity; _p_mutex.UnLock(); _data_sem.V();4.3 手动加解锁的问题为什么应该使用RAII锁_p_mutex.Lock()和UnLock()中间万一抛异常或提前返回锁就永远锁死了。用RAII守卫构造即加锁析构即解锁{ MutexGuard guard(_p_mutex); _RingQueue[_p_step] args; _p_step (_p_step 1) % _capacity; } _data_sem.V();4.4 内存泄漏为什么delete[]可能永远执行不到void* Producer(void* args) { ... while(true) { ... } // 死循环 delete[](static_castThreadData*(args)-_name); // 永远到不了 }while(true)是死循环后面的delete[]永远执行不到。而且ThreadData* data new ThreadData(...) 也没人释放。演示代码里进程退出会兜底但工程代码里这是硬伤。更省心的做法是用智能指针auto data std::make_uniqueThreadData(ringqueue, name); pthread_create(tid, nullptr, Producer, data.get()); data.release(); // 线程函数里再接管或者干脆把ThreadData和name都放到栈上用结构体传值根本不用new。4.5 rand()的线程安全问题五个生产者线程同时调rand()标准并不保证线程安全。换成thread_local的随机数引擎thread_local std::mt19937 rng(std::random_device{}()); int idx rng() % TASK_NUM;4.6 Pop()中的T data为什么依赖默认构造T data; // 要求 T 有默认构造函数 _data_sem.P(); data _RingQueue[_c_step];如果T没有默认构造函数这里直接编译不过。改成拷贝初始化T Pop() { _data_sem.P(); _c_mutex.Lock(); T data _RingQueue[_c_step]; _c_step (_c_step 1) % _capacity; _c_mutex.UnLock(); _blank_sem.V(); return data; }4.7 优化后的RingQueue核心实现void Enqueue(const T args) { _blank_sem.P(); { MutexGuard guard(_p_mutex); _RingQueue[_p_step] args; _p_step (_p_step 1) % _capacity; } _data_sem.V(); } T Pop() { _data_sem.P(); T data; { MutexGuard guard(_c_mutex); data _RingQueue[_c_step]; _c_step (_c_step 1) % _capacity; } _blank_sem.V(); return data; }4.8 对整体实现的评价框架选型没问题信号量管资源计数两把锁分别保护生产者之间和消费者之间的竞争生产者和消费者之间靠信号量天然错开槽位不需要额外互斥。这套设计是对的。主要优化集中在三块TaskManager别反复重建、V操作移出锁外、手动锁换成RAII。前两个是性能问题第三个是健壮性问题。改完之后这份代码就从“能跑”升级成“跑得好、不容易崩”了。4.9 P操作与Lock操作谁应该先执行在实现环形缓冲区时“申请信号量”和“申请互斥锁”谁先谁后存在两种写法顺序一先加锁再申请信号量。线程先拿到互斥锁进入临界区再进行sem_wait申请资源。顺序二先申请信号量再加锁。线程先sem_wait申请资源成功拿到资源后再获取互斥锁。两种方式功能上都能跑通但在多线程环境下顺序二的执行效率明显更高。4.9.1 为什么这样才能保证正确性有人可能会担心先P操作、后加锁会不会不安全答案是不会。信号量的P/V操作由操作系统底层保证原子性不需要互斥锁再给它加一层保护。P操作本身就是原子的线程要么拿到资源要么被挂起中间没有可插入的窗口。4.9.2 高效性对比用“买电影票”理解锁与信号量用“买电影票”打个比方两种顺序的差距一目了然。先加锁再申请信号量相当于所有人排成一条单列长队只有排到最前面的人才能掏出手机尝试买票。如果票已经卖完了这个人挂起等待身后排队的所有人跟着一起堵死。整条队伍被卡在一个人身上。先申请信号量再加锁相当于所有人先在网络上各自并发抢票。抢到票的人再去影院门口排队核验入场。没抢到票的压根不用去排队省了那份排队的时间。在并发场景下顺序二的优势非常明显当某一个线程拿到资源、获取锁、在临界区里更新队列下标时其他线程完全可以并发地执行P操作提前预分配资源。等到它们需要进临界区时资源已经攥在手里了只需要再抢一把锁就行。换句话说P操作可以在锁外并行做锁只负责保护临界区那一小段。把P操作挪到锁内等于把“抢资源”这个本来可以并行的事硬生生塞进了串行的临界区白白拉长了持锁时间也拉低了整体并发度。先申请信号量再加锁。锁的粒度越细并发度越高。P操作是原子的放心放在锁外做。如果这篇文章对你有帮助别忘了点个赞、点个收藏、点个关注。你的每一次反馈都是我继续硬核输出的最大动力。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑