Linux 线程同步与互斥(三) 生产者消费者模型,基于阻塞队列的生产者消费者模型的代码实现

目录

一、生产者消费者模型

什么是生产者消费者模型

如何理解该模型高效?

为什么要使用生产者消费者模型?

二、基于阻塞队列的生产者消费者模型

[1. 单单关系模型](#1. 单单关系模型)

BlockQueue.hpp框架

Main.cc框架

Makefile

扩充1:

模拟阻塞现象

扩充2:

梳理流程

[Enqueue 生产者函数的流程:](#Enqueue 生产者函数的流程:)

[Pop 消费者函数的流程:](#Pop 消费者函数的流程:)

[整体逻辑 :](#整体逻辑 :)

[流程演示 :](#流程演示 :)

[1. 水位线功能](#1. 水位线功能)

[2. 统计正在睡觉的线程数量](#2. 统计正在睡觉的线程数量)

[2. 多多关系模型](#2. 多多关系模型)

三、封装条件变量Cond.hpp

四、完整代码

Thread.hpp

Mutex.hpp

Cond.hpp

BlockQueue.hpp

Task.hpp

Makefile

Main.cc

五、重谈生产者消费者模型

六、总结


一、生产者消费者模型

什么是生产者消费者模型

为了理解生产者消费者模型(productor&&consumer (CP模型)) ,现在我们引入一个买卖辣条的场景供大家理解生产者消费者模型

  1. 假设有这么一个超市,它里面专门卖辣条,日常我们想吃辣条的时候,一般都是去该超市中去购买辣条,我们作为顾客,那么扮演的就是消费者的角色,顾客一般有很多,所以消费者也有很多,当消费者从超市购买完成辣条之后,通常是拿到家里后,在家里吃辣条

  2. 可是超市中的种类繁多的辣条也不是平白无故的出现的,即超市需要从很多供应商进货,供应商负责将辣条放到超市里,但是供应商的辣条也不是凭空出现的,供应商需要有自己的辣条工厂来生产辣条,所以供应商从自己的辣条工厂获取辣条,所以供应商就扮演的是生产者的角色

  3. 超市就如同一个大号的缓存,超市负责从供应商那里进货,然后将辣条放到货架上,卖给消费者,如果超市中的货架上的辣条已经放满了,那么供货商将无法继续供给辣条,所以供应商就应该停下生产,等待消费者购买辣条。如果超市中货架上的辣条已经全部为空了,那么消费者将无法继续购买辣条,所以消费者应该停下购买,等待生产者生产辣条

  4. 假设临近过年了,按照往年经验供应商预计顾客会对辣条的需求量剧增,但是过年的时候,工厂的工人要放假无法生产辣条了,而过年的时候,顾客对辣条的需求量又很大,所以怎么办呢?所以多个供应商在过年前的前一个月加大工厂的产量,确保将超市的货架摆满,于是过年的时候,工厂的工人放假了,消费者就去辣条超市中购买辣条,此时生产者已经不生产辣条了,但是消费者仍能购买到很多辣条

  5. 所以超市作为一个大号的缓存,可以将生产和消费的行为进行一定程度的解耦,超市可以以两种角度看待,空间角度,商品数角度,其中生产者关心的是超市的空间,消费者关心的是商品数

  6. 所以此时我们回归一下,将上述场景与linux联系起来,那么供应商以及背后的工厂联合起来扮演的是生产者,顾客以及背后的家联合起来扮演的就是消费者,超市是一个巨大的缓存,那么就是扮演的特定结构的内存空间

  7. 在linux中线程是操作系统调度的基本单位,所以说无论是生产者还是消费者都只能由线程来承担,而线程之间传输的只能是数据,所以生产者线程(多个)传输数据到特定结构的内存空间,消费者线程(多个)再从特定结构的内存空间拿数据

  8. 而特定结构的内存空间又是共享资源,多线程并发访问共享资源会出现数据不一致等并发问题,所以要使用锁保护共享资源,即同一时间只允许一个执行流访问这个共享资源

  9. 所以由于生产者有多个,消费者有多个,那么其中也必然会有3中关系,如下的互斥都是为了保证数据安全,只有一个角色访问超市

  • 1. 生产者和生产者之间:互斥(供应商和供应商之间是竞争关系,彼此都希望对方倒闭,然后自己独占市场,所以生产者和生产者是互斥关系)
  • 2. 消费者和消费者之间:互斥(假设超市只剩下一包辣条,但是此时却进来了两个消费者,这两个消费者是竞争关系,都想争夺最后一包辣条,都希望双方不和自己竞争,然后自己得到辣条,所以是消费者和消费者是互斥关系)
  • 3. 生产者和消费者之间:互斥与同步(超市中有很多货架,假设供货商正在向货架上放辣条,此时消费者不能来将辣条抢走,因为我供应商还没有统计我摆满了几个货架,还没有和超市算钱,所以生产者和消费者之间有互斥关系,如果超市中的货架上的辣条已经放满了,那么供货商将无法继续供给辣条,所以供应商就应该停下生产,等待消费者购买辣条。如果超市中货架上的辣条已经全部为空了,那么消费者将无法继续购买辣条,所以消费者应该停下购买,等待生产者生产辣条,所以生产者和消费者之间还应该按照一定是顺序访问超市,所以生产者和消费者之间还有同步关系)
  1. 所以为了便于理解,我们给生产者消费者模型一个321原则
  • 3种关系------如上
  • 2种角色------生产者和消费者
  • 1个场所------特定结构的内存空间
  1. 并且生产者消费者模型还有两个优点
  • 支持忙闲不均(即可以平衡生产者和消费者的需求,生产者生产多,消费者需求少,不要紧,可以将生产者生产的数据放到特定结构的内存空间缓存起来。生产者生产少,消费者需求多,不要紧,特定结构的内存空间中还有生产者提前缓存的数据,那么消费者直接拿数据就行)
  • 生产和消费进行解耦
  • 高效(那么如何理解高效呢?如下)

如何理解该模型高效?

  1. 我们看生产者不能仅仅看到它向超市供应辣条,还应该看到他从工厂获取辣条,那么对应到linux中即我们看生产者线程不能单单的看它向特定结构的内存空间放数据,即将数据放到特定结构的内存空间,还应该看到它获取数据,如何获取数据呢?从用户(例如用户的一些登录注册请求等),网络中进行获取数据

  2. 同样的,我们看消费者不能仅仅看到它从超市购买辣条,还应该看到他在家中吃辣条,那么对应到linux中即我们看消费者线程不能单单的看他从特定结构的内存空间中拿数据,还应该看到他加工处理数据

  3. 我们将生产者将数据放到特定结构的内存空间称之为生产数据到特定结构的内存空间,将消费者从特定的内存空间中拿数据称之为消费数据

  4. 于是生产者在生产者消费者模型做了两点:

  5. 获取数据

  6. 生产数据到特定结构的内存空间

  7. 于是消费者在生产者消费者模型做了两点:

  8. 消费数据

  9. 加工处理数据

  10. 别忘了有多个生产者,有多个消费者,那么对应在linux中有多个生产者线程,有多个消费者线程,所以那么当生产者消费者模型运行起来的时候,也就意味着操作系统内有多个线程并发式的运行,但是特定结构的内存空间在访问的时候由于已经加了锁,所以同一时间只允许一个线程访问,那么为什么小编还要说生产者消费者模型高效呢?

  11. 生产者从用户,网络等获取数据需不需要花费时间?需要。消费者加工处理数据需不需要时间?需要,这就对了都需要时间

  12. 我们的多生产者线程,多消费者线程的高效是对比单生产者线程,单消费者线程的场景得出的

  13. 那么有多个生产者线程中的一个生产者线程在生产数据到特定结构的内存空间,其它的生产者线程在并发的获取数据,那么这是不是节约时间?是的

  14. 那么有多个消费者线程中的一个消费者线程在从特定结构的内存空间获取数据,即消费数据,其它的消费者线程在并发的加工处理数据,那么这是不是节约时间?是的

  15. 那么有一个生产者线程在生产数据到特定结构的内存空间,多个消费者线程在并发的加工处理数据,这是不是节约时间?是的

  16. 那么有一个消费者线程在从特定结构的内存空间获取数据,即消费数据,多个生产者线程在并发的从用户,网络等获取数据,这是不是节约时间?是的

  17. 那么多个生产者线程获取数据,多个消费者线程加工处理数据,一个生产者线程在身缠数据到特定结构的内存空间,这是不是节约时间?是的

  18. 那么多个生产者线程获取数据,多个消费者线程加工处理数据,一个消费者线程在从特定结构的内存空间获取数据,即消费数据,这是不是节约时间?是的

  19. 所以我们才说生产者消费者模型是高效的

为什么要使用生产者消费者模型?

生产者消费者模型就是用一个容器(特定结构的内存空间)来解决生产者和消费者强耦合的问题。生产者和消费者之间不直接通讯,而是通过阻塞队列(容器)来进行通讯。所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列即可。消费者不找生产者要数据,而是直接从阻塞队列里获取数据。阻塞队列就相当于一个缓冲区,用来平衡生产者和消费者的处理能力,用来给生产者和消费者解耦合的。

二、基于阻塞队列的生产者消费者模型

那么生产者消费者模型中的特定结构的内存空间,即一个容器,这里我们采用阻塞队列来实现,那么下面我们就来使用queue模拟阻塞队列的生产消费模型,所以说阻塞队列阻塞队列那么就要用到队列,所以我们采用C++中的queue来模拟阻塞队列进而实现生产消费模型。

其实在这个模型中三种关系永远固定

    1. 生产者和生产者
    1. 消费者和消费者
    1. 生产者和消费者

我们在编写代码时也会存在多种关系,比如一个生产者和一个消费者,多个生产者和多个消费者,我们先来写单单关系的代码,然后再写多多关系的代码:

1. 单单关系模型

BlockQueue.hpp框架

Main.cc框架

Makefile

扩充1:

上面这段代码首先初始化了一把互斥锁 _mutex 和两个条件变量,以此构建同步基础:生产者线程进入 Enqueue 时,先加锁锁定公共队列,若队列已满则在自己的 _producer_cond 条件变量上阻塞等待 (同时也会自动释放锁);消费者线程进入 Pop 时,同样先加锁,若队列为空则同样在自己的 _consumer_cond 条件变量上阻塞等待(同时也自动释放锁)。但因为没有添加唤醒逻辑,当生产者因队列满阻塞、消费者因队列空阻塞后,它们无法被唤醒,导致线程永久卡死,程序无法继续推进。

正因为当前代码只有 "线程阻塞等待" 的逻辑,却没有 "唤醒阻塞线程" 的机制,所以下面我们需要引入唤醒逻辑:生产者生产完数据后,必须唤醒在 _consumer_cond (消费者条件变量)上等待的消费者 ,告知消费者 "队列有数据了,可以消费";消费者消费完数据后,必须唤醒在 _producer_cond (生产者条件变量)上等待的生产者,告知生产者 "队列有空位了,可以生产",如此才能形成 "生产 - 唤醒消费 - 消费 - 唤醒生产" 的闭环,让线程在条件满足时跳出等待、继续执行,避免永久卡死。

那有几个问题 :

问题一 : 什么情况下需要唤醒生产者 / 消费者?

唤醒消费者的场景

  • 当生产者生产了数据(队列从空 → 非空)时,需要唤醒正在等待的消费者,告诉它们 "有数据可以消费了"。对应代码中就是生产者调用 Enqueue 成功插入元素后,队列从空变成非空,此时必须唤醒消费者。

唤醒生产者的场景

  • 当消费者消费了数据(队列从满 → 非满)时,需要唤醒正在等待的生产者,告诉它们 "队列有空位可以生产了"。对应代码就是消费者调用 Pop 成功取出元素后,队列从满变成非满,此时必须唤醒生产者。

问题二 : 谁来唤醒生产者和消费者?

  • 生产者唤醒消费者,因为生产者是生产数据的一方,只有生产者知道 "队列里有新数据了",所以由生产者在生产完成后,唤醒等待的消费者。
  • 消费者唤醒生产者,消费者是消费数据的一方,只有消费者知道 "队列里有空位了",所以由消费者在消费完成后,唤醒等待的生产者。

问题三 : 唤醒操作放在加锁解锁中间,也就是临界区以内?还是要放在解锁后面,临界区以外?

建议唤醒操作放在加锁解锁中间,也就是临界区以内,把 pthread_cond_signal 放在加锁和解锁中间,目的就是先发出唤醒通知再释放锁,避免先解锁导致锁立刻被其他无关线程抢走,让被唤醒的线程无法及时拿到锁执行,从而保证同步的连贯性。

模拟阻塞现象

下面我们要模拟出阻塞现象 怎么模拟?

此时消费一个生产一个,消费一个生产一个

下面我们看一下目前阶段的完整代码并运行:

扩充2:

BlockQueue.hpp : Main.cc :

运行结果(阻塞情况一) :

符合预期

阻塞情况二 :

现在我们看生产消费二者速率正常同时进行的情况 :

此时因为我们并没有对打印语句进行加锁,所以打印的时候可能会出现并发打印错乱,这里我们先不管

细节1 : 过量的唤醒信息

细节2. pthread_cond_wait函数调用失败

细节3. 伪唤醒/虚假唤醒 (if 改为 while)

什么是虚假唤醒?

虚假唤醒就是线程在没有收到 signal/broadcast 信号、或者条件根本没满足的情况下,被系统/内核意外唤醒了。POSIX 标准明确规定 pthread_cond_wait 允许在没有收到信号时返回(也就是虚假唤醒),这不是 bug,是底层实现的正常行为,所以我们写代码必须兼容这种情况。

我们先来看一下我们之前用 if 会出现的问题 :

极端场景(虚假唤醒发生):

  • 队列是空的,消费者线程 A 进入 if,调用 wait 释放锁、阻塞等待。此时发生虚假唤醒(被系统/内核意外唤醒)线程 A 被意外唤醒,从 wait 返回,自动重新拿到锁。因为是 if,不会再检查队列是否为空,直接执行 _bq.front(),但队列还是空的!结果导致访问空队列,直接触发段错误(Segmentation fault),程序崩溃。

替换为 while 的安全写法 :

同样的虚假唤醒场景:

  • 队列空,消费者 A 进入 while,调用 wait 阻塞。虚假唤醒发生,A 被唤醒,从 wait 返回,重新拿到锁。回到 while 条件判断:_bq.empty() 还是 true,于是再次进入 wait,继续阻塞。直到真正收到生产者的 signal,队列非空了,while 条件不满足,才跳出循环,执行消费逻辑。最后结果100% 保证队列里有数据,绝对不会访问空队列,彻底避免崩溃。

梳理流程

Enqueue 生产者函数的流程:

我们梳理一下 Enqueue 生产者函数的流程:

生产者执行入队函数 Enqueue 时会先加锁进入临界区,随后通过 while 循环判断队列是否已满,整个流程分为两条互斥且独立的路径。第一条路径是队列未满,此时 while 条件不成立,线程不会进入等待,直接将数据放入队列,在仍然持有锁的情况下调用 signal 唤醒可能在消费者条件变量下等待的线程,之后解锁退出临界区,被唤醒的消费者线程便可竞争锁并执行消费;第二条路径是队列已满,while 条件成立,线程会调用 wait 原子性地释放锁并将自己挂到生产者的条件等待队列中休眠,后续的入队、唤醒、解锁操作全部暂停,直到被消费者线程唤醒,被唤醒后线程会重新尝试获取锁,拿到锁后回到 while 再次判断队列状态,确认队列不满后跳出循环,完成数据入队,再在锁内唤醒消费者,最后解锁退出。整个过程中,唤醒操作始终在持有锁的临界区内完成,要么是未等待直接执行唤醒,要么是等待被唤醒、重新拿到锁后再执行唤醒,确保了修改共享队列与发送唤醒信号的原子性,避免信号丢失,同时依靠 while 循环防止虚假唤醒,让生产者和消费者形成稳定的等待与唤醒协作闭环。

Pop 消费者函数的流程:

消费者执行出队函数 Pop 时会先加同一把锁进入临界区,再通过 while 循环判断队列是否为空,流程同样分为两条互斥独立的路径。第一条路径是队列不为空,while 条件不成立,线程不进入等待,直接从队列取出数据,在仍然持有锁的情况下调用 signal 唤醒可能在生产者条件变量下等待的线程,之后解锁退出临界区,被唤醒的生产者线程便可竞争锁继续生产;第二条路径是队列为空,while 条件成立,线程调用 wait 原子性地释放锁并把自己挂到消费者的条件等待队列中休眠,后续的取数据、唤醒、解锁操作全部暂停,直到被生产者线程唤醒,被唤醒后重新尝试获取锁,拿到锁后回到 while 再次判断队列状态,确认队列不为空后跳出循环,完成数据取出,再在锁内唤醒生产者,最后解锁退出。整个过程中唤醒操作同样始终在持有锁的临界区内完成,要么是未等待直接唤醒,要么是等待被唤醒、重新拿到锁后再唤醒,保证了队列操作与唤醒信号的原子性,配合 while 循环避免虚假唤醒,与 Enqueue 形成 "生产唤醒消费、消费唤醒生产" 的完整同步闭环。

问题一 : 那 Enqueue 和 Pop 函数里面都都有锁,这两个锁是独立的吗?还是同一个的?

Enqueue 和 Pop 用的是同一把锁、唯一的锁、全局共用的锁,不是两把独立的锁。在整个阻塞队列里,只有一个 _mutex 互斥锁,生产者的 Enqueue 和消费者的 Pop 操作,用的都是这同一把锁。不管是生产者还是消费者,谁要访问公共队列,都必须先抢这同一把锁,抢到了才能进临界区,没抢到就阻塞等待。正因为是同一把锁,才能保证同一时刻只有一个线程能操作队列,不会出现生产者和消费者同时修改队列导致的数据混乱;也正是因为共用一把锁,wait 才能正确释放这把锁让对方线程使用,唤醒后也能重新争抢这把锁,从而实现生产者与消费者之间的安全同步。

整体逻辑 :

整个生产者消费者模型的核心逻辑确实就是这样:不满足 while 条件时,线程直接插入或取出元素,然后在锁内唤醒对方线程,再解锁退出;把 pthread_cond_signal 放在加锁和解锁中间,目的就是先发出唤醒通知再释放锁,避免先解锁导致锁立刻被其他无关线程抢走,让被唤醒的线程无法及时拿到锁执行,从而保证同步的连贯性。

而当满足 while 条件时,线程会执行 pthread_cond_wait 原子释放锁并把自己挂到对应的条件等待队列休眠,此时 wait 之后的所有代码都会暂停执行,不再往下运行。 这个时候,另一方线程就可以顺利抢到这把被释放的锁,进入自己的临界区执行操作,比如生产者因队列满 wait 释放锁后,消费者就能抢到锁去消费数据,消费完成后再唤醒正在等待的生产者,之后自己解锁,形成完整的协作循环,双方就这样依靠同一把锁和两个条件变量,互相等待、互相唤醒,交替执行而不会出现数据竞争或死锁。

流程演示 :

问题一 : 锁到底在谁手里?

谁在执行临界区代码,锁就在谁手里;

谁调用了 wait,谁就立刻把锁交出去,自己去睡觉;

谁被唤醒,谁就要重新抢锁,抢到了才能继续往下跑。

问题二 : 那整个过程是如何循环下去的呢?

问题三 : 线程创建出来,进入对应的线程出口函数之后,比如说两个线程,一个是生产者,一个是消费者,分别进入对应自己的生产、消费线程函数之后。又是怎么联系起来的这两个线程?

两个线程一开始是完全独立的,当我们创建线程:

这时候:A 跑 producer ,B 跑 consumer ,它们是两条独立的执行流,各跑各的,本来互不认识。真正把它们绑在一起的,是这三样东西 :

这四个是全局共享的。也就是说:

  1. A 和 B 抢同一把锁
  2. A 和 B 操作同一个队列
  3. A 在 cond_producer 睡觉,B 负责叫醒它
  4. B 在 cond_consumer 睡觉,A 负责叫醒它

这就是它们唯一的联系。除此之外,它们没有任何关系。

它们是怎么 "互动" 起来的?

有个疑问 : 就是如果一段代码中有多处地方都加锁了,那这个锁是唯一的吗,也就是说你加锁了我就不能加锁了,还是我们同时都可以加锁?

结论 : 只要是同一把锁 (同一个 pthread_mutex_t 变量),不管代码里多少处加锁,全局只有一个 "持有权"。你加锁了,我就绝对不能加锁;我加锁了,你就必须等着。绝对不可能两个人同时加锁成功。只要用的是同一个 mutex,那不管写多少遍 lock,本质都是在抢同一把钥匙。

如上线程 A 在任意位置调用 lock(&mutex) 拿到锁,此时线程 B 不管在代码任何地方调用 lock(&mutex) 都会阻塞,卡住不动,直到线程 A unlock,不管是在Enqueue 里加锁,,Pop 里加锁,主线程加锁,其他函数加锁,只要是同一把锁,全局互斥,同一时间只能一个人持有。

下面我们在对这个单单模型的代码进行增添点细节:

1. 水位线功能

阻塞队列的高低水位线,本质是给队列加了两个「触发阈值」,用来精细化控制唤醒时机,避免无意义的频繁唤醒,优化性能:

_blockqueue_low_water(低水位线):队列的下限阈值

_blockqueue_high_water(高水位线):队列的上限阈值
队列创建时,根据总容量 _cap 自动计算高低水位:低水位是容量的 1/3,高水位是容量的 2/3。比如队列容量是 9:低水位 = 3,高水位 = 6;容量是 10:低水位 = 3,高水位 = 6(整数除法向下取整)

生产者 Enqueue 函数的水位线逻辑 :

原来的逻辑只要生产者 push 了数据,不管队列有多少元素,都立刻唤醒消费者

加了水位线后生产者 push 数据后,先判断当前队列长度是否超过高水位线,只有 size > 高水位 时,才调用 signal 唤醒消费者。如果队列长度没到高水位(比如容量 9,队列里只有 4 个元素),就不唤醒消费者,让消费者继续休眠。这样就避免了避免消费者被频繁唤醒,只有队列积累了足够多的数据(超过 2/3 容量),才唤醒消费者批量消费,减少线程上下文切换,提升性能。

消费者 Pop 函数的水位线逻辑 :

原来的逻辑只要消费者 pop 了数据,不管队列剩多少元素,都立刻唤醒生产者

加了水位线后消费者 pop 数据后,先判断当前队列长度是否低于低水位线。只有 size < 低水位 时,才调用 signal 唤醒生产者。如果队列长度没低于低水位(比如容量 9,队列里还有 5 个元素),就不唤醒生产者,让生产者继续休眠。避免生产者被频繁唤醒,只有队列剩余数据不足 1/3 容量时,才唤醒生产者补充数据,同样减少线程切换开销。

2. 统计正在睡觉的线程数量

两个变量 sleep_productor_num 和 sleep_consumer_num,本质上是计数器。它们不再简单的看队列元素多少(静态阈值),而是统计正在睡觉的线程数量(动态动态策略),以此来决定是否唤醒。

构造函数初始化:初始化为 0。

  • sleep_consumer_num > 0:说明当前有消费者在等数据吃,它是空等。
  • sleep_productor_num > 0:说明当前有生产者在等空位放,它是空等。

生产者 Enqueue 的逻辑(精准唤醒)

生产者执行入队操作时,首先通过 pthread_mutex_lock 获取全局唯一的互斥锁,进入临界区以独占访问共享队列。随后通过 while 循环校验队列状态:若队列已满(_bq.size() == _cap),则先将 sleep_productor_num(休眠生产者计数器)自增 1,标记当前生产者即将进入休眠状态,再调用 pthread_cond_wait 原子释放锁并将自身挂入生产者条件变量等待队列阻塞休眠;待被消费者唤醒后,wait 函数自动重新竞争锁,成功获取锁后将 sleep_productor_num 自减 1,恢复计数器状态,再次校验队列状态,确认队列未满后跳出循环。若队列未满,则直接跳过等待流程,执行 _bq.push(in) 完成数据入队操作。入队完成后,判断 sleep_consumer_num(休眠消费者计数器)是否大于 0:若存在处于休眠状态的消费者线程,则调用 pthread_cond_signal 唤醒消费者条件队列中的阻塞线程,通知其队列已有新数据可消费;若不存在休眠消费者,则不执行唤醒操作,避免无效唤醒。最后调用 pthread_mutex_unlock 释放互斥锁,退出临界区,完成本次入队流程。

消费者 Pop 的逻辑(精准唤醒)

消费者执行出队操作时,同样先通过 pthread_mutex_lock 获取全局唯一的互斥锁,进入临界区。通过 while 循环校验队列状态:若队列为空(_bq.empty()),则先将 sleep_consumer_num(休眠消费者计数器)自增 1,标记当前消费者即将进入休眠状态,再调用 pthread_cond_wait 原子释放锁并将自身挂入消费者条件变量等待队列阻塞休眠;待被生产者唤醒后,wait 函数自动重新竞争锁,成功获取锁后将 sleep_consumer_num 自减 1,恢复计数器状态,再次校验队列状态,确认队列非空后跳出循环。若队列非空,则直接跳过等待流程,执行 _bq.front() 与 _bq.pop() 完成数据出队操作。出队完成后,判断 sleep_productor_num(休眠生产者计数器)是否大于 0:若存在处于休眠状态的生产者线程,则调用 pthread_cond_signal 唤醒生产者条件队列中的阻塞线程,通知其队列已有空位可生产;若不存在休眠生产者,则不执行唤醒操作,避免无效唤醒。最后调用 pthread_mutex_unlock 释放互斥锁,退出临界区,完成本次出队流程。
通过 sleep_productor_num 与 sleep_consumer_num 两个计数器,实现了精准唤醒的优化逻辑:仅当对方存在休眠线程时才执行 signal 操作,彻底杜绝了无意义的空唤醒,大幅减少线程上下文切换开销,同时保留了原模型的线程安全特性,适配高并发场景下的性能需求

上面只是单单线程之间,下面我们来看一下多线程与多线程之间的关系 :

2. 多多关系模型

其实多多关系很简单,我们只需要在单单关系的基础上改一下Main.cc中主函数创建线程的个数,如下:

我们定义了 c[3] 数组,对应 3 个消费者线程;p[2] 数组,对应 2 个生产者线程。

3 个消费者线程、2 个生产者线程,全部绑定同一个阻塞队列对象 bq,所有线程共享同一把锁、同一个队列、同一组条件变量。所有消费者执行同一个 ConsumerRoutie 入口函数,所有生产者执行同一个 ProductorRoutie 入口函数,是多线程的标准写法。线程等待(join)主线程依次等待 3 个消费者、2 个生产者线程全部执行完毕,再退出 main 函数,避免主线程提前结束导致子线程被强制终止,是多线程程序的标准收尾操作。
此时我们的阻塞队列的文件(BlockQueue.hpp)无需修改, BlockQueue.hpp 类(包含互斥锁、条件变量、水位线 / 休眠计数器等逻辑),天生就是线程安全的。不管是 1 对 1、1 对多、多对 1、多对多的线程模型,队列内部的互斥锁会保证同一时间只有一个线程操作队列,条件变量会保证线程间的同步唤醒,完全不需要修改任何一行队列代码,直接复用即可。


在完成最简版生产者与消费者逻辑验证、确认阻塞队列正常实现数据收发与线程同步后,我们可以在此基础上扩展功能,完善多线程场景下的调试与区分能力。由于极简代码无法区分不同线程的执行日志,**所以我们新增全局线程计数器与配套互斥锁,实现GetNumber线程编号函数,为每个生产者、消费者线程分配唯一序号,通过序号自定义线程名称,**方便日志打印区分不同线程;同时在消费逻辑中增加延时控制,模拟真实业务中不均衡的生产消费速度,让线程等待、唤醒、调度的运行现象更贴合实际高并发场景,在不改动原有阻塞队列核心同步逻辑的前提下,让程序更贴合工程实战场景,也更便于观察多线程交替运行细节。

GetNumber函数的作用就是给每个线程分配一个全局唯一、不重复的编号,用来给线程命名、区分不同的线程,方便你在日志 / 打印中清晰识别是哪个生产者 / 消费者在执行操作。

消费者线程中,线程启动后,第一时间调用 GetNumber() 拿到自己的唯一编号(比如第 1 个消费者拿到 1,第 2 个拿到 2,以此类推)用编号拼接出线程名(如 Consumer-1、Consumer-2),通过 pthread_setname_np 给线程设置自定义名称,方便调试、查看线程状态,后续打印日志时,直接输出线程名,就能一眼区分是哪个消费者在消费数据。

生产者线程中逻辑完全对称:给每个生产者分配唯一编号,生成 Productor-1、Productor-2 这类线程名,同样用于日志打印和线程调试,区分不同生产者的生产行为。

为什么要加锁?

num 是全局共享变量,如果不加锁直接 num++,在多线程并发场景下会出现竞态条件:

多个线程同时读取 num 的值,可能拿到同一个编号,导致线程名重复

加锁后,同一时间只有一个线程能执行 num++,保证编号的全局唯一性、原子性,彻底避免并发问题

我们在那个 BlockQueue.hpp 里面定义了一个锁,在这个主函数 Main.cc 文件里面还定义了一个全局的锁。并且这个主文件包含了那个 BlockQueue.hpp 那个头文件,在这两个文件里面分别定义了锁,难道不矛盾吗?之前不是说只有一个锁吗?

完全不冲突、一点都不矛盾!两把锁管的是两件完全不相关的事,各司其职,互不干扰你之前记的阻塞队列全程只有一把锁,约束的是队列内部共享资源;现在全局这把新锁,管的是队列以外的全局计数器num,两个锁是独立对象、互不相干。

🔒 锁 1:BlockQueue 类内部的 _mutex 是保护队列本身 _bq 的线程安全。管的是 Enqueue 入队、Pop 出队、修改队列大小、唤醒判断,所有操作队列的线程,必须抢这把锁,保证同一时间只有 1 个线程改队列,生命周期随阻塞队列对象走,一个队列对象对应专属一把队列锁

🔒 锁 2:main 文件全局 lock 是保护全局计数器 int num 的线程安全,只在 GetNumber() 里用,给线程分配唯一编号,和队列_bq没有任何关系,不参与任何队列的入队、出队操作,全局程序全程存在

锁是变量,不同变量就是完全不同的两把钥匙类里的_mutex是类成员变量,全局lock是全局独立变量,内存地址完全不一样,是两个毫无关联的互斥锁。一把锁锁住队列,完全不会影响另一把锁锁住计数器。

三、封装条件变量Cond.hpp

下面我们再封装关于条件变量的hpp文件,主要以 C++ 风格的条件变量 RAII 封装类,把 Linux 原生的 pthread_cond_t 系列 C 函数,包装成了面向对象、自动管理生命周期的 C++ 类。

  1. 完美的 RAII 封装:构造自动初始化,析构自动销毁,彻底避免手动管理条件变量的常见错误(忘记初始化、忘记销毁、重复销毁)
  2. 面向对象封装:把 C 风格的原生函数,包装成 C++ 类的成员方法,符合 C++ 编程习惯,和你封装的Mutex类完美配合
  3. 接口极简清晰:只暴露Wait/Signal/Broadcast三个核心接口,完全覆盖条件变量的所有常用操作,没有冗余功能

四、完整代码

一共 6 个文件 :

Thread.hpp

Mutex.hpp

Cond.hpp

BlockQueue.hpp

Task.hpp

Makefile

Main.cc

五、重谈生产者消费者模型

问题1 : 任何时刻,都只有一个线程在访问阻塞队列,那何谈高效呢?

同一时刻,只有一个线程能碰阻塞队列本身。要么生产,要么消费,不可能两个一起改队列。这体现出了队列操作的互斥性。
但是它的高效性体现在另一方面 : 生产者在 "计算 / 准备数据" 的时候,消费者可以同时在 "计算 / 处理数据"。这才是高效的体现!生产前后的逻辑、消费前后的逻辑,是可以同时跑的。


代码中就是这样体现出来的,如下图 :

代码里的两个线程 ------ 生产者线程、消费者线程,是两个真正的独立执行流。它们从代码的第一行开始,就是各自在跑的。

consumer.Start()启动,Start 函数内部调用 pthread_create,系统创建一个独立的子线程,子线程开始执行 ThreadRoutine,最终跳转到 ConsumerRoutine,此时消费者线程已经开始跑了!但主线程还在继续往下执行,还没启动生产者。下来 productor.Start() 函数,生产者线程真正启动,同样调用 pthread_create,系统再创建一个独立的子线程,子线程跳转到 ProducerRoutine,开始执行,从这一行执行完的瞬间开始,两个子线程就真正同时跑了!

再具体点就是每个 pthread_create 都会创建一个独立的执行流(线程),有自己的程序计数器(PC)、栈空间。两个线程创建完成后,CPU 会给它们分别分配时间片,各自独立执行自己的函数

只要不遇到锁、不被阻塞,两个线程就会完全并行、同时往前跑。

  • 消费者线程:从 ConsumerRoutine 第一行开始,循环执行 sleep(1) → Pop(&t) → t() → 打印结果
  • 生产者线程:从 ProducerRoutine 第一行开始,循环执行 usleep → 生成随机数 → Enqueue(t) → 打印生产

两个线程互不干扰,CPU 会在它们之间快速切换,宏观上看起来就是同时进行。

问题延伸 : 还有个问题就是两个线程被创建好之后同时进行吗?它们分别在 CPU 上,还是同时在 CPU 上进行的?

两个线程被创建后,不是同时在 CPU 上跑,而是由操作系统通过 "时间片切换" 一个一个轮流跑。只是因为切换速度极快,我们在宏观上看起来就像它们同时在跑。

  1. 在硬件物理层面上,CPU 是单核的话,同一时刻只能有一个线程在 CPU 上执行,两个子线程不可能同时在 CPU 上跑,做不到物理并行,而是一个一个轮流执行的。

  2. 在操作系统层面上,Linux 是这样调度两个线程的:每个线程分配一个时间片(比如 10ms),线程 A 跑 10ms 被打断,然后保存上下文,切换到线程 B,线程 B 跑 10ms,再切换,再循环,两个线程是 "轮流" 在 CPU 上执行的,不是同时。

  3. 在宏观层面上看的话,虽然一个 CPU 只能同时跑一个线程,但因为时间片非常小(毫秒级),切换速度极快(微秒级),切换成本很低,所以在用户看来,两个线程像是 "同时在跑"。这叫并发,不是真正并行。

所以同一时刻,一个 CPU 只能跑一个线程。多个线程是靠时间片轮转调度实现 "同时运行" 的假象。当是多核 CPU 时才能真正物理上同时跑多个线程。

六、总结

生产者消费者模型通过缓冲队列解耦生产与消费过程,实现高效并发处理。该模型包含生产者、消费者和共享队列三种角色,通过互斥锁保证队列访问安全,使用条件变量实现线程同步。生产者将数据放入队列,消费者从队列取出数据,当队列满时生产者等待,队列空时消费者等待。模型采用"321原则":3种线程关系(生产者间、消费者间、生产消费间互斥)、2种角色、1个共享场所。其高效性体现在生产与消费过程可并行执行,同时支持忙闲不均的场景。实现时需注意虚假唤醒问题,建议使用while循环而非if判断条件。通过水位线机制可优化唤醒频率,减少线程切换开销。该模型广泛应用于多线程编程,有效平衡系统负载。

谢谢大家的观看!

相关推荐
.柒宇.2 小时前
nginx入门教程
运维·nginx
Dxy12393102162 小时前
Python基于BERT的上下文纠错详解
开发语言·python·bert
w6100104662 小时前
cka-2026-ConfigMap
java·linux·cka·configmap
cc_yy_zh2 小时前
Win10 家庭版找不到Device Guard; 无法处理 VMware Workstation与Device Guard不兼容问题
linux·vmware
航Hang*2 小时前
VMware vSphere 云平台运维与管理基础——第2章(扩展):VMware ESXi 5.5 安装、配置与运维
运维·服务器·github·系统安全·虚拟化
嵌入式吴彦祖2 小时前
Luckfox Pico Ultra W WIFI
linux·嵌入式硬件
SPC的存折3 小时前
MySQL 8组复制完全指南
linux·运维·服务器·数据库·mysql