You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

169 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 08 | 管程:并发编程的万能钥匙
并发编程这个技术领域已经发展了半个世纪了,相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢?这个问题如果让我选择,我一定会选择**管程技术**。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领域的同学都喜欢将其翻译成“监视器”这是直译。操作系统领域一般都翻译成“管程”这个是意译而我自己也更倾向于使用“管程”。
所谓**管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发**。翻译为Java领域的语言就是管理类的成员变量和成员方法让这个类是线程安全的。那管程是怎么管的呢
## MESA模型
在管程的发展史上先后出现过三种不同的管程模型分别是Hasen模型、Hoare模型和MESA模型。其中现在广泛应用的是MESA模型并且Java管程的实现参考的也是MESA模型。所以今天我们重点介绍一下MESA模型。
在并发编程领域,有两大核心问题:一个是**互斥**,即同一时刻只允许一个线程访问共享资源;另一个是**同步**,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
我们先来看看管程是如何解决**互斥**问题的。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
利用管程可以快速实现这个直观的想法。在下图中管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了线程A和线程B如果想访问共享变量queue只能通过调用管程提供的enq()、deq()方法来实现enq()、deq()保证互斥性,只允许一个线程进入管程。
不知你有没有发现管程模型和面向对象高度契合的。估计这也是Java选择管程的原因吧。而我在前面章节介绍的互斥锁用法其背后的模型其实就是它。
![](https://static001.geekbang.org/resource/image/59/c4/592e33c4339c443728cdf82ab3d318c4.png "管程模型的代码化语义")
那管程如何解决线程间的**同步**问题呢?
这个就比较复杂了不过你可以借鉴一下我们曾经提到过的就医流程它可以帮助你快速地理解这个问题。为进一步便于你理解在下面我展示了一幅MESA管程模型示意图它详细描述了MESA模型的主要组成部分。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
管程里还引入了条件变量的概念,而且**每个条件变量都对应有一个等待队列,**如下图条件变量A和条件变量B分别都有自己的等待队列。
![](https://static001.geekbang.org/resource/image/83/65/839377608f47e7b3b9c79b8fad144065.png "MESA管程模型")
那**条件变量**和**条件变量等待队列**的作用是什么呢?其实就是解决线程同步问题。你可以结合上面提到的阻塞队列的例子加深一下理解(阻塞队列的例子,是用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系,本文中**一定要注意阻塞队列和等待队列是不同的**)。
假设有个线程T1执行阻塞队列的出队操作执行出队操作需要注意有个前提条件就是阻塞队列不能是空的空队列只能出Null值是不允许的**阻塞队列不空**这个前提条件对应的就是管程里的条件变量。 如果线程T1进入管程后恰好发现阻塞队列是空的那怎么办呢等待啊去哪里等呢就去条件变量对应的**等待队列**里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血于是给你开了个验血的单子你呢就去验血的队伍里排队。线程T1进入条件变量的等待队列后是允许其他线程进入管程的。这和你去验血的时候医生可以给其他患者诊治道理都是一样的。
再假设之后另外一个线程T2执行阻塞队列的入队操作入队操作执行成功之后**“阻塞队列不空”**这个条件对于线程T1来说已经满足了此时线程T2要通知T1告诉它需要的条件已经满足了。当线程T1得到通知后会从**等待队列**里面出来,但是出来之后不是马上执行,而是重新进入到**入口等待队列**里面。这个过程类似你验血完,回来找大夫,需要重新分诊。
条件变量及其等待队列我们讲清楚了下面再说说wait()、notify()、notifyAll()这三个操作。前面提到线程T1发现“阻塞队列不空”这个条件不满足需要进到对应的**等待队列**里等待。这个过程就是通过调用wait()来实现的。如果我们用对象A代表“阻塞队列不空”这个条件那么线程T1需要调用A.wait()。同理当“阻塞队列不空”这个条件满足时线程T2需要调用A.notify()来通知A等待队列中的一个线程此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
这里我还是来一段代码再次说明一下吧。下面的代码用管程实现了一个线程安全的阻塞队列(再次强调:这个阻塞队列和管程内部的等待队列没关系,示例代码只是用管程来实现阻塞队列,而不是解释管程内部等待队列的实现原理)。阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
1. 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了`notFull.await();`。
2. 对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了`notEmpty.await();`。
3. 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空`notEmpty`对应的等待队列。
4. 如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满`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.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
```
在这段示例代码中我们用了Java并发包里面的Lock和Condition如果你看着吃力也没关系后面我们还会详细介绍这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是**await()和前面我们提到的wait()语义是一样的signal()和前面我们提到的notify()语义是一样的**。
## wait()的正确姿势
但是有一点需要再次提醒对于MESA管程来说有一个编程范式就是需要在一个while循环里面调用wait()。**这个是MESA管程特有的**。
```
while(条件不满足) {
wait();
}
```
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后如何通知相关线程。管程要求同一时刻只允许一个线程执行那当线程T2的操作使线程T1等待的条件满足时T1和T2究竟谁可以执行呢
1. Hasen模型里面要求notify()放在代码的最后这样T2通知完T1后T2就结束了然后T1再执行这样就能保证同一时刻只有一个线程执行。
2. Hoare模型里面T2通知完T1后T2阻塞T1马上执行等T1执行完再唤醒T2也能保证同一时刻只有一个线程执行。但是相比Hasen模型T2多了一次阻塞唤醒操作。
3. MESA管程里面T2通知完T1后T2还是会接着执行T1并不立即执行仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是notify()不用放到代码的最后T2也没有多余的阻塞唤醒操作。但是也有个副作用就是当T1再次执行的时候可能曾经满足的条件现在已经不满足了所以需要以循环方式检验条件变量。
## notify()何时可以使用
还有一个需要注意的地方就是notify()和notifyAll()的使用,前面章节,我曾经介绍过,**除非经过深思熟虑否则尽量使用notifyAll()**。那什么时候可以使用notify()呢?需要满足以下三个条件:
1. 所有等待线程拥有相同的等待条件;
2. 所有等待线程被唤醒后,执行相同的操作;
3. 只需要唤醒一个线程。
比如上面阻塞队列的例子中对于“阻塞队列不满”这个条件变量其等待线程都是在等待“阻塞队列不满”这个条件反映在代码里就是下面这3行代码。对所有等待线程来说都是执行这3行代码**重点是 while 里面的等待条件是完全相同的**。
```
while (阻塞队列已满){
// 等待队列不满
notFull.await();
}
```
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
```
// 省略入队操作...
// 入队后,通知可出队
notEmpty.signal();
```
同时也满足第3条只需要唤醒一个线程。所以上面阻塞队列的代码使用signal()是可以的。
## 总结
管程是一个解决并发问题的模型,你可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
Java参考了MESA模型语言内置的管程synchronized对MESA模型进行了精简。MESA模型中条件变量可以有多个Java语言内置的管程里只有一个条件变量。具体如下图所示。
![](https://static001.geekbang.org/resource/image/57/fa/57e4d94e90226b70be3d57024f5333fa.png "Java中的管程示意图")
Java内置的管程方案synchronized使用简单synchronized关键字修饰的代码块在编译期会自动生成相关加锁和解锁的代码但是仅支持一个条件变量而Java SDK并发包实现的管程支持多个条件变量不过并发包里的锁需要开发人员自己进行加锁和解锁操作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
## 课后思考
wait()方法在Hasen模型和Hoare模型里面都是没有参数的而在MESA模型里面增加了超时参数你觉得这个参数有必要吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。