POSIX 信号量与基于环形队列的生产者消费者模型
1. 信号量
在进程间通信(IPC)中已经对信号量有初步认识,接下来将深入学习
1.1 信号量的本质
信号量本质上是一个计数器,用于表示某种资源的数量。它是一种对特定资源的 预订机制:线程在访问资源前必须先“预订”(减少计数器),使用完后“归还”(增加计数器)。
信号量的另一个本质: 信号量不仅是一个计数器,它还将“资源是否就绪”的判断提前到了访问临界资源之前,并且提供了阻塞等待队列。当资源不足时,线程自动挂起;当资源可用时,线程被唤醒。这种机制比主动轮询效率高得多
1.2 P与V操作介绍
-
P 操作(等待):
sem_wait()将信号量的值减 1。如果值为 0,则线程阻塞,直到信号量变为正数。 -
V 操作(发布):
sem_post()将信号量的值加 1,并唤醒可能阻塞在该信号量上的线程。
这两种操作都是原子操作,由内核保证,不会被打断。
注意:信号量本身也是临界资源,但 PV 操作是原子的,因此可以安全地用于同步。
1.3 多线程使用资源的两种场景
-
场景一:将目标资源整体使用 例如一个互斥的缓冲区,同一时刻只能有一个线程访问。 解决方案:互斥锁(mutex) 或 二元信号量(值为 0 或 1)。
-
场景二:将目标资源按不同的“块”分批使用 例如环形队列中有多个空位,多个线程可以同时生产,只要它们操作不同的槽位。 解决方案:计数信号量,计数器表示空闲块的数量。
2. 环形队列的设计与空/满判断
生产者消费者模型常使用环形队列作为共享缓冲区。环形队列是一个固定大小的数组,通过两个指针(下标)head 和 tail 分别指向消费者和生产者当前的位置。
2.1 基本操作
int head = 0, tail = 0;// 生产后移动 tailtail = (tail + 1) % N;// 消费后移动 headhead = (head + 1) % N;- 取模运算使得指针在到达数组末尾后回到开头,形成逻辑上的环。
2.2 空 / 满的判定问题
环形队列的一个经典问题是:当 head == tail 时,队列可能是空的,也可能是满的。为了解决歧义,有两种常见方案:
-
方案一:使用计数器
count单独用一个整数记录当前队列中的元素个数。-
空:
count == 0 -
满:
count == N
-
-
方案二:牺牲一个位置(空一格) 规定队列始终保留一个空位。
-
空:
head == tail -
满:
(tail + 1) % N == head这样head永远不会追上tail,但会浪费一个槽位。
-
两种方案各有优缺点,在生产者消费者模型中,我们通常采用 计数信号量 来控制,因此空/满判断不再需要显式比较,而是由信号量的值自动体现。
3. 基于环形队列的生产者消费者模型
3.1 四个核心约定
为了保证系统正确运行,生产者与消费者必须遵守以下约定:
-
队列为空时,消费者不能消费,生产者应先运行。
-
队列为满时,生产者不能生产,消费者应先运行。
-
生产者不能超过消费者一圈以上(即不能覆盖尚未消费的数据)。
-
消费者不能超过生产者(即不能消费尚未生产的数据)。
3.2 并发可行性分析
-
只要生产者和消费者不访问同一个槽位,就可以同时进行。
-
它们可能访问同一槽位的情况只有两种:
-
队列为空:
head == tail,消费者想取但无数据。 -
队列为满:
head == tail(方案一)或(tail+1)%N == head(方案二),生产者想放但无空位。
-
-
因此,只有当队列为空或为满时,生产者和消费者才需要互斥和同步;在其他情况下,它们可以并发执行。
3.3 如何用信号量保证约定?
我们将环形队列中的资源分为两类:
-
空位置:生产者需要的资源,初始数量 = N。
-
数据:消费者需要的资源,初始数量 = 0。
定义两个信号量:
-
sem_blank:表示空位置的个数,初值为N。 -
sem_data:表示数据的个数,初值为0。
定义两个下标:
-
p_step:生产者下一次生产的位置,初值 0。 -
c_step:消费者下一次消费的位置,初值 0。
生产者线程的流程:
P(sem_blank); // 申请一个空位置,若没有则阻塞在 p_step 处生产数据;p_step = (p_step + 1) % N;V(sem_data); // 数据个数加1,唤醒可能等待的消费者消费者线程的流程:
P(sem_data); // 申请一个数据,若没有则阻塞在 c_step 处消费数据;c_step = (c_step + 1) % N;V(sem_blank); // 空位置个数加1,唤醒可能等待的生产者3.4 约定的实现解析
-
约定1(空):当队列为空时,
sem_data == 0,消费者执行P(sem_data)会阻塞;而sem_blank == N,生产者可以正常执行,从而保证了“空时生产者先运行”。 -
约定2(满):当队列为满时,
sem_blank == 0,生产者执行P(sem_blank)会阻塞;而sem_data == N,消费者可以正常执行,保证了“满时消费者先运行”。 -
约定3(生产者不能超一圈):
sem_blank保证了生产者最多只能比消费者多生产一圈(因为生产者每生产一个就消耗一个空位,当空位为 0 时阻塞)。 -
约定4(消费者不能超生产者):
sem_data保证了消费者最多只能消费已生产的数据数量(当数据为 0 时阻塞)。
关键点:信号量把“资源是否存在”这个条件,以原子形式在访问临界资源之前就进行了判断。如果资源不足,线程会自动阻塞,而不是通过轮询或额外的互斥锁来实现同步
3.5 代码实现思路
3.5.1 版本划分
-
v1:单生产者 + 单消费者 此时不存在多个生产者/消费者之间的竞争,信号量已经足够。
-
v2:多生产者 + 多消费者 多个生产者和多个消费者会同时访问
p_step和c_step,因此需要额外的互斥锁来保护这两个下标(因为下标本身也是临界资源)。 通常做法是:为p_step和c_step各加一把互斥锁,或者只用一个互斥锁保护整个下标数组的访问。不过由于信号量已经保证了槽位分配的原子性,下标操作本身仍然可能被多个线程同时修改,所以必须加锁。
3.5.2 信号量封装(可选)
可以将信号量的初始化、销毁、P、V 操作封装成类或函数,便于复用。
3.5.3 何时用信号量,何时用互斥锁?
-
如果资源可以拆分为多个独立单元(如环形队列的多个槽位),使用计数信号量。
-
如果资源是整体使用的(如一个完整的缓冲区),使用互斥锁(或二元信号量)。
4. 信号量接口(POSIX 标准)
使用信号量需要包含头文件 <semaphore.h>。
sem_init() - 初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);-
sem:指向信号量对象的指针。 -
pshared:-
0:线程间共享(同一进程内)。 -
非零:进程间共享(需要信号量位于共享内存中)。
-
-
value:信号量的初始计数值。
sem_destroy() - 销毁
int sem_destroy(sem_t *sem);销毁信号量,释放资源。
sem_wait() - 等待
int sem_wait(sem_t *sem);将信号量的值减 1。如果值为 0,则阻塞直到信号量变为正数。
sem_post() - 发布
int sem_post(sem_t *sem);将信号量的值加 1,并唤醒可能阻塞在该信号量上的一个线程。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!

