237_多生产者-多消费者问题
# 2.3_7_多生产者-多消费者问题
各位同学大家好,在这个小节中我们会学习一个多生产者多消费者的这样一个问题模型。
先来看一下问题的描述,假设桌子上面有一个盘子,每一次只能向这个盘子里放一个水果,有 4 个人,父亲、母亲、女儿和儿子,父亲每一次会向盘子里放 1 个苹果,而女儿专门等着吃盘子里边的苹果,所以如果盘子里有苹果的话,女儿会把苹果给取出,并且把它吃掉。
另外母亲会专门往盘子里边放橘子,儿子又专门等着母亲把橘子放到盘子里,之后他会把盘子里的橘子给取出并且吃掉。
如果说由于盘子的容量是只能装一个水果,所以如果父亲把苹果装到了盘子里的话,那么如果这个苹果还暂时没有被女儿取出,这个时候母亲又包了一个橘子,并且他尝试把橘子放到这个盘子里,但由于这个盘子里只能装一个水果,所以这种这个时候母亲把橘子放入盘子,这个行为应该被阻止。
另外由于儿子只吃橘子,所以此时如果盘子里装的是一个苹果的话,那么如果儿子正在尝试从盘子里取出水果,这个水果由于不是他想吃的,所以他的这种行为也应该被阻止或者说阻塞。
这个问题其实我们可以把它抽象为咱们上一个小节学过的生产者消费者模型,我们可以把这个盘子看作是一个大小为一,初始为空的缓冲区,然后把父亲和母亲看作是两个生产者进程,把女儿和儿子分别看作是两个消费者进程。当然与上一节所讲的生产者消费者模型不同的是小节中的例子,这些生产者和这些消费者,他们所生产的东西和他们所消费的东西类型是不一样的。而上个小节我们介绍的例子当中,所有的生产者生产的都是一种东西,而所有的消费者也都是消费同一种东西,所以这就是为什么把小节的问题称作多生产者多消费者的原因。
所谓的多不是指多个,应该把它理解为是多类,不同类别的生产者和不同类别的消费者,他们所需要生产的和所需要消费的这些产品是不一样的。那么这个问题我们怎么用 pv 操作来解决?
我们用上个小节学习过的方法来一步一步一次分析。首先我们来看一下这个题目当中所描述的场景,总共有几类进程,很显然父亲、母亲、女儿、儿子他们各属于一类进程,另外它们之间是否存在同步和互斥的关系?之前我们说过这个题目当中这个盘子我们可以把它看作是一个缓冲区,而上一节我们聊过,对于缓冲区的访问,一般来说都需要互斥的进行,所以我们需要实现对盘子这种缓冲区的互斥访问。
另外是否存在这种一前一后的同步关系?首先父亲要将苹果放入盘子之后,女儿才能取到苹果,所以父亲进程和女儿进程,他们之间有一对同步关系。另外母亲要把橘子放入盘子之后,儿子才可以取到橘子,所以他们俩之间也存在一对同步关系。
第三,只有盘子为空的时候,父亲或母亲才可以把水果放到盘子当中,而这个地方的盘子为空,这个事件其实既可以由儿子触发,也可以由女儿触发。假如说盘子里放的是橘子,那么取走后,盘子为空,这个事件就应该由儿子来出发,由儿子取走橘子。而如果说盘子里放的是苹果的话,那就应该由女儿取走苹果,然后由女儿来触发盘子为空这个事件,而只有盘子为空,这个事件发生之后,才能允许父亲进程或母亲进程往盘子里放入水果。
所以女儿和儿子他们可能会触发一个事件,来引发父亲或者母亲往盘子里放入水果,这就是这个题目当中所包含的互斥关系和同步关系。
第二步我们来看一下各个进程之间的 pv 操作流程大概是什么样的。我们知道实现互斥其实很简单,无非就是在访问临界资源的之前和访问临界资源之后,分别对互斥变量实行一个 p 操作和一个 v 操作。
而实现同步关系,其实我们之前也说过,我们只需要遵循一个原则,就是所谓的前 v 后 p,也就是前面的事件发生了之后,我们需要执行一个 v 操作,而后面的事件发生之前,我们需要执行一个 p 操作,那就是这个样子。
那么对于实现互斥关系来说,我们当然是需要设置一个初值为一的这种斥信号量,而对于这些同步关系来说,我们需要根据具体的情况来判断每一个同步变量应该设为多少。
父亲进程需要把苹果放入盘子,女儿才能取到苹果,所以我们需要设置一个同步信号量叫做 apple,用来实现这个同步关系。而由于刚开始盘子里边是没有苹果的,所以我们把这个值设为初始值设为 0,只有父亲对这个同步信号量执行 v 操作之后,女儿对这个同步信号量执行的 p 操作才不会被阻塞。
同样的对于母亲进程来说,我们也设置一个叫做 orange,也就是橘子的一个同步信号量,它的初始值也设为一,因为刚开始盘子里边是没有橘子的
另外只有盘子为空的时候,父亲和母亲才可以放入水果,而刚开始盘子本来就是空的,所以父亲和母亲在刚开始其实就可以向盘子里放入一个水果,所以这一对同步关系,我们就需要为它设置一个初值为一的同步信号量用来表示此时盘子是否为空。
那么来看一下具体的代码实现,我们总共声明了 4 个信号量,1 个其中一个是互斥信号量,另外三个是同步信号量。父亲进程和母亲进程做的事情就是不断的准备一个自己的水果,然后再把自己的水果放到盘子里,而女儿进程和儿子进程做的事情就是不断的从盘子中取出自己喜欢的水果,并且把这个水果给吃掉。
那父亲进程在放入水果之前,需要先检查这个盘子是否为空,所以在苹果放入盘子之前,父亲进程需要对这个信号量进行一个 p 操作,来检查此时盘子中到底还可以放多少个水果。如果这个苹果已经放入盘子,之后父亲进程又需要对 apple 同步信号量执行一个 v 操作,用来让 apple 的值加一,来告诉女儿进程,此时苹果盘子中的苹果数量已经加一了
而母亲进程也一样,他在把橘子放入盘子之前,需要同样需要对盘子中还可以放多少个水果进行检查。如果说此时这个盘子中已经有别的水果,那么母亲进程会被阻塞。而当母亲进程把橘子放入盘子之后,他同样也需要对 orange 这个同步信号量执行一个 v 操作,来通知儿子进程,此时盘子当中的橘子数已经加一了。
那么女儿进程和儿子进程在取出自己喜欢的水果之前,分别需要检查此时这个盘子当中是否已经有自己喜欢的水果,所以女儿进程是对 apple 信号量执行 p 操作,而儿子进程是对 orange 信号量执行 p 操作。
而女儿进程在从盘子中取出苹果之前,需要先检查此时苹果的盘子中苹果的数量是否足够,如果没有苹果的话,它将被阻塞,而当他把苹果取出之后,又需要对 plate 这个信号量执行 v 操作,用来告诉父亲进程和母亲进程,此时盘子已经变空了。
那么儿子进程也和女儿进程也类似,只不过是他在检查的时候是检查盘子当中是否有橘子,这样的话我们就实现了咱们在这个图中表示的这些同步关系。
另外我们还需要实现各个进程对盘子这种缓冲区的斥访问,所以我们在这些进程访问盘子之前,对互斥信号量执行一个 p 操作,访问之后又执行一个 v 操作,分别是对临界区进行加锁和解锁,其他的这些进程也一样。
接下来我们再来考虑一个问题,可不可以不要互斥信号量?就是这个样子,我们把互持信号量去掉,并且把对互斥信号量的 pv 操作也都去掉。
我们来分析一下,如果是这样的话,这些进程会怎么并发执行。刚开始由于 Apple 和 orange 这两个信号量的数量都为 0,所以女儿进程和儿子进程无论谁上处理机运行,肯定在执行到 p 操作的时候都会被阻塞。
那么我们假设刚开始是由父亲进程上处理机运行,那么它首先会对盘子同步信号量执行一个 p 操作,由于刚开始信号量的值为一,也就是说盘子这种资源还足够,所以父亲进程他可以顺利的跳过 p 操作,然后开始把苹果放入盘子当中。
而这个时候如果说切换回母亲进程,那么当母亲进程对盘子的信号量执行 p 操作的时候,由于这个值已经变为了 0 盘子,这种资源已经不够了,所以母亲进程在这个时候会被阻塞等待。
而当父亲进程把苹果放入盘子之后,他又会对 apple 同步信号量执行一个 v 操作。这个时候女儿进程他又会被唤醒,然后从盘子当中取出苹果之后,女儿进程又会对 plate 同步信号量执行一个 v 操作。
由于之前母亲进程是因为这个信号量而被阻塞的,所以当她执行 v 操作之后,母亲进程就会被唤醒,之后母亲进程就可以开始顺利的访问临界区资源。
而当母亲进程在访问盘子这种临界资源的时候,由于 plate 的值为 0,然后 orange 和 apple 的值也都为 0,所以除了母亲进程以外,别的这些进程即使上处理机运行也肯定会被卡在 p 操作这,也就会被阻塞。
所以通过刚才的分析,我们会发现在这个题目当中,我们即使不设置专门的斥信号量 mutex,我们依然可以实现这些进程对盘子这种临界区的互斥访问,为什么呢?
其实原因在于这个题目当中的缓冲区的大小值为一,大家可以自己再尝试分析一下更多的情况,apple、orange 和 plate 这三个同步信号量,那么同一时刻最多只有一个会是一,而这几个进程刚开始都需要对其中的某一个信号量执行 p 操作。由于这三个同步信号量当中,同一时刻最多只有一个的值会是一,所以这些进程执行各自的 p 操作的时候,最多只有一个进程不会被阻塞,可以顺利的进入临界区进行访问。
假如我们把盘子的容量设为二,也就是缓冲区的容量把它设为二的话会发生什么情况?假设刚开始盘子就可以放两个水果,那么刚开始父亲进程执行 p 操作,发现盘子资源足够,所以他可以进入临界区开始访问盘子,而母亲进程在执行 p 操作之后,也发现盘子这种资源依然是足够的,所以它同样也会进入临界区,对盘子这种临界资源进行访问,所以这就发生了父亲进程和母亲进程两个进程,同时访问盘子这种临界资源的情况(容易覆盖)。通过上个小节的讲解,我们知道如果两个生产者进程,他们同时对一个缓冲区来进行访问的话,那么有可能会导致数据覆盖的问题,这个地方也一样。
因此如果我们在生产者消费者问题当中遇到了缓冲区大于一的情况,那么我们就必须设置一个互斥信号量 Mutex 来保证各个进程是可以互斥的访问缓冲区的。而如果缓冲区大小等于一的话,那么我们即使不设置互斥信号量,有可能也可以实现互斥访问临界区这个事情。当然这不是绝对的,只是有可能不需要设置互斥信号量,要具体问题具体分析。
如果大家在考试的时候遇到缓冲区大小唯一的情况的时候,那么可以自己分析一下,如果能确定不需要使用互斥信号量的话,那么不设置也可以。但是如果来不及仔细分析的话,大家最好是加上斥信号量,因为加上了肯定也没错。
不过我们需要注意的是上个小节强调过的那个问题,实现互斥的,对于 Mutex 信号量的 p 操作,一定要在实现同步的 p 操作之后,否则是有可能会引起死锁的,这个点大家还能不能回忆起来呢?那么和之前的小节一样,大家也需要体会 pv 操作这种相关的题目的解题思路
和上个小节介绍的经典的生产者消费者问题,不太一样的是小节模型是多生产者多消费者,他的同步关系要比之前小节所介绍的同步关系要复杂的多。大家需要注意一个事情,在分析这种同步问题的时候,我们不能从单个进程的行为的角度来出发,而需要把这种一前一后的事情把它看作是两个事件的前后关系。这句话我们不太容易理解,我们直接来看例子。
如果说在这个题目当中,我们是从单个进程行为的角度来考虑的话,那么我们会有这样的结论。首先从题目当中我们可以知道,如果盘子里边装的是苹果,那么一定要女儿取走苹果之后,父亲和母亲才可以放入水果。
所以当女儿进程取走苹果之后,可能会导致父亲进程,可以放入水果,同样也可能导致母亲进程可以放入水果。
另外如果盘子里装的是橘子的话,那么儿子取走举子之后,可能会导致父亲进程,可以放入水果,也可能会导致母亲进程,可以放入水果。
那么如果我们从这种单个进程的行为来分析的话,仅仅这 2 句话,我们就可以分析出这样的 4 个同步关系。那么,有 4 个同步关系是不是就意味着我们要设置 4 个同步信号量来分别实现这 4 个一前一后的这种关系了?当然不是。
其实正确的分析方法,我们应该从事件的角度来考虑,我们应该把刚才所描述的这种进程行为的前后关系,也就是女儿取走取走水果,这个行为导致父亲可以放入水果,同样也导致母亲可以放入水果。从这种进程行为的前后关系,我们可以把它抽象为一对事件的前后关系,我们应该把进程同步问题把它理解为某一个事件,一定要求发生在另一个事件之前,而不是某一个进程的行为要发生在另一个进程的行为之前。
那么我们如果从事件的角度来考虑的话,刚才所描述的这两句话,其实我们可以抽象为两个事件,盘子变空的事件可一定要发生在放入水果这个事件之前,而盘子变空这个事件既可以由儿子进程来引发,也可以由女儿进程来引发。放水果的这个事件,既可以是父亲进程来执行,也可以是母亲进程来执行。所以刚才我们看起来有 4 对同步关系,其实我们如果把从事件的角度来考虑的话,我们可以把它抽象为一对事件的前后关系,而这些事件可以由哪个进程来引发,那么进程就需要对这个事件对应的同步信号量执行一个 v 操作。
同样的父亲进程和母亲进程都有可能会执行放入水果这个事件。所以在放他们执行各自的放入水果事件之前,我们需要对这个事件对应的同步信号量执行,分别执行一个 p 操作。
这个地方大家还需要自己认真的体会,这对于初学者来说确实比较容易犯这样的问题,如果能把这个部分的内容理解了,以后再做同步相关的这些大题的时候,应该问题就不大了。