Linux系统篇47——线程(十二) POSIX 信号量与环形队列,把判空判满提前到访问之前 本文收录于「流浪」的系列专栏Linux系统⚙️C数据结构与算法PythonLangChain LangGraph️MySQL 数据库Git 工具计算机网络LLM大厂面试、八股学习筑基专栏 博客主页流浪 原创首发于 CSDN前言线程十一用一把互斥锁加两个条件变量把阻塞队列写完整判空判满发生在临界区里面。本篇换第二种实现——POSIX 信号量计数器数的就是资源剩余份数申请失败就等于条件不成立判断被挪到访问临界资源之前还自带原子性。最后落到环形队列版生产者消费者单生产单消费一把锁都不用。一、POSIX 信号量是什么1.1 一种新的标准1. 和 System V 是同类角色POSIX 是一种新的标准它和 System V 扮演的是同类角色都提供信号量这套同步机制System V 那一版在《Linux系统篇28——通信五信号量专篇》里拆过底朝天三件套加自定的 union semun全挂在内核大表上POSIX 相比 System V 更常用写多线程代码默认选它2. 手册给两套接口的定位手册口径System V 信号量是更老的 APIPOSIX 信号量提供的是**更简单、设计更好的接口**同一页手册也补了实话POSIX 可用面不如 System V 广老系统上更常见的是后者所以「淘汰」说的是选择不是删除内核从没把 System V 摘掉1.2 System V 为什么被淘汰1. 复杂度摆在那创建、控制、操作是 semget、semctl、semop 三套系统调用各记一套参数还得惦记 SEM_UNDO——异常退出不还就是死锁隐患这些篇 28 都演示过POSIX 这边四个函数走完全程sem_init、sem_wait、sem_post、sem_destroy本体就是一个 sem_t 变量2. 本体就是一个变量sem_t 不需要先去内核创建对象拿 id直接当全局变量或类的成员变量用接口变简单的本质是把使用门槛从三套系统调用压成一个变量的四个方法1.3 出现时间与发展历史1. 时间线System V 在前出自 ATT 的 System V UNIX 一脉三样 IPC 都在这条线上POSIX 信号量在后1993 年获批的 POSIX.1b 实时扩展把它标准化2001 年并入 POSIX.1 基础规范冠 POSIX 前缀就是为了和更古老的 System V 区分开2. Linux 侧的落地Linux 2.6 之前只支持无名的线程间信号量2.6 内核配上提供 NPTL 的 glibc 之后才算完整实现NPTL 在《Linux系统篇42——线程七pthread库管理线程的工作流》里拆过头文件是 semaphore.h编译加 -pthread信号量分有名、无名两种形态跨进程用有名的多线程用无名的就够二、信号量在数什么2.1 从订票说起1. 买票就是预定打个比方还是那家电影院座位被所有观众共享看电影先买票买票就是对座位这一临界资源的预定票卖一张少一张还有多少票就有多少座位没被预定完整版篇 28 铺过信号量干的就是售票处的活2. 计数器统计的是剩余份数信号量这个计数器数的就是临界资源还剩几份大于 0 意味着还能预定等于 0 意味着一份都预定不到资源份数、计数器数值、可预定次数三者始终是一回事2.2 手册定义1. 一个不许减到负数的整数手册口径信号量是一个整数它的值永远不被允许低于零能做的操作只有两种sem_post 让值加一sem_wait 让值减一值为零时 sem_wait 阻塞直到值重新大于零才能完成这次减一2. 减一和加一各是什么语义减一是申请资源剩余份数少一份加一是释放资源用完还回去多一份申请和释放对应教科书上的P 操作和 V 操作篇 28 起就沿用这对记号2.3 普通信号量与二元信号量1. 按数的份数分两种相对于数 N 份相同资源的普通信号量还有一种信号量是二元信号量数的是只有一份的资源普通信号量初始值是 N二元信号量初始值是 1取值只在 0 和 1 之间变化2. 二元与互斥锁初始值为 1 的信号量行为上和互斥锁非常像一次只放一个执行流进去但两者不等价——信号量没有持有者概念谁都能释放互斥锁有不是加锁者解锁会被拦下这组区别篇 28 拆过完整版记住结论像但不等价三、两种使用场景3.1 整体使用1. 资源当一个整体进出第一种场景把资源当成一个整体使用执行流要么整体拿走、要么整体还回阻塞队列就是典型篇 46 的阻塞队列被一把互斥锁全包判空判满都在锁里面完成2. 配套技术是互斥加同步mutex 负责动资源这一步的互斥进临界区改数据一次只许一个二元信号量负责等资源这一步的同步资源不可用时让申请者睡回去等篇 46 的条件变量就是这个角色3.2 分块使用1. 资源划成格子分别预定第二种场景把资源按不同块分批使用划成 N 个独立格子每个格子单独预定格子之间互不干扰生产和消费可以落在不同格子上并行环形队列就是典型结构2. 配套技术是信号量本身一格一份资源信号量直接数格子数空格子的一个数有数据格子的一个整体使用选 mutex 加二元信号量分块使用选信号量两种场景两套工具四、P 与 V 的原子性4.1 申请与释放的约定1. 所有线程先看到同一个信号量多线程使用资源的第一步是所有线程都能看到同一个信号量 sem它本身是个共享变量申请的约定是 P 操作计数器减一落到接口上是 sem_wait释放的约定是 V 操作计数器加一落到接口上是 sem_post2. 申请失败怎么办计数器值为零时申请失败条件不满足申请者阻塞在信号量上等没有进临界区别的线程释放资源触发 V 操作值回到大于零等待者被唤醒接着完成自己的减一4.2 信号量本身也是临界资源1. 保护者也需要被保护计数器被所有线程同时读写所以信号量这个变量本身也是临界资源线程八里的 ticket-- 就是反面教材一句减一被编译成三步切走一次就错一次2. P 和 V 必须是原子的所以 P: --、V: 这两步必须是原子操作这是信号量能立住的前提手册口径sem_wait 在值大于零时减一立即返回值为零时阻塞sem_post 完成加一后若值因此大于零唤醒一个阻塞在 sem_wait 里的线程原子性由库函数在内部打包保证使用者拿到的是一步到位的语义五、POSIX 信号量的接口5.1 sem_init1. 原型与参数intsem_init(sem_t*sem,intpshared,unsignedintvalue);// sem要初始化的信号量本体就是一个 sem_t 变量// pshared0 表示线程间共享非 0 表示进程间共享// value计数器初始值也就是资源的初始份数pshared 为 0 时信号量放在所有线程都可见的位置全局或堆上都行非 0 时必须放进共享内存进程间才能共同操作重复 init 是未定义行为value 超过 SEM_VALUE_MAX 返回 EINVAL系统不支持进程间共享返回 ENOSYS成功返回 0、失败返回 -1 并设置 errno5.2 sem_wait 与两个变体1. 原型与语义intsem_wait(sem_t*sem);// 申请资源计数器减 1值为 0 就阻塞intsem_trywait(sem_t*sem);// 非阻塞版申请不到立刻返回errno 置 EAGAINintsem_timedwait(sem_t*sem,conststructtimespec*abs_timeout);// 带绝对超时的等待sem_wait 的三态值大于零减一立即返回值为零阻塞等被信号打断返回 EINTREINTR 不是真正的失败值没减、资源没拿到重试一次就行sem_timedwait 传绝对时刻到点没申请到就返回 ETIMEDOUT怕睡死过去的场景用它5.3 sem_post 与 sem_destroy1. 原型与语义intsem_post(sem_t*sem);// 释放资源计数器加 1唤醒一个等待者intsem_destroy(sem_t*sem);// 销毁无名信号量用完且无人等待时调用sem_post 完成加一后若值因此大于零唤醒一个阻塞在 sem_wait 中的线程一次 post 只唤醒一个要叫醒几个就 post 几次手册口径sem_post 是异步信号安全的可以在信号处理函数里安全调用2. destroy 的雷区只销毁 sem_init 初始化的信号量还有线程阻塞在它上等待时销毁是未定义行为所在内存要释放前先 destroy 再放内存顺序反了在某些实现上会漏资源5.4 封装#includeiostream#includesemaphore.hstaticconstintNUM1;classSemModule{public:SemModule(intdefaultnumNUM){sem_init(_sem,0,defaultnum);}voidP(){sem_wait(_sem);//自动减减}voidV(){sem_post(_sem);//自动加加}~SemModule(){sem_destroy(_sem);}private:sem_t _sem;};六、为什么信号量版不用再判条件6.1 条件变量版的判断在临界区里面1. 篇 46 的骨架回顾阻塞队列版的生产者流程加锁、判满、满了就 wait、不满就写入、唤醒、解锁判空判满发生在持有互斥锁的状态下是临界区内部的动作判断针对的是队列这一整块资源6.2 信号量把判断提前到访问之前1. 申请失败就是条件不满足信号量是用来描述临界资源数目的申请失败就是条件不满足这两件事是同一件事生产者关心有没有空位就让一个信号量数空位消费者关心有没有数据就让一个信号量数数据P 操作挡在访问临界资源之前值不够就睡在信号量上人根本没走到临界区门口2. 判断被原子化地前移了信号量是把对临界资源是否存在、是否就绪等条件以原子性的形式呈现在访问临界资源之前就判断了判断和申请合成一个原子动作没有「判完到进门之间条件变了」的窗口篇 46 里判空判满的 while 循环在这一版里没有对应物6.3 醒来之后不用再判1. 信号量的值本身就是条件被唤醒意味着值已经回到大于零条件已经成立直接进临界区干活不存在唤醒打偏的问题七、基于环形队列的生产者消费者7.1 单生产者消费者模型1 环形结构2 成员变量int_cap;//信号量std::vectorT_rq;//队列 用数组模拟 方便实现环形SemModule _c_sem;//封装过的信号量 消费者SemModule _p_sem;// 生产者Mutex _c_mutex;//封装过的锁多 消费者//生产者会用到Mutex _p_mutex;// 生产者int_c_step;// 消费者下标int_p_step;// 生产者下标3 构造staticconstintSpace5;RingQueue():_cap(Space)//信号量,_rq(_cap)//队列容量,_c_step(0)//消费者初始下标,_p_step(0)//生产者初始下标,_c_sem(0)//消费者信号量刚开始没有数据,_p_sem(_cap)//生产者信号量刚开始全是空位{}4 生产者入队voidEqueue(constTin){// 生产者// 1. 申请信号量空位置信号量//先申请信号量P是检查 “有没有空位”没有就先在门口排队、不占互斥锁从而避免 “占着锁等空位” 造成的死锁。_blank_sem.P();{LockGuardlockguard(_pmutex);// 2. 生产_rq[_p_step]in;// 3. 更新下标_p_step;// 4. 维持环形特性_p_step%_cap;}_data_sem.V();}5 消费者拿数据voidPop(T*out){// 消费者// 1. 申请信号量数据信号量_data_sem.P();{LockGuardlockguard(_cmutex);// 2. 消费*out_rq[_c_step];// 3. 更新下标_c_step;// 4. 维持环形特性_c_step%_cap;}_blank_sem.V();}6 完整落地#includeiostream#includevector#includeSem.hppstaticconstintgcap5;usingnamespaceSemModule;templatetypenameTclassRingQueue{public:RingQueue(intcapgcap):_cap(cap),_rq(cap),_blank_sem(cap),_p_step(0),_data_sem(0),_c_step(0){}voidEqueue(constTin){// 生产者// 1. 申请信号量空位置信号量_blank_sem.P();{// 2. 生产_rq[_p_step]in;// 3. 更新下标_p_step;// 4. 维持环形特性_p_step%_cap;}_data_sem.V();}voidPop(T*out){// 消费者// 1. 申请信号量数据信号量_data_sem.P();{// 2. 消费*out_rq[_c_step];// 3. 更新下标_c_step;// 4. 维持环形特性_c_step%_cap;}_blank_sem.V();}private:std::vectorT_rq;int_cap;// 生产者Sem _blank_sem;// 空位置int_p_step;// 消费者Sem _data_sem;// 数据int_c_step;};main文件void*consumer(void*mes){RingQueueint*bqstatic_castRingQueueint*(mes);while(true){sleep(1);inti0;bq-Pop(i);std::cout我是客户端我拿到了一份数据istd::endl;}returnnullptr;}intdata1;void*productor(void*mes){RingQueueint*bqstatic_castRingQueueint*(mes);while(true){sleep(1);std::cout我是服务端我生产了一份数据std::endl;bq-Equeue(data);data;}returnnullptr;}intmain(){RingQueueint*bqnewRingQueueint();pthread_t c[1],p[1];pthread_create(p[0],nullptr,productor,bq);pthread_create(c[0],nullptr,consumer,bq);pthread_join(c[0],nullptr);pthread_join(p[0],nullptr);deletebq;return0;}7.2 多生产者消费者模型1 mutex封装#includeiostream#includepthread.hnamespaceMutexModule{classMutex{public:Mutex(){pthread_mutex_init(_mutex,nullptr);}voidLock(){pthread_mutex_lock(_mutex);}voidUnLock(){pthread_mutex_unlock(_mutex);}~Mutex(){pthread_mutex_destroy(_mutex);}private:pthread_mutex_t _mutex;};classLockGrund{public:LockGrund(Mutexmutex):_mutex(mutex){_mutex.Lock();}~LockGrund(){_mutex.UnLock();}private:Mutex_mutex;};}2 参数多两把锁int_cap;std::vectorT_rq;SemModule _c_sem;SemModule _p_sem;Mutex _c_mutex;Mutex _p_mutex;int_c_step;int_p_step;3 生产者入队 消费者出队模块加入锁voidEqueue(constTin){// 生产者// 1. 申请信号量空位置信号量_blank_sem.P();{LockGuardlockguard(_pmutex);// 2. 生产_rq[_p_step]in;// 3. 更新下标_p_step;// 4. 维持环形特性_p_step%_cap;}_data_sem.V();}voidPop(T*out){// 消费者// 1. 申请信号量数据信号量_data_sem.P();{LockGuardlockguard(_cmutex);// 2. 消费*out_rq[_c_step];// 3. 更新下标_c_step;// 4. 维持环形特性_c_step%_cap;}_blank_sem.V();}4 主函数逻辑structthreaddata{RingQueueint*rq;std::string name;};void*consumer(void*args){threaddata*tdstatic_castthreaddata*(args);while(true){sleep(3);// 1. 消费任务intt0;td-rq-Pop(t);// 2. 处理任务 -- 处理任务的时候这个任务已经被拿到线程的上下文中了,不属于队列了std::couttd-name 消费者拿到了一个数据: tstd::endl;// t();}}intdata1;void*productor(void*args){threaddata*tdstatic_castthreaddata*(args);while(true){sleep(1);// sleep(2);// 1. 获得任务// std::cout 生产了一个任务: x y ? std::endl;std::couttd-name 生产了一个任务: datastd::endl;// 2. 生产任务td-rq-Equeue(data);data;}}intmain(){// 扩展认识: 阻塞队列: 可以放任务吗// 申请阻塞队列RingQueueint*rqnewRingQueueint();// 构建生产和消费者// 如果我们改成多生产多消费呢// 单单: cc, pp - 互斥关系不需要维护互斥与同步// 多多cc, pp - 之间的互斥关系pthread_t c[2],p[3];threaddata*tdnewthreaddata();td-namecthread-1;td-rqrq;pthread_create(c,nullptr,consumer,td);threaddata*td2newthreaddata();td2-namecthread-2;td2-rqrq;pthread_create(c1,nullptr,consumer,td2);threaddata*td3newthreaddata();td3-namepthread-3;td3-rqrq;pthread_create(p,nullptr,productor,td3);threaddata*td4newthreaddata();td4-namepthread-4;td4-rqrq;pthread_create(p1,nullptr,productor,td4);threaddata*td5newthreaddata();td5-namepthread-5;td5-rqrq;pthread_create(p2,nullptr,productor,td5);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);return0;}八、文末面试题8.1 推导题1. 信号量版生产者消费者为什么不需要 while 再判条件答推导条件变量版判的是队列整体状态唤醒可能打偏醒来条件未必成立所以要 while 再判。信号量版的条件就是计数器的值本身P 操作把判断和申请合成一个原子动作申请失败直接睡在信号量上被唤醒意味着值已经大于零条件已经成立不存在醒了条件又不成立的路径。2. _p_sem 和 _c_sem 的初值为什么是容量和 0答推导初值代表初始时刻资源的份数开场全是空格子、没有数据所以数空位的 _p_sem 初值等于容量数数据的 _c_sem 初值为 0。此后生产是 p 减一、c 加一消费反过来两个计数的和始终等于容量队列状态被两个数字分工记着。3. 单生产者单消费者的环形队列为什么一把锁都不用答推导需要加锁的是被多方读写的变量而生产者下标和消费者下标各只有一个写者单写者变量无竞争。剩下的风险是同一格子同时被读写这被两个信号量挡住写格子的前提是它被计在空位里读格子的前提是它被计在数据里两个集合不相交读写永不相遇。4. 多生产多消费时为什么恰好是两把锁合并成一把行不行答推导多生产会踩生产者下标多消费会踩消费者下标各需要一把锁把写入加前移锁成整体正好对应两条同类竞争关系。合并成一把会死锁生产者拿锁后 P 空位失败睡下锁还攥在手里消费者进不了临界区无法消费也就腾不出空位双双卡死。两把锁不是省并发度是正确性的硬要求。5. 信号量本身也是临界资源P 和 V 的原子性由谁保证答推导由库函数在内部打包保证。sem_wait 把读值、判断、减一、必要时挂起实现成不可分割的整体sem_post 把加一和唤醒打包成一体。使用者在接口外看不到中间状态也就不需要自己再加锁去保护计数器原子性在库这一层被消化掉了。6. 连续 sem_post 多次但没有人等待这些释放会丢吗答推导不会。sem_post 每次把计数器加一值会累积下来之后来的 sem_wait 直接发现值大于零减一通过一次都不浪费。对照条件变量pthread_cond_signal 在没有等待者时信号直接丢失不记数所以条件变量必须配 while 重判而信号量的值本身就是账本。8.2 真题1. 阻塞队列的生产者消费者问题中empty、full、mutex 三个信号量的初值应如何设置答推导 · 已对照解析转述题目给容量为 10 的缓冲区正确答案是 empty 等于 10、full 等于 0、mutex 等于 1。empty 数空缓冲区初始全空所以等于容量full 数产品数量初始没有产品所以为 0mutex 是互斥信号量保护缓冲区一次只被一个执行流操作名额只有一个所以初值为 1。这和本篇的 blank_sem、data_sem 初值设定是同一套逻辑。真题·转述自 51CTO 软考《信号量机制的核心概念与数学依据》2. 九个生产者、六个消费者共享容量为 8 的缓冲区互斥使用的信号量初始值应为多少答推导 · 已对照解析转述正确答案是 1。互斥信号量保护的是缓冲区的整体访问权任何时刻最多一个执行流在操作名额数就是 1跟生产者数量、消费者数量、缓冲区容量都没有关系。解析里专门纠了错选 6把消费者个数当成互斥名额属于同步互斥混为一谈。真题·转述自 CSDN《2.3 进程同步》习题解析结语信号量把「有没有资源」的判断提前到访问之前并且原子化环形队列再把资源拆到格子级锁就省到了最少。评论区聊聊你写环形队列版时踩过的坑觉得有用点个赞Linux 系统篇持续更新。