POSIX 信号量与基于环形队列的生产者消费者模型

2045 字
10 分钟
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;
// 生产后移动 tail
tail = (tail + 1) % N;
// 消费后移动 head
head = (head + 1) % N;
  • 取模运算使得指针在到达数组末尾后回到开头,形成逻辑上的环。

2.2 空 / 满的判定问题#

环形队列的一个经典问题是:当 head == tail 时,队列可能是空的,也可能是满的。为了解决歧义,有两种常见方案:

  • 方案一:使用计数器 count 单独用一个整数记录当前队列中的元素个数。

    • 空:count == 0

    • 满:count == N

  • 方案二:牺牲一个位置(空一格) 规定队列始终保留一个空位。

    • 空:head == tail

    • 满:(tail + 1) % N == head 这样 head 永远不会追上 tail,但会浪费一个槽位。

两种方案各有优缺点,在生产者消费者模型中,我们通常采用 计数信号量 来控制,因此空/满判断不再需要显式比较,而是由信号量的值自动体现。

3. 基于环形队列的生产者消费者模型#

3.1 四个核心约定#

为了保证系统正确运行,生产者与消费者必须遵守以下约定:

  1. 队列为空时,消费者不能消费,生产者应先运行。

  2. 队列为满时,生产者不能生产,消费者应先运行。

  3. 生产者不能超过消费者一圈以上(即不能覆盖尚未消费的数据)。

  4. 消费者不能超过生产者(即不能消费尚未生产的数据)。

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,并唤醒可能阻塞在该信号量上的一个线程。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
HandlerOne
おかえり
公告
本站已整理 138 篇技术笔记,并按 8 条主线排成学习路线(141 站);站内搜索与桌宠对话均已上线。
分类
标签
最新动态
站点统计
文章
138
分类
12
标签
42
总字数
597,044
站点信息
构建平台
腾讯云 · Nginx
博客版本
v6.16.8
文章目录