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

目录

1. 什么是管程

2. 管程模型

2.1 解决互斥问题

2.2 解决线程间的同步问题

2.3 代码实现

3. wait() 的正确姿势

4. notify() 何时可以使用

5. 总结

6. 思考


管程是一种可以很方便解决并发问题的核心技术,Java 语言在 1.5 之前,提供的唯一的并发原语就是管程,而且 1.5 之后提供的 SDK并发包,也是以管程技术为基础的。可以说,管程就是一把解决并发问题的万能钥匙。

 

1. 什么是管程

不知道你是否曾思考过这个问题:为什么 Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个看似从天而降的方法?在刚接触 Java 的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以 Java 选择了管程。

所谓管程,指的是管理共享变量以及对共享变量操作的过程,让它们支持并发。翻译为 Java 语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。管程对应的英文名是 Monitor。那管程是怎么管的呢?

 

2. 管程模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen(哈森)模型、Hoare(霍尔)模型和MESA(梅萨)模型。其中,现在广泛应用的是 MESA模型,并且 Java 管程的实现参考的也就是 MESA 模型。

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程间如何通信、协作。这两大问题,管程都是能够解决的。

 

2.1 解决互斥问题

管程解决互斥问题的思路很简单,就是将共享变量和对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现, enq()、deq() 保证互斥性,只允许一个线程进入管程,你会发现,管程模型和面向对象高度契合,估计这就是 Java 选择管程的原因,而前面介绍的互斥锁的用法,其背后的模型其实就是它。

07-管程:并发编程的万能钥匙_第1张图片

2.2 解决线程间的同步问题

解决线程间的同步问题,比较复杂,但是可以借鉴一下前面的就医流程,为了进一步理解,下面展示 MESA 管程模型的示意图,它详细介绍了 MESA 模型的主要组成部分。

 

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

 

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

07-管程:并发编程的万能钥匙_第2张图片

那条件变量和条件队列的作用是什么呢?其实就是解决线程同步问题的。你也可以结合上面提到的入队出队的例子加深一下理解。

假设有个线程 T1 执行出队操作,不过出队操作有个前提,就是队列不能为空,而队列为空这个前提条件就是管程里的条件变量。如果线程 T1 进入管程里发现队列是空的,就去条件变量对应的等待队列中等待。

在假设之后另一个线程 T2 执行入队操作,入队成功后,队列不为空条件对线程 T1 来说以及满足了,此时线程 T2 要通知 T1,当线程 T1 得到通知后,会从等待队列中出来,但是出来后不是马上执行,而且从新进入入口等待队列,这个过程类似于你化验完了,回来找大夫,需要从新排队分诊。

条件变量和等待队列清楚了,下面说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现队列为空不满足时,需要进入等待队列里等待,这个过程就是通过调用 wait() 方法来实现的,同理当队列不空条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个。

 

2.3 代码实现

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

public class BlockedQueue{
    final Lock lock = new ReentrantLock();
    
    // 条件变量:队列不满
    final Condition notFull = lock.newCondition();
    
    // 条件变量:队列不空
    final Condition notEmpty = lock.newCondition();

    // 入队
    void enq(T x) throws Exception {
        lock.lock();
        try {
            while (队列已满){
                // 等待队列不满
                notFull.await();
            }
            // 省略入队操作...
            // 入队后, 通知可出队
            notEmpty.signal();
        }finally {
            lock.unlock();
        }
    }
    
    // 出队
    void deq() throws Exception {
        lock.lock();
        try {
            while (队列已空){
                // 等待队列不空
                notEmpty.await();
            }
            // 省略出队操作...
            // 出队后,通知可入队
            notFull.signal();
        }finally {
            lock.unlock();
        }
    }
}

 

3. 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 再次执行的时候,可以曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

 

《Effective Java》第二版中文版第69条244页位置对这一点说了一页,我看着一知半解。我能理解的一点是:对于从wait中被notify的进程来说,它在被notify之后还需要重新检查是否符合执行条件,如果不符合,就必须再次被wait,如果符合才能往下执行。所以:wait方法应该使用循环模式来调用。按照上面的生产者和消费者问题来说:错误情况一:如果有两个生产者A和B,一个消费者C。当存储空间满了之后,生产者A和B都被wait,进入等待唤醒队列。当消费者C取走了一个数据后,如果调用了notifyAll(),注意,此处是调用notifyAll(),则生产者线程A和B都将被唤醒,如果此时A和B中的wait不在while循环中而是在if中,则A和B就不会再次判断是否符合执行条件,都将直接执行wait()之后的程序,那么如果A放入了一个数据至存储空间,则此时存储空间已经满了;但是B还是会继续往存储空间里放数据,错误便产生了。错误情况二:如果有两个生产者A和B,一个消费者C。当存储空间满了之后,生产者A和B都被wait,进入等待唤醒队列。当消费者C取走了一个数据后,如果调用了notify(),则A和B中的一个将被唤醒,假设A被唤醒,则A向存储空间放入了一个数据,至此空间就满了。A执行了notify()之后,如果唤醒了B,那么B不会再次判断是否符合执行条件,将直接执行wait()之后的程序,这样就导致向已经满了数据存储区中再次放入数据。错误产生。

 

4. notify() 何时可以使用

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

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

 

5. 总结

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

07-管程:并发编程的万能钥匙_第3张图片

 

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

 

6. 思考

wait() 方法,在Hasen(哈森)模型、Hoare(霍尔)模型里面,都是没有参数的,而在MESA(梅萨)模型里面,增加了超时参数,你觉得这个参数有必要么?

wait() 不加超时参数,相当于得一直等着别人叫你去门口排队,加了超时参数,相当于等一段时间,再没人叫的话,我就受不了自己去门口排队了,这样就诊的机会会大一点,避免没人叫的时候傻等。

 

你可能感兴趣的:(并发编程,多线程,并发编程,管程,锁)