
📚 本文收录于「流浪」的系列专栏
| 🐧 Linux系统 | ⚙️ C++ |
| 📊 数据结构与算法 | 🐍 Python |
| 🔗 LangChain & 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 信号量是更老的 API,POSIX 信号量提供的是**
更简单、设计更好的接口** - 同一页手册也补了实话: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 在前,出自 AT&T 的 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_post
2. 申请失败怎么办
- 计数器值为零时申请失败,条件不满足,申请者阻塞在信号量上等,没有进临界区
- 别的线程释放资源触发 V 操作,值回到大于零,等待者被唤醒接着完成自己的减一
4.2 信号量本身也是临界资源
1. 保护者也需要被保护
- 计数器被所有线程同时读写,所以信号量这个变量本身也是临界资源
- 线程(八)里的 ticket-- 就是反面教材:一句减一被编译成三步,切走一次就错一次
2. P 和 V 必须是原子的
- 所以 P: --、V: ++ 这两步必须是原子操作,这是信号量能立住的前提
- 手册口径:sem_wait 在值大于零时减一立即返回,值为零时阻塞;sem_post 完成加一后,若值因此大于零,唤醒一个阻塞在 sem_wait 里的线程
- 原子性由库函数在内部打包保证,使用者拿到的是一步到位的语义
五、POSIX 信号量的接口
5.1 sem_init
1. 原型与参数
c
int sem_init(sem_t *sem, int pshared, unsigned int value);
// sem:要初始化的信号量,本体就是一个 sem_t 变量
// pshared:0 表示线程间共享,非 0 表示进程间共享
// value:计数器初始值,也就是资源的初始份数
- pshared 为 0 时,信号量放在所有线程都可见的位置,全局或堆上都行;非 0 时必须放进共享内存,进程间才能共同操作
- 重复 init 是未定义行为;value 超过 SEM_VALUE_MAX 返回 EINVAL,系统不支持进程间共享返回 ENOSYS,成功返回 0、失败返回 -1 并设置 errno
5.2 sem_wait 与两个变体
1. 原型与语义
c
int sem_wait(sem_t *sem); // 申请资源:计数器减 1,值为 0 就阻塞
int sem_trywait(sem_t *sem); // 非阻塞版:申请不到立刻返回,errno 置 EAGAIN
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout); // 带绝对超时的等待
- sem_wait 的三态:值大于零,减一立即返回;值为零,阻塞等;被信号打断,返回 EINTR
- EINTR 不是真正的失败,值没减、资源没拿到,重试一次就行
- sem_timedwait 传绝对时刻,到点没申请到就返回 ETIMEDOUT,怕睡死过去的场景用它
5.3 sem_post 与 sem_destroy
1. 原型与语义
c
int sem_post(sem_t *sem); // 释放资源:计数器加 1,唤醒一个等待者
int sem_destroy(sem_t *sem); // 销毁无名信号量,用完且无人等待时调用
- sem_post 完成加一后,若值因此大于零,唤醒一个阻塞在 sem_wait 中的线程;一次 post 只唤醒一个,要叫醒几个就 post 几次
- 手册口径:sem_post 是异步信号安全的,可以在信号处理函数里安全调用
2. destroy 的雷区
- 只销毁 sem_init 初始化的信号量;还有线程阻塞在它上等待时销毁,是未定义行为
- 所在内存要释放前先 destroy 再放内存,顺序反了在某些实现上会漏资源
5.4 封装
cpp
#include <iostream>
#include<semaphore.h>
static const int NUM=1;
class SemModule
{
public:
SemModule(int defaultnum=NUM)
{
sem_init(&_sem,0,defaultnum);
}
void P()
{
sem_wait(&_sem);//自动减减
}
void V()
{
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 成员变量
cpp
int _cap;//信号量
std::vector<T> _rq;//队列 用数组模拟 方便实现环形
SemModule _c_sem;//封装过的信号量 消费者
SemModule _p_sem;// 生产者
Mutex _c_mutex;//封装过的锁多 消费者
//生产者会用到
Mutex _p_mutex;// 生产者
int _c_step;// 消费者下标
int _p_step;// 生产者下标
3 构造
cpp
static const int Space = 5;
RingQueue()
: _cap(Space)//信号量
, _rq(_cap)//队列容量
, _c_step(0)//消费者初始下标
, _p_step(0)//生产者初始下标
, _c_sem(0)//消费者信号量(刚开始没有数据)
, _p_sem(_cap)//生产者信号量(刚开始全是空位)
{}
4 生产者入队
cpp
void Equeue(const T &in)
{
// 生产者
// 1. 申请信号量,空位置信号量
//先申请信号量(P)是检查 "有没有空位",没有就先在门口排队、不占互斥锁,从而避免 "占着锁等空位" 造成的死锁。
_blank_sem.P();
{
LockGuard lockguard(_pmutex);
// 2. 生产
_rq[_p_step] = in;
// 3. 更新下标
++_p_step;
// 4. 维持环形特性
_p_step %= _cap;
}
_data_sem.V();
}
5 消费者拿数据
cpp
void Pop(T *out)
{
// 消费者
// 1. 申请信号量,数据信号量
_data_sem.P();
{
LockGuard lockguard(_cmutex);
// 2. 消费
*out = _rq[_c_step];
// 3. 更新下标
++_c_step;
// 4. 维持环形特性
_c_step %= _cap;
}
_blank_sem.V();
}
6 完整落地
cpp
#include <iostream>
#include <vector>
#include "Sem.hpp"
static const int gcap = 5;
using namespace SemModule;
template <typename T>
class RingQueue
{
public:
RingQueue(int cap = gcap)
: _cap(cap),
_rq(cap),
_blank_sem(cap),
_p_step(0),
_data_sem(0),
_c_step(0)
{
}
void Equeue(const T &in)
{
// 生产者
// 1. 申请信号量,空位置信号量
_blank_sem.P();
{
// 2. 生产
_rq[_p_step] = in;
// 3. 更新下标
++_p_step;
// 4. 维持环形特性
_p_step %= _cap;
}
_data_sem.V();
}
void Pop(T *out)
{
// 消费者
// 1. 申请信号量,数据信号量
_data_sem.P();
{
// 2. 消费
*out = _rq[_c_step];
// 3. 更新下标
++_c_step;
// 4. 维持环形特性
_c_step %= _cap;
}
_blank_sem.V();
}
private:
std::vector<T> _rq;
int _cap;
// 生产者
Sem _blank_sem; // 空位置
int _p_step;
// 消费者
Sem _data_sem; // 数据
int _c_step;
};
main文件
void *consumer(void *mes)
{
RingQueue<int> *bq = static_cast<RingQueue<int> *>(mes);
while (true)
{
sleep(1);
int i = 0;
bq->Pop(&i);
std::cout << "我是客户端,我拿到了一份数据" << i << std::endl;
}
return nullptr;
}
int data = 1;
void *productor(void *mes)
{
RingQueue<int> *bq = static_cast<RingQueue<int> *>(mes);
while (true)
{
sleep(1);
std::cout << "我是服务端,我生产了一份数据" << std::endl;
bq->Equeue(data);
data++;
}
return nullptr;
}
int main()
{
RingQueue<int> *bq = new RingQueue<int>();
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);
delete bq;
return 0;
}

7.2 多生产者消费者模型
1 mutex封装
cpp
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
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;
};
class LockGrund
{
public:
LockGrund(Mutex &mutex) : _mutex(mutex)
{
_mutex.Lock();
}
~LockGrund()
{
_mutex.UnLock();
}
private:
Mutex &_mutex;
};
}
2 参数多两把锁
cpp
int _cap;
std::vector<T> _rq;
SemModule _c_sem;
SemModule _p_sem;
Mutex _c_mutex;
Mutex _p_mutex;
int _c_step;
int _p_step;
3 生产者入队 消费者出队模块加入锁
cpp
void Equeue(const T &in)
{
// 生产者
// 1. 申请信号量,空位置信号量
_blank_sem.P();
{
LockGuard lockguard(_pmutex);
// 2. 生产
_rq[_p_step] = in;
// 3. 更新下标
++_p_step;
// 4. 维持环形特性
_p_step %= _cap;
}
_data_sem.V();
}
void Pop(T *out)
{
// 消费者
// 1. 申请信号量,数据信号量
_data_sem.P();
{
LockGuard lockguard(_cmutex);
// 2. 消费
*out = _rq[_c_step];
// 3. 更新下标
++_c_step;
// 4. 维持环形特性
_c_step %= _cap;
}
_blank_sem.V();
}
4 主函数逻辑
cpp
struct threaddata
{
RingQueue<int> *rq;
std::string name;
};
void *consumer(void *args)
{
threaddata *td = static_cast<threaddata*>(args);
while (true)
{
sleep(3);
// 1. 消费任务
int t = 0;
td->rq->Pop(&t);
// 2. 处理任务 -- 处理任务的时候,这个任务,已经被拿到线程的上下文中了,不属于队列了
std::cout << td->name << " 消费者拿到了一个数据: " << t << std::endl;
// t();
}
}
int data = 1;
void *productor(void *args)
{
threaddata *td = static_cast<threaddata*>(args);
while (true)
{
sleep(1);
// sleep(2);
// 1. 获得任务
// std::cout << "生产了一个任务: " << x << "+" << y << "=?" << std::endl;
std::cout << td->name << " 生产了一个任务: " << data << std::endl;
// 2. 生产任务
td->rq->Equeue(data);
data++;
}
}
int main()
{
// 扩展认识: 阻塞队列: 可以放任务吗?
// 申请阻塞队列
RingQueue<int> *rq = new RingQueue<int>();
// 构建生产和消费者
// 如果我们改成多生产多消费呢??
// 单单: cc, pp -> 互斥关系不需要维护,互斥与同步
// 多多:cc, pp -> 之间的互斥关系!
pthread_t c[2], p[3];
threaddata *td = new threaddata();
td->name = "cthread-1";
td->rq = rq;
pthread_create(c, nullptr, consumer, td);
threaddata *td2 = new threaddata();
td2->name = "cthread-2";
td2->rq = rq;
pthread_create(c + 1, nullptr, consumer, td2);
threaddata *td3 = new threaddata();
td3->name = "pthread-3";
td3->rq = rq;
pthread_create(p, nullptr, productor, td3);
threaddata *td4 = new threaddata();
td4->name = "pthread-4";
td4->rq = rq;
pthread_create(p + 1, nullptr, productor, td4);
threaddata *td5 = new threaddata();
td5->name = "pthread-5";
td5->rq = rq;
pthread_create(p + 2, 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);
return 0;
}

八、文末面试题
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 数产品数量,初始没有产品所以为 0;mutex 是互斥信号量,保护缓冲区一次只被一个执行流操作,名额只有一个所以初值为 1。这和本篇的 blank_sem、data_sem 初值设定是同一套逻辑。
真题·转述自 51CTO 软考《信号量机制的核心概念与数学依据》
2. 九个生产者、六个消费者共享容量为 8 的缓冲区,互斥使用的信号量初始值应为多少?
答(推导 · 已对照解析,转述):正确答案是 1。互斥信号量保护的是缓冲区的整体访问权,任何时刻最多一个执行流在操作,名额数就是 1,跟生产者数量、消费者数量、缓冲区容量都没有关系。解析里专门纠了错选 6:把消费者个数当成互斥名额,属于同步互斥混为一谈。
真题·转述自 CSDN《2.3 进程同步》习题解析
💬 结语: 信号量把「有没有资源」的判断提前到访问之前并且原子化,环形队列再把资源拆到格子级,锁就省到了最少。评论区聊聊你写环形队列版时踩过的坑,觉得有用点个赞,Linux 系统篇持续更新。