极客时间 ——《Java并发编程实战》 08 | 管程:并发编程的万能钥匙

2020-10-28   11 次阅读


什么是管程

Java 在 1.5 之前仅提供了 synchronized 关键字以及 waitnotifynotifyAll 这三个方法,而没有提供信号量这样的编程原语。

因为 Java 采用的是 管程技术,synchronized 关键字以及 watinotifynotifyAll 这三个方法都是管程的组成部分,而管程信号量等价的,可以互相实现。但是管程更容易使用,所以 Java 选择了管程。

管程对应的英文是 Monitor,也被翻译为 监视器,操作系统领域一般意义为管程。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。 翻译为 Java 领域的对应语言就是:管理类的成员变量成员方法,让这个类是线程安全的。

那么管程是如何实现的呢?

MESA 模型

管程在发展过程中出现过三种不同的模型:

  • Hasen 模型
  • Hoare 模型
  • MESA 模型

这里我想弄明白另外两种模型的区别,结果用中文搜索了一下,全他妈是把极客时间这篇文章当自己东西的,读书笔记就笔记,直接把别人东西当自己的真tmd不要脸,全是无效数据。

换英文搜了下,wiki真是牛逼,这里面把三种模型都介绍了。

英文

中文

现在广泛应用的是第三个 MESA 模型,并且 Java 中对于管程的实现参考的也是 MESA 模型,于是作者在这里重点介绍了这个模型。

并发领域有两个核心问题:

  • 互斥同一时刻只允许一个线程访问共享资源。
  • 同步:线程之间如何通信协作

这两个问题管程都可以解决。先是 互斥 问题:

管程解决互斥的思路很简单:将共享变量以及对共享变量的操作统一封装起来。

在下图中,管程 X 将 共享变量 queue 这个队列和对队列的相关操作 入队 enq、出队 deq 都封装起来了。

线程A 和 线程B 如果想访问共享变量 queue,只能通过调用管程提供的 enq 和 deq 方法来实现,这两个方法保证了互斥性,只允许一个线程进入管程 。

管程模型与面向对象的设计思想高度契合,这可能也是 Java 选择管程的原因,互斥锁的用法背后就是管程。

同步

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

在管程模型里,共享变量对共享变量的操作是被封装起来的,图中最外层框代表着封装的意思。

框上只有一个入口,入口旁边还有一个等待队列,当多个线程同时视图进入管程内部时,只允许一个线程进入,其他线程在入口等待队列中等待。 这个过程就类似之前的分诊。

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

条件变量等待队列的作用就是为了解决 线程同步 问题,以下是一个例子:

假设有 线程T1 执行出队操作,但是执行出队操作有个前提,就是队列不能为空的,队列不空这个前提条件就是管程里的 条件变量

如果 线程T1 进入管程后恰好发现队列是空的,那么只能去条件变量对应的等待队列中等待。

这个过程类似于大夫发现你需要验血,于是给你开了个验血的单子,你就去验血的队伍里排队。 线程T1 进入条件变量的 等待队列后,是允许其他线程进入管程的

这就类似于你去验血的时候医生是可以给其他患者治病的。

假设另一个 线程T2 执行入队操作,入队操作执行成功之后,队列不空 这个条件对于 T1 已经满足了,此时 线程T2 需要通知 T1,告诉它需要的条件已经满足,当线程T1 得到通知后就会从 等待队列中出来,但是不是立即执行,而是重新进入 入口 的 等待队列中,这类似于做完检查需要重新分诊叫号,等待进入诊室。

条件变量以及等待队列的作用说清楚了,下面是 waitnotifynotifyAll 这三个操作的作用。

进入等待队列需要使用 wait 方法,前面说的 线程T1 发现队列不空这个条件不满足,则进入对应的等待队列,这个过程就是通过调用 wait 方法实现。、

同理当队列不空条件满足后,线程T2需要调用 A.notify 来通知 A 等待队列中的一个线程,此时这个队列中只有 线程T1,而 notifyAll 这个方法则会通知队列中的所有线程

下面是另一段说明代码:实现了一个阻塞队列,队列有两个操作分别是入队出队,这两个操作都需要先获取,类比管程模型中的入口

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();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }


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

  1. 对于入队操作,如果队列已满,则需要等待,直到队列不满,所以这里使用了 notFull.awit();
  2. 对于出队操作,如果队列为空,就需要等待直到队列不空,这里用了 notEmpty.awit();
  3. 如果入队成功,则队列不空,通知条件变量:队列不空 notEmpty 对应的等待队列
  4. 如果出队成功,则队列不满,通知条件变量:队列不空 notFull 对应的等待队列

在这段代码中,使用了 JDK 并发包中的 LockCondition,虽然看着陌生,但是其 awit 的语义 和 wait 一样,signalnotify 语义一样。

wait 的正确姿势

MESA 管程有一个编程范式:在 while 循环里调用 wait,这是 MESA 管程特有的。

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

Hasen 模型、Hoare 模型 和 MESA 模型的**核心区别**就是:条件满足后,如何通知相关线程。

管程要求同一时刻只允许一个线程执行,当 线程T2 的操作使 线程T1 等待条件满足时, T1T2 谁可以执行呢?

  1. Hasen :要求 notify 放在代码的最后,这样 T2 通知完 T1 后,T2结束了,然后 T1 再执行,这样保证了同一时刻只有一个线程执行。
  2. HoareT2 通知完 T1 后,T2 阻塞T1马上执行,等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行,但是 相比 Hasen模型,T2 多了一次唤醒操作
  3. MESAT2 通知完 T1后,T2接着执行T1并不立即执行,仅仅是从条件变量等待队列进入到入口等待队列。 这样的好处是 notify 不用放到代码的最后,T2 也不需要做多余的阻塞唤醒操作。但是也存在一个副作用,当 T1 再次执行时,之前满足的条件可能已经不满足了,所以需要循环检验条件变量

notify 何时使用

之前曾经介绍过,除非经过深思熟练,否则尽量使用 notifyAll,如果要使用 notify,需要满足以下三个条件

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

上面阻塞队列例子中,对于队列不满这个条件变量,其阻塞队列中的线程都是在等待队列不满这个条件。反应在下面这三行代码:

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

对于所有等待线程来说,都是执行这三行代码。重点是 while 里的等待条件完全相同

而线程被唤醒后执行的操作也相同:

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

也满足了第三个使用 notify 的条件:只需要唤醒一个线程。所以上面例子中使用的是与 notify 相同语义的 signal 方法。

总结:

Java 参考 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简

MESA 模型中,条件变量可以有多个Java 语言内置的管程条件变量只有一个。如下图所示:

Java 内置的管程方案使用简单, synchronized 关键字修饰代码块,在编译期自动生成成对的加锁和解锁代码,但是仅支持一个条件变量

JDK 并发包中实现的管程支持多个条件变量,但是并发包中的锁需要开发人员自己手动加锁解锁

并发编程中的两大核心问题:互斥和同步都可以由管程解决,并且很多工具类底层都是管程实现的,所以学好管程很关键。

个人总结:

这篇文章既拓展了知识面,让我明白了 Javasynchronized 背后的实现逻辑,也顺便知道了不同的管程模型。

同时图文并茂,又举了俩例子,让人更加容易理解管程是什么。

比如我之前就对 waitnotify 的使用总是不太理解,看完 7,8两篇文章,写了这些对应的代码,可以说基本掌握了。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。