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

节选自 王宝令-Java变法编程实战,欢迎大家订阅~

什么是管程

不知道你是否曾经思考过这个问题:为什么 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 选择管程的原因吧。

管程模型的代码化语义

那管程是如何解决线程间的同步问题呢?

这个就比较复杂了,不过你可以借鉴一下我们曾经提到过的就已流程,它可以帮你快速地理解这个问题。为进一步便于你理解,在下面👇,我展示了一副 MESA 的管程模型示意图,它详细的描述了 MESA 模型的主要组成部分。

MESA管程模型

在管程模型里,共享变量和对共享变量的操作是被封装起来的,👆图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者在门口等待。

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,条件变量 A 和条件变量 B 分别都有自己的等待队列。条件变量和等待队列的作用其实就是解决线程同步问题。你也可以结合上面提到的入队出队例子加深一下理解。

假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不为空这个前提条件就是管程的条件变量。如果线程 T1进入管程后恰好发现队列是空的,那就只能去“队列不为空”这个条件变量队列里等待了。这个过程类似大夫给你开了单子让你去验血,你就去验血窗口排队去了。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。这个你去验血的时候,医生可以给其他患者诊治,道理是一样的。

再假设之后另外一个线程 T2执行入队操作,入队操作执行成功之后,“队列不为空”这个条件对于线程 T1来说已经满足了,此时线程 T2要通知T1,告诉它需要的条件已经满足了。当线程 T1得到通知后,会从等待队列里面出来,但是出来后不是马上执行,而是重新进入到入口等待队列里面。这个过程类似你验血回来找大夫,需要重新分诊排队。

条件变量及其等待队列我们讲清楚了,下面再说说 wait()notigy()notifyAll()这三个操作。前面提到线程 T1发现“队列不为空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。如果我们用对象 A 代表“队列不为空”这个条件,那么线程 T1需要调用 A.wait() 。同理当“队列不为空”这个条件满足时,线程 T2需要调用 A.notify()来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notigAll()这个方法,它可以通知等待队列中的所有线程。

这里我们还是来一段代码再次说明一下吧。下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先互相获取互斥锁,类比管程模型中的入口。

  1. 对于入队操作,如果队列已满,就需要等待至队列不满,所以这里用了notFull.await();
  2. 对于出队操作,如果队列为空,就需要等待至队列不为空,所以就用了notEmpty.await();
  3. 如果入队成功,所以队列就不为空了,就需要通知条件变量:队列不为空 notEmpty 对应的等待队列。
  4. 如果出队成功,那队列就不满了,就需要通知条件变量:队列不满 notFull 对应的等待队列。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class BlockQueue<T>{
final Lock lock = new ReentrantLock();
// 条件变量:notFull
final Condition notFull = lock.newCondition();
// 条件变量:notEmpth
final Condition notEmpty = lock.newCondition();

// 入队
void enq(T x){
lock.lock();
try{
while(队列已满){
// 等待队列不满
notFull.await();
}
// do 入队操作
// 入队后,通知可出队
notEmpty.signal();
}fianlly{
lock.unlock();
}
}

// 出队
void deq(){
lock.lock();
try{
while(队列已空){
// 等待队列不空
notEmpty.await();
}
// do 出队操作
// 出队后,通知可入队
notFull.signal();
}finally{
lock.unlock();
}
}
}

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

wait() 的正确姿势

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

1
2
3
while(条件不满足){
wait();
}
  1. Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行。这样就能保证同一时刻只有一个线程执行。
  2. Hoare 模型里面,T2 通知完 T1 后,T2 阻塞, T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hesen 模型,T2 多了一个阻塞唤醒操作。
  3. MESA 管程里面,T2 通知完 T1 后, T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进入到入口等待队列里面。这样做的好处是 notify()不用放在代码最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环的方式检验条件变量。

notify() 何时可以使用

还有一个需要注意的地方,就是 notify()notifyAll() 的使用,前面章节,我曾经介绍过,**除非经过深思熟虑,否则尽量使用 notifyAll()**。那么什么时候可以使用 notify() 呢?需要满足以下三个条件:

  1. 所有等待线程拥有相同的等待条件;
  2. 所有等待线程被唤醒后,执行相同的操作;
  3. 只需要唤醒一个线程。

比如上面阻塞队列的例子中,对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,反映在代码里就是下面👇三行代码。对所有等待线程来说,都是执行这三行代码,重点是 while 里面的等待条件是完全相同的。

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

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

1
2
3
// 省略入队操作...
// 入队后,通知可出队
notEmpty.signal();

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

总结

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。具体如下图👇所示。

Java 中的管程示意图

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

并发编程里两大核心问题—互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题都可以解决,并且很多并发工具类的底层都是管程实现的,所以学好管程,就相当于掌握了一把并发编程的钥匙。

课后思考

  1. wait()方法,在 Hasen 模型和 Hoare 模型中都是没有参数的,而在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要么?

    有。Hasen 模型是执行完,再去唤醒另外一个线程,能保证线程的执行。Hoare 模型是中断当前线程,唤醒另外一个线程,执行完再去唤醒,也能够保证执行。而 MESA 是进入条件等待队列,不一定有机会能够执行。

  2. 针对条件变量的 while 循环,还是不太理解,您说是范式,那它一定是为了解决特定的场景而强烈推荐的,也有评论说是为了解决虚假唤醒,但唤醒后,不也是从条件的等待队列进入到入口的等待队列,抢到锁后,重新进行条件变量的判断,用 if 完全可以啊,为什么必须是 while,并且是范式?

    1
    2
    3
    4
    5
    code1;
    if(条件不满足){
    wait();
    }
    code2;

    以上👆代码中,当调用 wait()时,阻塞。被唤醒时,就直接执行 code2 了,没机会重新判断。

    使用 while 范式,如果 A 线程 wait 了,而且未设置超时时间。由于代码 bug,没有其他线程 notify ,就会导致 A 线程一直 wait。增加超时时间后,A 线程可以自己决定是否继续等待。这样代码的健壮性会更好。

    简单来说,一个锁实际上对应两个队列,,一个就绪队列,对应入口等待队列;一个是阻塞队列,实际对应条件变量等待队列。wait 操作是把当前线程放入条件变量的等待队列中,而 notifyAll 是将条件变量等待队列中的所有线程唤醒到就绪队列(入口等待队列)中,实际上哪个线程执行由 JVM 操作。

  3. 对信号量和管程的理解

    // TODO 信号量

    管程是定义了一个数据结构和能为并发执行的一组操作,这组操作能够进行同步和改变管程中的数据。这相当于对临界资源的同步操作都集中进行管理,凡是要访问临界资源的进程或线程,都必须先通过管程,由管程的这套机制来实现多进程或线程对同一临界资源的互斥和使用。管程的同步主要通过 condition 类型的变量(条件变量),条件变量可执行 await()signal() 操作。管程一般是有语言编译器进行封装,体现在 OOP 中的封装思想,也如老师所说的,管程和面向对象高度契合。

  4. 文中说到的条件变量,假如 synchronized(instance){// do something;},这样一段代码,程序实际运行过程中条件变量是什么呢?

    没用到条件变量,只有调用 waitnotify 的时候才会用到。