【Linux】多线程 --- 线程同步与互斥+生产消费模型-2

简介: 【Linux】多线程 --- 线程同步与互斥+生产消费模型-2

二、线程同步+生产消费模型

1.通过条件变量抛出线程同步的话题


1.

我们前面就说过,在抢票逻辑中,刚释放完锁的线程由于竞争能力比较强,导致其他线程无法申请到锁,那么长时间其他线程都无法申请到锁,只能阻塞等待着,这样的线程处于饥饿状态!

我们可以举一个例子来理解条件变量是如何实现线程同步的。

假设现在学校开了一间学霸vip自习室,学校规定这间自习室一次只能进去一个人上自习,自习室门口挂着一把钥匙,谁来的早先拿到这把钥匙,就可以打开门进入自习室学习,并且进入自习室之后,把门一反锁,其他人谁都不能进来。然后你第二天准备去学习了,卷的不行,直接凌晨三点就跑过来,拿着钥匙进入自习室上自习了,然后卷了3小时之后,你想出来上个厕所,一打开门发现外面站的一堆人,都在叽叽喳喳的讨论谁先来的,怎么来的这么早?这么卷?然后你怕自己等会儿把钥匙放到墙上之后,上完厕所回来之后有人拿着钥匙进入了自习室,你就又卷不了了,所以你把钥匙揣兜里,拿着钥匙去上厕所了,其他人当然进入不了自习室,因为你拿着钥匙去上厕所了。等你回来的时候,你又打开门,又来里面上了3小时自习,你感觉自己饿的不行了,在不吃饭就饿死在里面了,所以你打开门,准备出去吃饭了,然后突然你自己感觉负罪感直接拉满,我凌晨3点好不容易抢到自习室,现在离开是不太亏了,所以你又打开自习室回去上自习去了,别人当然竞争不过你呀!因为钥匙一直都在你兜里,你出来之后把钥匙放到墙上,你发现有点负罪感,你又拿起来钥匙回去上自习,因为你离钥匙最近,所以你的竞争能力最强。结果你来自习室上了1分钟自习又出来了,然后又负罪的不行,又回去了,周而复始的这么干,结果别人连自习室长啥样都没见到。

像这样由于长时间无法得到锁的线程,没办法进入临界区访问临界资源,我们称这样的线程处于饥饿状态!


2.

所以学校推出了新政策,所有刚刚从自习室出来的人,都必须回到队列的尾部重新排队等待进入自习室,这样的话,其他人也就可以拿到钥匙进入自习室了。

所以,在保证数据安全的前提下,让线程能够按照某种特定的顺序来访问临界资源,从而有效避免其他线程的饥饿问题,这就叫做线程同步!


2.生产消费模型的概念理解(321原则)


1.

上面我们已经初步理解了条件变量带来的作用,那就是让互斥访问的线程能够实现同步,有效避免其他线程的饥饿问题,但在真正学习使用条件变量之前,我们还需要再来谈论一个模型,叫做生产消费模型,在谈论完生产消费模型之后,我们在来使用一下条件变量,然后基于条件变量+生产消费模型实现出一个基于阻塞队列式的生产消费模型代码。


2.

实际生活中,我们作为消费者,一般都会去超市这样的地方去购买产品,而不是去生产者那里购买产品,因为供货商一般不零售产品,他们都会统一将大量的商品供货到超市,然后我们消费者从超市这样的交易场所中购买产品。

而当我们在购买产品的时候,生产者在做什么呢?生产者可能正在生产商品呢,或者正在放假呢,也可能正在干着别的事情,所以生产和消费的过程互相并不怎么影响,这就实现了生产者和消费者之间的解耦。

而超市充当着一个什么样的角色呢?比如当放假期间,消费爆棚的季节中,来超市购买东西的人就会非常的多,所以就容易出现供不应求的情况,但超市一般也会有对策,因为超市的仓库中都会预先屯一批货,所以在消费爆棚的时间段内,超市也不用担心没有货卖的情况。而当工作期间,大家由于忙着通过劳动来换取报酬,可能来消费的人就会比较少,商品流量也会比较低,那此时供货商如果还是给超市供大量的货呢?虽然超市可能最近确实卖不出去东西,但是超市还是可以把供货商的商品先存储到仓库中,以备在消费爆棚的季节时,能够应对大量消费的场景。所以超市其实就是充当一个缓冲区的角色,在计算机中充当的就是数据缓冲区的角色。

而计算机中哪些场景是强耦合的呢?其实函数调用就是强耦合的一个场景,例如当main调用func的时候,func在执行代码的时候,main在做什么呢?main什么都做不了,他只能等待func调用完毕返回之后,main才能继续向后执行代码,所以我们称main和func之间就是一种强耦合的关系,而上面所说的生产者和消费者并不是一种强耦合的关系。ea6a8ddd895c42bf9fe88246a03e21ef.png


3.

如果深度挖掘一下生产消费模型,超市其实就是典型的共享资源,因为生产者和消费者都要访问超市,所以对于超市这个共享资源,他在被访问的时候,也是需要被保护起来的,而保护其实就是通过加锁来实现互斥式的访问共享资源,从而保证安全性。

在只有一份超市共享资源的情况下,生产和生产,消费和消费,以及生产和消费都需要进行串行的访问共享资源。但为了提高效率我们搞出了同步这样的关系,因为有可能消费者一直霸占着锁,一直在那里消费,但实际超市已经没有物资了,此时消费者由于竞争能力过强,也会造成不合理的问题,因为消费者消费过多之后,应该轮到生产者来生产了,所以对于生产者和消费者之间仅仅只有互斥关系是不够的,还需要有同步关系。cb2e8801507042208129c846ffc61a84.png



4.

从生产消费模型中可以提取出来一个321原则。即为3种关系,两个角色,1个交易场所。对应的其实是消费线程和消费线程的关系,消费线程和生产线程的关系,生产线程和生产线程的关系,交易场所就是阻塞队列blockqueue。而实现线程同步就需要一个条件变量,比如生产者生产完之后,超市给消费者打个电话,让消费者过来消费,消费完之后,超市在给生产者打个电话,让生产者来生产,这样就不会存在由于某一个线程竞争能力过强,一直生产或一直消费的情况产生,从而导致其他线程饥饿的问题。

f11167e4fdf44b758e120239cac762b4.png



5.

所以总结一下生产消费模型都有哪些好处。

a.他实现了生产和消费的解耦,使他们之间并不互相影响。

b.支持生产和消费一段时间的忙闲不均的问题。因为缓冲区可以预留一部分数据,进行数据的缓冲。

c.由于生产和消费的互斥与同步关系,提升了生产消费模型的效率。


但我其实还有一个问题,生产和消费是互斥的关系,那生产者生产的时候,消费者就不能消费,因为共享资源需要被加锁保护,而锁只有一把,所以每次只能有一个线程访问这个共享资源,那你凭什么说生产消费模型就高效了呢?这个问题很重要,后面讲完阻塞队列的代码实现之后,要重点谈一下这个问题!


3.条件变量实现线程同步的原理(条件变量内部维护了线程的等待队列,能wait线程也能wakeup线程)


1.

为了能够让多线程协同工作,就需要实现多线程的同步关系,为了维护同步关系,就需要引入条件变量。那条件变量是一个什么东西呢?他其实和互斥锁一样,都是一个数据类型定义出来的对象。初始化和销毁方案和互斥锁一模一样。唯一不同的是,条件变量在使用时有两个高频使用的接口,一个是pthread_cond_wait,该函数的作用是将等待某一个具体锁的线程放入条件变量的等待队列中进行等待,另一个是pthread_cond_signal,该函数的作用是唤醒条件变量中等待队列的第一个等待线程,另一个用的不怎么高频,但也偶尔会用一下的接口就是pthread_cond_broadcast,该函数将条件变量中的所有等待线程都会唤醒,让所有线程重新回归竞争锁的状态。而不是像signal那样,唤醒cond队列中任意一个阻塞等待锁的线程。

c9cae4752c834d65b1f6e7d7cdfdd10a.png


2.

除了之前我们举的自习室的例子之外,下面又举了一个面试官面试求职者的例子,其实说这么多例子就是为了让大家感受到条件变量所带来的作用,它能够让所有互斥访问的线程都能够按照某种顺序进入临界区,访问临界资源,这就是环境变量带来的最大的作用。既能保证共享资源访问的安全性,又能保证所有线程都可以拿到锁去访问共享资源,避免出现线程饥饿的问题。所以下面的例子大家看一下就好,如果你已经深刻的认识到条件变量带来的好处和作用,以及他所实现的线程同步的话,你可以直接忽略这段文字,跳转到下面条件变量实现同步的原理部分。60d72cf416424a219e2f8618192ec39f (1).png



3.

我们可以将条件变量理解为一个结构体,它内部会有一个字段专门表示当前线程等待的锁的使用情况,如果status有效,那么代表此时锁也被释放,还有一个字段是专门维护等待某一个锁的线程队列。当status变为有效的时候,我们可以调用pthread_cond_signal唤醒cond内部的等待队列中的某一个线程,将这个线程的上下文加载到CPU的寄存器上,并且这个线程会申请到上一个线程释放的锁,然后这个线程就可以拿着锁互斥的去访问临界区了。

所以条件变量实现同步的根本原因就是通过wait和signal来实现的,比如某一个线程释放完锁了,那你这个线程就不要再给我继续申请锁了,因为我要唤醒cond的等待队列中的线程了,他们还想要这把锁呢,至于你,就去cond的等待队列中等着就行了,等下次唤醒到你的时候,你才有资格重新申请锁。所以通过条件变量等待和唤醒的这样一种方式,成功实现了多个线程都能互斥式的访问临界区,而不会出现某些线程无法申请到锁而产生的饥饿问题。

13f43bb066014b15a942a304d0e1c2d5.png


4.串行、并发、并行的概念


1.

接下来我要给大家介绍几个概念,是关于串行、并发、并行的。单独说这几个概念实际并不难,但他们在现代计算机中是如何被分配的,这样的知识就比较珍贵了。另外需要说一点的是,网上有很多都喜欢把多核叫做多CPU,但是吧这么叫确实没什么太大的错误,因为一个处理器芯片上集成了多个核心,每个核心都有自己独立的存储单元,控制单元,算术逻辑单元,所以每个核心都可以跑不同的任务,从功能角度来讲,确实可以叫做多CPU,但是也容易误导萌新啊,就比如我这样的,我以为是真的多CPU处理器呢,原来是大部分人的叫法不同而已。

93cc5ec4f23945b98471500d6c8d30e3.png


2.

实际我们的计算机在工作时,是一定要进行并发的,因为并发能很好解决用户同时想要运行多个程序的需求,也就是我们所说的多任务处理,但同时也需要进行并行。就比如上面图中举得例子,每个大核跑不同的程序,但同时某一个大核在跑程序时,也可以时间片轮转的去执行另一个程序,所以并行和并发在计算中是同时存在的。

而并发一定要比并行效率高的前提是多任务情况,如果你站在多任务处理的角度去看待串行和并发,你一定可以理解为什么并发效率要更高,因为串行在线程被切换下去或者等锁被释放的时候,这段时间CPU什么都做不了,那这段时间就会被白白浪费掉,在多任务处理的情况下,效率一定就会下降。而对于并发来讲,如果某个线程被切换下去或者他在等待锁被释放的时候,是完全没有关系的,因为CPU会调度运行其他线程,所以被切换下去的线程在等待的时候,时间完全不会被浪费掉,而是会被CPU利用起来去跑其他的线程。

我以前不能理解为什么并发要比串行执行效率高的原因就是因为,我当时站的角度并不是多任务处理,而是单任务处理的角度,但这种场景一定非常少见,或者可以几乎说完全不存在,你想一下,你的电脑开机之后,会只有一个任务再被单独处理吗?绝对不会,怎么验证呢?非常简单!你打开你的任务管理器,去看一下有多少后台进程正在被运行,这会是单任务处理的场景吗?

我当时理解有误就是绝对,单独一个任务无论是串行还是并发执行效率都是一样的,但这个理解本身并没有错误,只不过这样的场景不存在,我们讨论这些线程执行效率的前提几乎都是默认在多任务处理的前提下进行讨论的!



5.条件变量的基本代码编写



1.

这里我们先用全局的互斥锁和条件变量进行简单的代码测试,帮助大家在代码层面上理解一下条件变量带来的效果,真正使用条件变量和生产消费模型编写代码的环境放在第三部分进行讲解。

首先我们创建出一批线程,并在线程函数内部对共享资源tickets进行加锁保护,和使用条件变量来实现线程之间的同步关系。在start_routine中,我们让所有的线程在进入临界区之后,先去执行等待,让所有的线程都去条件变量里面等着(实际执行pthread_cond_wait时会自动以原子性的方式释放当前线程持有的锁),然后由主线程来负责唤醒cond中的等待线程,如果是这样的话,那所有的线程都可以申请到锁访问到临界区,不会出现饥饿线程。

4e8e924d49b34a769ef48c08d3f011fe.png


2.

当主线程调用pthread_cond_signal唤醒cond队列中等待的线程后,可以看到线程抢票的运行结果,非常有顺序的执行票数- -,执行的顺序是12453,并且每个线程都兼顾到位,没有出现线程饥饿,无法执行票数- -的情况产生。

主要还是因为当线程被唤醒,访问完临界资源释放完锁之后,循环执行代码,他又会去执行pthread_cond_wait了,此时就又会释放锁,进入等待队列,而signal此时会继续重新唤醒等待队列的其他线程。以这样的方式来让所有线程都可以申请到锁。

37133ef362394a7f848e7110b7b86fcc.png

这里在补充介绍一个接口pthread_cond_timedwait,该接口与pthread_cond_wait不同的是,wait接口会将阻塞等待锁的线程放入cond的等待队列里面,直到有锁被释放时,pthread_cond_signal接口会唤醒cond等待队列中的线程。而timedwait是等待锁一段时间后,如果锁未被释放,那么该接口会自动超时返回,防止线程长时间的阻塞等待锁。但这个接口并不常用,我们还是重点使用pthread_cond_wait接口。ee19996f3f5741fda0b099ff6945fa76.png


ee19996f3f5741fda0b099ff6945fa76.png


3.

当调用pthread_cond_broadcast时,会唤醒cond阻塞队列中的所有等待线程,然后这批线程会依次按照某种顺序竞争锁,当线程使用完锁访问完临界区之后,就会释放锁,然后重新回到条件变量中进行等待,而此时剩余被唤醒的线程再去竞争锁,做着上一个线程同样的工作。所以打印结果如下图所示,唤醒一批线程之后,5个线程都抢票,每次都是以5个线程为单位进行唤醒。

这就是条件变量带来的线程同步,让所有线程先去条件变量中进行等待,随后会唤醒其中的每一个线程,唤醒后的线程在访问完临界资源后,又会重新投入等待队列当中,以这样的方式来让所有线程都能够申请锁访问到临界区的临界资源。

73cad9e1262f4ff99eb91a29820f9541.png


三、基于blockqueue的生产消费模型

1.双阻塞队列的多生产多消费模型的实现


1.

上面我们已经谈论过生产消费模型的概念和条件变量的代码实现,现在我们就要用这两样工具实现出基于阻塞队列的生产消费模型。

原本的计划是先将单生成单消费一个阻塞队列实现的生成消费模型,但是吧这样有点简单了,我们直接上难点的,越难才能越加深大家对线程同步与互斥,阻塞队列,条件变量的使用等等的理解,所以我们直接实现下面那种生产消费模型的代码,即为多生产多消费,并且实现两个阻塞队列,在这种复杂环境下依旧能够保持线程间的同步与互斥式的访问共享资源。3d61db828f934bf488071bb53384a784.png


2.

由于要实现两个分别存放不同任务的阻塞队列,那我们直接就写出来一个阻塞队列的类模板,这样就可以存放任意类型的对象,所以下面我们先来完善BlockQueue.hpp文件的代码,也就是阻塞队列的类模板代码。


我们需要一把锁来保证阻塞队列这个共享资源访问的安全性,并且生产线程不满足生产条件时,比如阻塞队列已经满了,则生产线程此时就不应该继续生产,而是要去cond的队列中进行wait,直到消费线程唤醒生产线程,所以生产线程要有自己的produce cond,简称pcond。反过来对于消费者来说同样如此,所以消费者在不满足消费条件的时候,也要去自己的cond队列中进行wait,那么消费者也应该要有自己的consume cond,简称ccond。所以类BlockQueue的私有成员应该包括_mutex互斥锁,_ccond,_pcond两个条件变量,我们还需要一个变量来描述阻塞队列的容量大小也就是_maxcap,然后再加一个STL容器queue< T > _q;然后希望定义出来的所有阻塞队列的最大容量都是同一个的,所以_maxcap定义为一个不可修改的静态成员变量,静态变量在类内只是声明,类外进行初始化,初始化时需要带上类名,不用添加static关键字。

阻塞队列需要实现的接口主要为四部分,构造函数内需要初始化好互斥锁以及两个条件变量,因为阻塞队列所使用的锁和条件变量是局部的(对象本身就在函数栈帧中)条件变量和锁,那么就需要在构造函数内进行初始化,在析构函数内完成销毁。


除此之外,还需要实现push和pop两个接口,为了保证向队列中push元素的安全性,所以接口中要进行加锁和解锁,然后就是判断是否满足push的条件,如果队列已经满了,那就不要继续push,也就是不要继续生产了,而是去pcond的队列中进行wait,一旦wait执行流就会阻塞停下来,等待被唤醒,如果满足条件,那直接用STLqueue的push接口push元素即可,非常简单。push元素之后,我们就该唤醒消费线程了,因为现在队列中至少有一个元素,是可以供消费者消费的,所以直接调用pthread_cond_signal唤醒ccond的队列中的线程即可。最后就是释放锁的步骤。


对于pop来说,由于STLqueue的pop接口不会返回pop出来的元素,所以我们需要通过输出型参数的方式拿到pop出来的元素值。与push的实现逻辑一样,pop满足的条件是队列中元素必须不为空,如果为空,则需要去ccond的队列中进行等待,直到被生产线程唤醒。pop数据之后,队列中一定至少有一个空的位置,所以此时应该唤醒生产线程,让生产线程进行元素的push,最后还是不要忘记释放锁。


对于接口的实现,大致逻辑说的差不多了。但在代码中还有几个细节需要特别说明一下。我们知道pthread_cond_wait接口是放在临界区内部的,所以在执行wait代码之前线程是持有锁的,为了在线程等待期间,其他线程也能申请到锁并进入临界区,所以在pthread_cond_wait被调用的时候,它会自动的以原子性的方式将锁释放,并将自己阻塞挂起到pcond的队列中。那么当队列中的某一个线程被唤醒的时候,他还是要从pthread_cond_wait开始向后执行,所以此时他还是在临界区内部,所以在

pthread_cond_wait返回的时候,会自动重新申请锁,然后继续在临界区中向后执行代码。另外判断逻辑的语句必须是while,不能是if,因为在多生产多消费的情景下,可能出现伪唤醒的情况,比如broadcast唤醒所有生产线程,但实际空位置只有一个,所以此时在唤醒之后,某一个线程竞争到锁,放入元素之后,队列已经满了,然后他释放了锁,其他某一个线程在竞争到锁之后,如果是if逻辑,那就不会重新判断是否满足,而是直接push元素,那就会发生段错误越界访问,所以要用while循环来判断,保证唤醒的线程一定是在条件满足的情况下进行的push元素。至于唤醒对方和释放锁的顺序怎么样都可以,因为唤醒对方,对方没锁的话,还是需要阻塞等待锁被释放,而如果先释放锁的话,由于对方没有被唤醒,那照样还是拿不到锁,所以这两个接口的调用顺序并不影响接口的功能,所以先写谁都可以。4f15820bba204007904002f7192b8e55.png



3.

主函数上层调用的逻辑就是要创建出多生产多消费的线程出来,而且要使用两个阻塞队列来完成计算任务和保存任务的产生与消费,所以我们又封装了一个BlockQueues类,类中封装两个Blockqueue,一个存储计算任务,一个存储保存任务,任务其实就是类对象,所以BlockQueues的类模板参数分别为C calculate和S save。然后就是创建出阻塞队列和多个生产线程和消费线程,以及保存线程。分别对应执行的线程函数是produce,consume,save,然后把BlockQueues类型的指针传给三个线程函数,这样在线程函数内部就可以通过BlockQueues类的两个指针成员去调用阻塞队列中的push和pop接口,完成任务的push和pop。


produce中,我们需要定义出CalTask类的对象,把这个任务对象push到c_bq(calculate blockqueue)这个阻塞队列中,构造对象需要两个操作数,以及操作运算符,还需要传一个mymath执行计算的函数指针进去,因为我们希望这些任务对象都是可调用对象,消费者在消费的时候,从队列中拿到任务之后就可以通过调用()运算符重载来完成计算任务mymath函数的调用,为了在打印的时候我们看的更加清晰,CalTask类内还实现了toTaskString函数,其实就是打印出计算任务的名称是什么,比如是1+1=?这样的名称,让我们在终端能够明显看到是produce线程函数在被执行。由于操作运算符有多种,所以定义出了字符串对象oper包括了5种运算符,然后我们又rand生产随机数,模拟两个操作数的生成。


consume中,任务比较艰巨,他需要消费计算任务CalTask,还需要生产保存任务SaveTask到s_bq(save blockqueue)保存阻塞队列中,消费任务需要传输出型参数,也就是一个空的CalTask对象t到pop接口中,然后pop结束后,t对象即为c_bq中取出的任务对象,拿出队列中的CalTask对象后,想要消费其实很简单,因为这个对象实际是仿函数对象,直接通过()调用即可。然后就是生产保存任务到阻塞队列中,与计算任务相同的是,保存任务对象也需要实现为可调用对象,这样在save线程取出任务对象时,也可以直接通过()来调用SaveTask类中的运算符重载函数,实现任务对象的消费。所以在构造SaveTask任务对象时,需要传计算任务的名称也就是一个string类型的对象,以便于执行保存任务到文件中时,我们能在文件当中看到对应保存的计算任务名是什么,然后还需要传一个函数指针Save,该函数的功能其实就是进行文件操作,将计算任务的名称保存到磁盘文件中。


save中,道理也是相同,要想拿出s_bq中的保存任务对象,自然需要通过输出型参数来拿出,所以我们传一个SaveTask类的空对象t到s_bq中的pop接口,pop调用之后,t就是s_bq中取出的保存任务可调用对象,所以消费的时候直接通过()来调用SaveTask类中的()重载函数即可完成保存任务,相对应的计算任务的名称就会保存在磁盘文件中。

三个线程函数的具体实现我们说完了,同样的在MainCp.cc这个文件当中也有一些细节要注意。记得我们在谈论如何避免产生死锁问题时,我们说到过一个写代码时需要注意的点就是,在多线程编程尤其是加锁的代码中,尽量将申请的资源统一在开头处一遍就申请好,不要在代码中需要的时候才去申请,因为那可能会出现一些你根本无法预料的错误。害害害,人教人教不会,事教人一教一个准,没错,我就是那个不在开头一遍申请好资源的人,所以我也遇到了我无法解决的bug,确实令我头疼了很长时间。初始化第二个阻塞队列的那行代码如果放在创建produce和consume线程之后,也就是我注释掉的那个地方,你去运行吧,保证爽死你,你看到的运行结果就会是,一会儿运行正常,一会儿报段错误,这对于刚接触多线程的萌新来说,友好度直接拉满。产生那样现象的原因是因为,如果主线程运行的足够快,那就会出现consume线程还没将保存任务放到s_bq之前,主线程的s_bq正好初始化好了,所以程序会正常运行。但如果主线程稍微运行的满了,那就会出现s_bq还未初始化好,consume线程就已经将保存任务放到s_bq里面了,但s_bq是还没分配内存的野指针,所以此时就会报段错误,因为我们访问了野指针。所以,老铁们,尽量在开头的时候把需要使用的空间资源就分配好,别等到使用的时候才去分配,因为多线程不好找错误啊!

ec8c4a653f4a4d5f91920b0f700850ae.png


4.

最后一个文件就是Task.hpp,这个文件就是我们要实现的计算任务类和保存任务类,以及计算的方法和保存的方法。计算任务类中要实现两个构造函数,一个是空的构造函数,用于main中构造出空对象作为输出型参数传递给阻塞队列的pop接口,另一个就是构造出真正的任务对象。类成员只需要两个操作数一个操作符,外加一个包装器即可,因为包装器可以包装很多可调用对象,所以如果你想搞一个仿函数对象,或者lambda表达式或函数指针来传给构造函数的话,包装器类型都是可以接收的,在构造函数内部将这些私有成员都初始化好即可。除此之外还需要实现一个()运算符重载和一个返回string任务名的toTaskString函数,为了将可调用对象的计算结果返回,()运算符重载内部回调了mymath的方法,将计算结果通过snprintf函数进行字符串格式化到buffer里面,然后用buffer构造出string对象进行函数返回。toTaskString也是将计算任务进行名称的格式化到buffer里面,同样返回一个由buffer构造出的string对象。

mymath函数的实现我就不说了,用switch case语句就可以实现两个操作数的计算,这真的可以算是入门级的代码实现了。

SaveTask类成员变量包括保存的计算任务名_message,这个任务名实际就是通过CalTask的()运算符重载函数返回的string对象,传到我们的SaveTask内的构造函数里的,另一个成员变量就是包装器,用于包装将任务名写到文件的文件操作方法Save函数指针。同样的还需要实现一个空的构造函数,用于main中调用pop时,将任务写到输出型参数空的SaveTask对象里。为了实现任务的消费,我们也实现出一个()运算符重载,老样子,回调一下包装器包装的可调用对象即可。

至于Save的实现也不难,就是比较正常的C语言文件操作,fopen打开文件,fclose关闭文件,fputs写入文件。9ab62548cc9d4b94a1e4b3bb6b362961.png


5.

到此为止我们就谈完了整个的双阻塞队列实现的多生产多消费模型,下面是程序的运行结果,我们很好的实现了计算任务的生产消费,保存任务的生产消费,且是在多个生产者多个消费者的多线程情景下实现的生产消费模型。而能够实现的原因还是因为我们有锁来保证多线程访问共享资源的互斥性,还有条件变量来保证多线程在互斥访问共享资源时的同步性。

8e5661fa64174426b0bcc3904a5c19e7.png


2.生产消费模型高效在哪里?(不影响其他多线程并发或并行的获取任务和执行任务)


1.

上面代码写完了,我们要来回答一个非常重要的问题,就是为什么生产消费模型是高效的?我并没有见到他高效在哪里啊!访问阻塞队列这个共享资源时,不还是得互斥式的访问么?你凭什么说生产消费模型高效呢?


确实!你说的没有问题,很正确!但实际生产消费模型根本不是高效在向阻塞队列中放元素和从阻塞队列中拿元素。而是高效在某一个线程在向阻塞队列中放任务的时候,不会影响其他线程获取任务,某一个线程在从阻塞队列中拿任务的时候,不会影响其他线程在执行任务。


我们今天所写的阻塞队列中不过是存储了一些微不足道的计算任务或保存任务,执行和获取起来根本不费力,但未来线程在真正获取某些大型任务比如从数据库,网络,外设拿来的用户数据需要处理呢?那在获取任务和执行任务的时候,会很费时间的。

而生产消费模型高效就高效在,你某一个线程互斥式的从阻塞队列中拿任务或取任务时,根本就不会影响我其他多个线程在获取任务或执行任务,并且其他多个线程是在并发或并行的执行任务,效率是很高的!


所以总结起来就一句话,生产消费模型并不高效在放任务到阻塞队列和从阻塞队列拿任务,而是真正高效在,某一个线程拿或放任务到blockqueue的时候,并不会影响其他线程并发或并行的获取任务和执行任务。

2cf5f6b7f76344978c60f76af90bc8ec.png
































相关文章
|
17天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
13 3
|
17天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
13 2
|
17天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
27 2
|
17天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
26 1
|
17天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
28 1
|
17天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
22 1
|
30天前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
38 1
|
5天前
|
缓存 监控 Linux
|
9天前
|
Linux Shell 数据安全/隐私保护
|
10天前
|
域名解析 网络协议 安全
下一篇
无影云桌面