管程,并发编程的万能钥匙

并发编程这个技术领域已经发展了半个世纪了,相关理论和技术纷繁复杂,那有没有一种核心技术可以很方便的解决我们的并发问题呢?这个问题如果让我选择,我一定会选择管程技术。Java语言在1.5之前提供的唯一并发原语就是管程,而且1.5之后提供的SDK并发包也是以管程技术为基础的。除此之外,C/C++、C#等高级语言也都支持管程。
可以这么说,管程就是一把解决并发问题的万能钥匙。

什么是管程

不知道你是否曾考虑过这个问题:为什么Java在1.5之前仅仅提供了synchronized关键字及wait() notify() notifyAll()这三个看似从天而降的方法?在刚接触Java的时候,我以为它会提供信号量这种编程原语。因为操作系统原理课程告诉我用信号量能解决所有的并发问题,结果我发现不是。后来我找到了原因,Java采用的是管程技术,synchronized关键字及wait() notify() notifyAll()这三个方法都是管程的组成部分,而管程和信号量是等价的。所谓的等价指的是管程能够实现信号量,也能用信号量实现管程,但是管程更容易使用,所以Java选择了管程。
管程对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成监视器,这是直译。操作系统领域一般都翻译成管程,这是意译。而我自己也更倾向于使用管程。

MASE模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是HaSen模型、HOare模型和MESA模型,其中现在广泛应用的是MESA模型。并且Java管程实现参考的是MESA模型,所以我们今天重点介绍一下MESA模型。
在并发编程领域,有两大核心问题,一个是互斥,即同一时刻只允许一个线程访问共享资源,另一个是同步,即线程之间如何通信、协作。这两大问题管程都是能够解决的。
我们先来看看管程是如何解决互斥问题。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是将线程不安全的队列封装起来。对外提供线程安全的操作方法,例如入队操作和出队操作。利用管程可以快速实现这个直观的想。在下图中,将管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq(),出队操作deq()都封装起来了,线程A和线程B如果想访问共享变量Queue只能通过调用管程提供的enq() deq()方法来实现,enq()、deq()保证了互斥性,只允许一个线程进入管程。
不知你有没有发现,管程模型和面向对象高度契合,这也是Java选择管程的原因。而我在前面章节介绍的互斥锁用法,其背后的模型就是它。
在这里插入图片描述
那管程如何解决线程间的同步问题呢?
这个就比较复杂了,不过你可以借鉴一下我们曾经提到过的就医流程,它可以帮助你快速的理解这个问题。为进一步便于你理解,在下面我展示了一幅MESA管程模型示意图,它详细的描述了MESA模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作都是被封装起来的,图的最外层的框就代表了封装的意思,框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程就类似于就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而每个条件变量都有对应的一个等待队列。如下图,条件变量A和条件变量B分别都有自己的等待队列。
在这里插入图片描述
条件变量和条件变量等待队列的作用是什么呢?其实就是解决线程同步问题,你可以结合上面提到的阻塞队列的例子加深一下理解。阻塞队列的例子就是用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系。本文中一定要注意阻塞队列和等待队列是不同。
假设有个线程T1执行阻塞队列的出队操作,执行出队操作,需要注意有个前提条件,就是阻塞队列不能是空的,空队列只能出NULL值,是不允许的。阻塞队列不空这个前提对应的就是管程里的条件变量,如果线程T1进入管程后,恰好发现阻塞队列是空的,那怎么办呢?等待啊,去哪里等呢,就去条件变量对应的等待对列里等,此时线程T1就去队列不空这个条件变量等待队列中等待。这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子。你呢就去验血的队伍里排队,线程T1进入了条件变量的等待队列后,是允许其他线程进入管程的,这就和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。
再假设之后,另一个线程T1执行阻塞队列的入队操作,入队操作执行成功之后,阻塞队列不空,这个条件对于线程T1来说已经满足了。此时线程T2要通知T1,告诉他需要的条件已经满足了。当线程T1得到通知后,就会从等待队列里面出来。但是出来之后不是马上执行,而是重新进入到入口等待队列里面,这个过程类似于你验完血回来找大夫,需要重新分诊。
条件变量及其等待队列我们讲清楚了,下面再来说说wait() notify() notifyAll()这三个操作。前面提到的线程T1发现"阻塞队列不空"这个条件不满足,需要进入对应的等待队列里等待。这个过程就是通过调用wait()来实现的。如果我们用对象A来代表阻塞队列不空这个条件,那么线程T1需要调用A.wait()。同理,当阻塞队列不空这个条件满足时,线程T2需要调用A.notify()来通知A的等待队列中的一个线程,此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列里的所有线程。
这里我还是来一段代码再次说明一下吧。下面的代码用管程实现了一个线程安全的阻塞队列,再次强调这个阻塞队列和管程内部的等待队列没有关系,示例代码只是用管程来实现阻塞队列,而不是解释管程内部等待队列的实现原理。阻塞队列有两个操作,分别是入队和出队,这两个操作都是先获取互斥锁,类比管程模型中的入口。
对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待,直到阻塞队列不满,所以这里用了notFull.await();
对于阻塞队列出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不为空,所以就用了notEmpty.await();
如果入队成功,那么阻塞队列就不空了,就需要通知条件变量阻塞队列不空notEmpty对应的等待队列
如果出队成功,那就阻塞队列不满了,就需要通知条件变量阻塞队列不满,notFull对应的等待队列

public class BlockedQueue<T>{
	final Lock lock = new ReentrantLock();
	//条件变量:队列不满
	final Condition notFull = lock.newCondition();
	//条件变量:队列不空
	final Condition notEmpty = lock.newCondition();
	//入队
	void enq(T x){
		lock.lock();
		try {
			while(队列已满) {
				//等待队列不满
				notFull.await();
			}
			//省略入队操作
			//入队后,通知可出队
			notEmpty.singnal();
		}.finall{
			lock.unlock();
		}
	}
	//出队
	void deq(){
		lock.lock();
		try{
			while(队列已空){
				//等待队列不空
				notEmpty.await();
			}
			//省略出队操作
			//出队后,通知可入队
			notFull.signal();
		}finally{
			lock.unlock();
		}
	}
}

这段示例代码中,我们用了Java并发包里面的Lock和Condition,如果你看着吃力也没有关系。后面我们还会详细介绍这个例子,只是先让你明白条件变量及其等待对列是怎么回事。需要注意的是await()和前面我们提到的wait()语意是一样的,single()和我们前面提到notify()语意是一样的。

wait()的正确姿势

但是有一点需要再次提醒,对MESA管程来说,还有一个编程范式,就是需要在一个while循环里面调用wait(),这个是MESA管程里特有。

while(条件不满足){
	wait();
}

Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
Hasen模型里面要求notify()放在代码的最后,这样T2通知完T1后,T2就可以结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
Hoare模型里面T2通知完T1后,T2阻塞,T1马上执行,等T1执行完再唤醒T2也能保证同一时刻只有一个线程执行,但是相比Hasen模型,T2多了一个阻塞唤醒操作MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列里面进入到入口等待队列里面,这样做的好处是notify不用放到代码最后。T2也没有多余的阻塞唤醒操作,但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件现在已经不满足了,所以需要以循环方式检验条件变量。

notify()何时可以使用

还有一个需要注意的地方就是notify()和notifyAll()的使用。前面章节我曾经介绍过,除非经过深思熟虑,否则尽量使用notifyAll()。那什么时候可以使用notify()呢?需要满足以下三个条件:
所有等待线程拥有相同的等待条件,
所有等待线程被唤醒后执行相同的操作,
只需要唤醒一个线程。
比如上面阻塞队列的例子中,对于阻塞队列不满这个条件变量,其等待线程都是在等待阻塞队列不满这个条件,反映在代码里面就是下面这三行代码。对所有等待队列来说,都是执行这三行代码,重点是while里面的等待条件是完全相同。

while(队列已满) {
	//等待队列不满
	notFull.await();
}

所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行。

//省略入队操作
//入队后,通知可出队
notEmpty.singnal();

同时也满足第三条,只需要唤醒一个线程,所以上面阻塞队列的代码使用single()是可以的。

总结

管程是一个解决并发问题的模型,你可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中条件变量可以有多个,Java语言内置的管程里面只有一个条件变量,具体如下图所示。
在这里插入图片描述

Java内置的管程方案(synchronized)使用简单,synchronized的关键字修饰的代码块在编译器会自动生成相关加锁和解锁代码,但是仅支持一个条件变量。Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁需要开发人员自己进行加锁和解锁操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值