并发编程-举足轻重的管程

306 阅读5分钟

前情回顾

上篇文章为你叙述了 Java 语言为了解决多线程并发问题,提出的方案,其中包括:

  • Happens-Before 规则
  • Volatile 关键字

着重讲了 volatile 的原理以及使用场景,单单靠上述方案,能够解决的并发问题实在有限,因此本文为你介绍并发编程界的『老大哥』,管程。

管程是什么?

很多小伙伴多多少少听说过管程这个词,但就是感觉它虚无缥缈的,说不清道不明,确实是这样,因为管程是一种思想,而这种思想超脱于语言之上,绝大多数语言实现并发编程都是依靠管程思想。

所谓管程,指的是管理共享数据以及对共享数据的操作过程,让它们支持并发,对应的英文单词是 Monitor。

根据实现的不同,衍生出了几种不同的管程模型:

  • Mesa 模型
  • Hasen 模型
  • Hoare 模型

今天主要介绍的是 Mesa 模型,Java 语言中并发编程就是依赖于这种模型实现的。

Mesa 模型

并发程序中两大核心问题:互斥与同步,即同一时间,只能有一个线程访问共享变量,以及线程之间如何通信与协作。我们来看一下管程是如何解决这两个问题的。

互斥问题

这个问题其实很容易,如何限制多线程访问共享变量,可以把共享变量封装起来,为访问变量,操作变量提供统一入口,并对这个入口进行加锁操作,这就好比足球场的检票入口一般,保证同一时间同一检票口只能通过一个人。

而 Java 中互斥锁的实现,其实就是依赖于 Mesa 模型。

同步问题

关于线程同步,大家可以想象一下医院就诊的过程,患者挂完号之后要进行分诊,一个医生同一时间只能接待一位患者,而其它患者需要在门外等待,这个场景下医生就像是共享变量,而门外的座位对应的就是管程中的入口等待队列,而等待的患者就是入口等待队列中的线程。

下面这幅图展现了管程模型的结构:

可以看到 Mesa 管程还引入了条件变量的概念,还是回到现实生活中就诊流程中。

假设分诊已经轮到你了,医生询问了一番之后,可能会叫你去验血,或者拍 CT,完成之后再回来进一步诊断,而这个验血,拍 CT 就可以看作是条件变量,每个条件变量都对应有一个条件变量等待队列,就好比现实生活中验血,拍 CT 排队等待的过程。

等一切检查都完成之后再回到之前就诊的科目处重新进行分诊,这个就对应管程中线程获取到条件变量后,重新回到入口等待队列,等待进入管程的机会。

管程实现

Java 语言内置的管程方案 synchronized 相信大家都不陌生,使用起来也方便,加锁释放锁都在底层隐式进行,但是它仅支持一个条件变量,就是 synchronized 锁住的那个对象。

而 Java 中另外一种管程实现——Lock 则更加灵活,支持多个条件变量,可以应对比较复杂的场景,例如生产者消费者模式

阻塞队列

Java 语言提供阻塞队列,就是通过生产者消费者模式实现的,而其中对并发的控制则是依赖于 Lock 实现,这边参照的是 ArrayBlockingQueue 源码,很典型的管程模型。

首先在成员变量列表中可以看到管程变量的定义,这边定义了两个条件变量,notEmpty,notFull

    /** Main lock guarding all access */
    final ReentrantLock lock;

    /** Condition for waiting takes */
    private final Condition notEmpty;

    /** Condition for waiting puts */
    private final Condition notFull;

阻塞队列进行 put 操作时,会检查是否满足入队操作,不满足则进行条件变量 notFull 的等待

    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }

成功入队之后需要唤醒等待条件变量 notEmpty 的线程,通知它们队列不为空

    private void enqueue(E x) {
        final Object[] items = this.items;
        items[putIndex] = x;
        if (++putIndex == items.length)
            putIndex = 0;
        count++;
        notEmpty.signal();
    }

阻塞队列进行 take 操作时,同样会检查是否满足出队条件,不满足则进行条件变量 notEmpty 的等待

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

成功出队之后需要环形等待条件变量 notFull 的线程,通知它们队列不满

    private E dequeue() {
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        E x = (E) items[takeIndex];
        items[takeIndex] = null;
        if (++takeIndex == items.length)
            takeIndex = 0;
        count--;
        if (itrs != null)
            itrs.elementDequeued();
        notFull.signal();
        return x;
    }

可以看到阻塞队列就是依赖于 Mesa 管程模型保证线程安全:

  • 生产者线程对阻塞队列操作前,先检查 lock 锁,获取到锁则进入管程
  • 检查是否满足操作(入队)条件,不满足则进入条件变量(notFull)的等待队列当中,并释放之前获取的管程锁
  • 另一方消费者线程操作(出队)完毕之后,通知 notFull 条件变量等待线程(生产者线程)

而以上被保护起来的区域,我们可以看做是管程监控管辖的区域,管程保证这块区域是线程安全的。

注意

在 Mesa 模型的管程实现中,可以看到我们使用 while 循环重复检查条件是否满足。

    try {
      while (count == 0)
        notEmpty.await();
      return dequeue();
    } finally {
      lock.unlock();
    }

因为该模型下,执行线程唤醒操作(singal)之后线程不会马上被唤醒,还要进入 monitor 入口队列中等待 monitor 锁,得到 monitor 锁之后才能被唤醒,因此从执行唤醒命令,到线程真正被唤醒,中间有一个时间间隔,很有可能条件已经不满足,需要重新检查。

总结

本文主要介绍了并发编程中的管程思想,并选择 Mesa 管程模型展开叙述,其中涉及:

  • Mesa 管程模型的结构
  • Java 语言内置管程实现
  • ArrayBlockingQueue 源码分析

相关知识

  • synchronized 内置锁原理

  • ReentrantLock 可重入锁

  • 生产者消费者模式