2311_管程
# 2.3_11_管程
各位同学大家好。在小节中我们会为大家介绍管程相关的内容,首先会从历史发展的角度来让大家了解为什么要引出管程这种机制。 另外管程的定义和基本特征是什么呢,这一点是考试当中比较容易作为选择题考察的一个知识点,最后为了让大家能够更形象的理解管程到底是什么,我们会有两个拓展,第一个拓展是用管程来解决生产者消费者问题。第二个拓展是会介绍一种在Java当中类似于管程的机制。
那么首先来看一下为什么要引入管程,在管程引入之前,其实人们用来实现进程同步和互斥,主要是使用信号量机制,就是咱们之前学过的那种pv操作。但是信号量机制存在的问题就是编写程序困难易出错,这点大家在做题的时候应该也有体会过。比如说咱们在生产者消费者问题当中提到过,如果说实现互斥的p操作,在实现同步的p操作之前,那么就有可能会引起死锁的状态。比如说如果我们按这样的方式来写生产者和消费者的话,那么按照123这样的顺序来执行,这两个进程都会被阻塞,都就会进入一种死锁的状态,所以我们在使用信号量机制的时候,就不得不关心这些pv操作的顺序,这就造成了我们在编写程序的时候很困难,并且极易出错的这种问题
所以人们就要想到能不能设计一种机制,能够让程序员写程序的时候,不需要再关注这么复杂的pv操作,可以让写代码更加轻松。在1973年有一个叫做什么brain的人,在pascal语言里首次引入了管程的成分,管程其实它是一种高级的同步机制,它本质上也是用于实现进程的互斥同步的,只不过它比之前的这种信号量机制要更方便易用一些,是一种更高级的同步机制。
# 管程的定义和基本特征
那么什么是管程?管程有什么基本特征呢?在聊这个问题之前,咱们必须再次强调,管程其实和之前学过的pv操作一样,它也是用来实现进程的互斥和同步的,而进程之间要实现互斥和同步,是因为进程之间可能会共享某些数据资源,比如说像生产者消费者问题当中,生产者和消费者都需要共享的访问缓冲区这种资源
所以为了实现各个进程,对一些共享资源的互斥或者同步的访问的话,那么管程就要有这样一些部分组成。
第一,局部于管程的共享数据结构说明,比如说咱们刚才提到的生产者消费者问题当中,生产者和消费者都需要共享访问的缓冲区,其实我们可以用一种数据结构来表示缓冲区,对缓冲区进行管理,所以在管程当中需要定义一种和这种共享资源相对应的这种共享数据结构。
第二,对上面所提到的这种数据结构进行操作的一组过程,跨考的同学可能不太容易理解什么叫过程,其实过程可以直接理解为它就是所谓的函数。所以第二句话也可以这么说,管程当中还需要定义对之前所提到的这种共享数据结构进行操作的一组函数。
第三,还需要有对局部于管程的共享,数据设置初始值的语句。特别绕,反正就是对这个数据结构要进行初始化的一些语句,也需要在管程当中说明。
第四,管程还需要有一个名字,如果学过面向对象设计的同学可能会发现管程的定义其实就有点类似于我们的类,对吧?在类当中我们可以定义一些数据,并且还可以定义对这些数据进行操作的一组函数一组过程。另外我们还可以在这个类当中定义一些对这些数据进行初始化的语句,当然如果没有学过面向对象语言的同学理解不了也没有关系,咱们之后还会有别的例子来让大家理解。
为了用管程实现进程之间的互斥和同步,管程有这样一些特征
第一,局部与管程的数据只能被局部于管程的过程所访问。
第二,一个进程只有通过调用管程内的过程,才能进入管程访问共享数据,这个有点像英语的长难句,其实他们说的事情也很简单,就是说管程当中定义的这些共享的数据结构,只能被管程当中定义的这一些函数所修改。所以如果我们想要修改管程当中的这一些共享数据结构的话,我们只能通过调用管程提供的这些函数来间接的修改这些数据结构,其实这就是第一句和第二句的意思。
第三,每次仅允许一个进程在管程内执行某个内部过程,就是说管程当中虽然定义了很多函数,但是同一时刻肯定只有一个进程在使用管程当中的某一个函数。别的进程如果也想使用管程当中的某一些函数的话,只要之前的进程还没有用完,别的进程就暂时不能开始执行管程的这些函数,所以这是第三句的意思。
每次仅允许一个进程在管程内执行某个内部过程,为什么要这么设计?我们可以想一下,比如说我们把生产者消费者问题当中的缓冲区定义为了管程当中的某一种共享数据结构,按照之前咱们学习的内容,我们知道各个进程对缓冲区的访问必须是互斥的,也就是有一个进程在访问缓冲区的时候,别的进程肯定不能同时访问,必须先等待。所以如果我们能够保证每一次仅有一个进程,能在管程当中的某一个内部过程当中执行的话,那么这就意味着每一次对共享数据结构的访问,肯定只有一个进程正在进行,而不可能有多个进程正在同时的访问共享数据结构,所以这就是管程的精髓所在。
# 拓展1:用管程解决生产者消费者问题
接下来我们用一个具体的例子看一下管程是怎么解决生产者消费者问题的,需要注意的是在这个地方并没有按照某一种严格的语法规则来进行表述,这个地方只是为了让大家容易能够理解,所以用了类C语言的伪代码来表示管程当中的这一系列逻辑,我们可以用程序设计语言当中提供的某一种特殊的语法,比如说monitor,end monitor,用这样一对关键字来定义一个管程,就是指中间的这个部分,就是管程的内容
管程的名字叫 ProducerConsumer。另外我们可以定义一些条件变量用来实现同步,还可以定义一些普通的变量,用来表示我们想要记录的信息,比如说缓冲区当中的产品个数。
其实除此之外,我们还需要定义对缓冲区进行描述的一些数据结构,只不过为了方便我们这就省去了。那生产者进程想要往缓冲区里放入一个自己新生产的产品,可以直接调用管程当中定义的这个insert函数就可以实现。
像之前咱们用pv操作的时候,生产者进程需要有一堆pv操作,但如果采用了管程,这个代码就变得特别简洁。首先生产一个产品之后定义管程当中的 insert函数,然后把自己生产的产品作为这个函数的参数传进去,接下来的问题,生产者进程就不用管了,接下来就由管程来负责解决剩下的什么互斥同步一系列很复杂的问题。
同样的消费者进程也可以很简单的调用管程当中定义的某一个函数,就可以实现从缓冲区当中取出一个产品这样的事情,所以消费者进程的代码也变得非常简洁,而去从缓冲区当中取出一个产品的时候,缓冲区空了怎么办,还要对缓冲区的互斥怎么办,这些消费者进程都不用关心,剩下的都是管程会负责解决的问题。
我们定义了管程之后,在编译的时候,其实会由编译器负责实现各个进程互斥的进入管程当中的过程。这样一件事情举个例子,比如说有两个生产者进程并发的执行,并且先后都调用了管程的insert这个过程或者说这个函数。那么由于刚开始没有任何一个进程,正在访问这个管程当中的某一个函数,所以第一个生产者进程在调用insert函数的时候是可以顺利的执行下去的,它会执行完一系列代码,包括判断缓冲区是否满了,或者此时是否有一些是消费者进程需要唤醒,这系列的事情都是在管程的insert函数里边进行完成的,而如果在第一个进程没有执行完这个函数相应的这一系列逻辑的时候,第二个进程就尝试着也想调用insert函数,那么由于编译器实现的这些功能,它会暂时阻止,第二个进程进入insert函数。所以就会把第二个进程阻塞在insert函数后面,就类似于一个排队器,让他先等待。等第一个进程访问完了insert函数之后,才会让第二个进程开始进入insert函数,然后执行相应的这一系列逻辑,所以其实互斥的使用某一些共享数据,这是由编译器负责为我们实现的。程序员在写程序的时候不需要再关心如何实现互斥,只需要直接调用管程提供的这一系列的方法,其实它本身就已经能够保证这是互斥的进行的。
那除了互斥之外,管程还可以实现进程的同步。我们可以在管程当中设置一些条件变量,比如说在这个地方我们设置了full和empty这两个条件变量,还有与他们对应的等待和唤醒操作,用来实现进程的同步问题。
比如说如果有两个消费者进程先执行,生产者进程后执行,那么第一个消费者进程在执行的时候,首先是调用了管程的remove过程,或者说这个函数,首先需要判断此时缓冲区里是否有可用的产品,由于刚开始 count 的值本来就是0,所以第一个消费者进程需要执行wait,也就是等待操作,于是第一个消费者进程会等待在empty条件变量相关的队列当中。
同样的第二个消费者进程开始执行remove函数的时候,也会发现此时count的值是0,所以它也需要执行等待操作。同样的也会插入到empty条件变量对应的队尾。就像这样子,
之后如果有一个生产者进程开始执行,它会执行管程的insert函数,或者说这个过程,那么他会把他自己生产的产品放入到缓冲区当中,并且会检查自己放入的产品是不是缓冲区当中的第一个产品,如果说是第一个产品的话,就意味着此时有可能有别的消费者进程正在等待我的产品。所以接下来生产者进程在执行inside函数的时候,也会在其中执行一个唤醒操作 signal功能操作,用于唤醒等待在Mt这个条件变量对应的等待队列当中的某一个进程,一般来说都是唤醒排在队头的进程,也就是第一个消费者进程。
由于第一个消费者进程被唤醒了之后,他就可以开始往下执行,首先是执行count--,让count的值由一又变回了0,然后在检查在自己取走产品之前,缓冲区是不是已经满了?如果说缓冲区之前已经是满的,那么就意味着有可能会有生产者进程需要被唤醒。于是消费者进程又会调用一个对full条件变量的signal,也就是唤醒操作原理,和刚才咱们介绍empty这个的原理其实是一样的,最后remove函数会返回一个消费者进程想要的产品,对应的一个可以理解为是指针,所以第一个消费者进程就可以通过这样一个步骤就可以取出他想要的产品。
而在取产品的过程当中,如何实现对缓冲区的互斥访问,或者当缓冲区当中没有产品的时候,自己的消费者进程应该怎么处理?这一切都不需要消费者进程再来关心,这一切都是由管程负责解决的。
所以从这个例子当中可以看到,在采用了管程这种机制之后,实现进程的互斥和同步,这些事情就变得简单多了。我们只需要调用管程当中的某一些过程来完成我们想要完成的事情就可以了。
那么我们再根据刚才的例子,用自己的话对管程的特点进行一个描述。
- 第一,我们需要在管程当中定义一些共享数据,比如说像生产者消费者问题当中的缓冲区对应的数据结构,我们就可以在管程当中定义。
- 第二,我们可以在管程当中定义一些用于访问这些共享数据的入口,这个入口其实就是所谓的函数或者之前那种说法当中所谓的过程。比如说在生产者消费者问题当中,我们定义了一个insert函数和一个remove函数,通过这两个入口,我们可以对缓冲区进行操作。
- 第三,其实我们在生产者消费者进程当中是不可以直接访问共享缓冲区的,我们只能通过管程当中定义的这些特定的入口,也就是它提供的这些函数,才能访问这个共享的缓冲区。
- 第四管程当中有很多入口,比如说有insert入口,有remove这样的入口,也就是有很多个函数,但是每一次管程当中只能开放其中的一个入口,并且只能让一个进程或线程进入。所以这种特性就可以保证在一个时间段内最多只会有一个进程在访问我们的共享数据,比如说刚才提到的这种缓冲区,但需要注意的是这种互斥访问管程当中各个入口的这种特性,这种互斥特性它是由编译器负责实现的,程序员其实并不需要关心。
- 第五,我们可以在管程当中设置一些条件变量和对应的等待唤醒操作来解决同步问题,可以让一个进程或线程在条件变量上等待,比如说咱们刚才举到的在empty条件变量上等待的例子,如果一个进程在条件变量上等待的话,进程应该先释放管程的使用权,也就是要让出这个入口。另外我们还可以通过唤醒操作,把等待在条件变量上的进程或者线程唤醒。
那么通过刚才的例子,我们知道程序员其实可以通过某一种特殊的语法来定义一个管程,比如说monitor和end monitor这样一对关键字之后,其他的程序员就可以通过管程当中定义的这些特殊的入口,或者说这些特定的函数就可以很方便的实现进程的同步和互斥了。
有没有发现其实在管程当中实现了我们之前pv操作当中需要实现的什么排队阻塞互斥这一系列的问题,我们只需要简单的调用一个特殊的入口,一个函数就可以很方便的使用了。其实这就是程序设计当中所谓封装的思想,把一些复杂的细节隐藏了,对外只需要提供一个简单易用的接口。
# 拓展2:Java中类似于管程的机制
那么其实Java当中也提供了类似于管程的机制,如果我们用 synchronized这样一个关键字来定义一个来描述一个函数的话,这个函数就可以保证同一时间段内只能被一个线程所调用。比如说我们定义一个叫做 monitor的一个类,然后这个类当中的某一个函数,我们用 synchronized这样一个关键字进行描述,这就意味着insert这个函数同一时间段内只能被一个线程所调用,或者说即便多个线程都几乎同时调用了insert函数,但后面调用的那些线程是需要排队等待的,只有当前使用insert函数的那个线程执行完了相应的代码,最后退出了这个函数之后,下一个线程才可以开始进入这个函数,然后执行其中的代码,这个地方就不再展开细聊了。
对于不熟悉Java的同学看不懂也没有关系,这个地方只不过是为了让大家知道管程这个概念其实离我们并不遥远,我们自己也会用到和管程类似的一种机制。只有熟悉了一个概念之后,再遇到与这个概念相关的一些知识点的时候,大家才不会有那种恐惧的感觉。
另外如果熟悉Java的同学,在时间允许的情况下,其实也可以自己动手用这个关键字来实尝试实现一下生产者消费者问题当中的管程到底应该怎么定义。
# 小结
这个小节我们介绍了管程相关的内容,管程的引入其实就是为了解决信号量机制编程麻烦容易出错的问题。管程其实无非也就是为了实现进程的同步和互斥,只不过是实现起来会更方便一些。
另外我们介绍了管程的组成和基本特征,在考试当中最容易考察的其实是管程的这两个基本特征。首先外部的进程或者线程只能通过管程提供的特定入口,这个入口是什么意思?现在应该已经知道了,其实也就是管程当中定义的某一些函数或者说过程,才可以访问管程当中定义的那些共享数据。另外每一次仅允许一个进程在管程内执行某个内部过程,这两点是最容易在选择题当中进行考察的。
另外我们补充了两个知识点,各进程互斥访问管程的特性其实是由编译器负责实现的,程序员并不需要关心。另外可以在管程当中设置条件变量,还有等待唤醒操作,就可以解决管程的同步问题,管程其实就是应用了封装的思想,把进程同步互斥,这些复杂的细节隐藏在了管程定义的那些函数之内,而对外只提供一个简单易用的函数调用的接口,所以管程是应用了封装的思想