从管程角度理解AQS

1,040 阅读9分钟

通过一个例子走进管程的世界

冰墩墩的生产消费问题

22年春节期间,北京冬奥会举办得特别成功,吉祥物冰墩墩让大家“一户一墩”成了困难,现在有4个消费者同时抢冰墩墩的时候冰墩墩没货,过一会儿,冰墩墩分为两批生产,每次生产两个冰墩墩,如下图所示 image.png

示例代码

image (1).png

走进管程世界

上面的冰墩墩生产消费例子,用到了并发中Lock和Condition,它里面的核心思想是MESA管程来保证线程的互斥同步,下面我们来聊聊,Java中解决并发问题的钥匙——管程

什么是管程?

管程指的是管理共享变量以及对共享变量的的操作过程,让它们线程安全的支持并发。

管程如何实现互斥?

管程模型中,将共享变量和对共享变量的操作封装,访问共享变量必须通过管程提供的方法来实现,方法保证了互斥性,只允许一个线程进入管程,与面向对象思想契合。

管程如何实现同步?

  • 管程模型是封装的,多个线程试图进入管程,只允许一个线程进入,其它线程在入口等待队列中等待。
  • 管程里引入了条件变量的概念,每个条件变量都有一个条件变量等待队列

MESA管程的运行机制

image (2).png

  • 共享变量:多个线程同时操作同一个对象的共享变量,这个共享变量的操作,**管程就是来保证这个共享变量操作是线程安全的。**如上面例子中BingDwenDwen中的amount。
  • 等待队列:互斥的保证,在Java语言管程的实现中,操作共享变量,需要先进入管程,进入管程实现的方式不同,比如AQS中,可重入锁进入管程代表的是state被当前线程设置为1,如果进入管程失败需要入到AQS的CLH队列中。这个CLH队列就是等待队列。
  • 条件变量:我们使用lock创建的condition对象,它结合条件队列与等待队列来实现同步
  • 条件队列:当进入管程内部的线程不满足竞态条件时,需要放弃时间片,调用条件变量的await进入到条件队列中,处于阻塞状态

注:图中只列举了一个条件队列,其实这块可以有多个条件队列,本文解析AQS原理的时候,也会只使用一个条件队列。

根据示例代码理解管程的运行机制

通过冰墩墩的示例代码来理解管程的运行机制,按程序运行的结果(这只是一种结果),拆解运行机制大体分为三个阶段:1.T1、T2、T3、T4全部没抢到冰墩墩、2.PT1生产两个冰墩墩,T1、T2抢到冰墩墩、3.PT2生产两个冰墩墩,T3、T4抢到冰墩墩

1. T1、T2、T3、T4全部没抢到冰墩墩

首先T1获取到锁,进入管程内部,想要对共享变量amount进行操作,但是被竞态条件while(amount==0)校验住,调用了条件变量的await方法释放时间片,进入到条件队列中处于阻塞状态 image (3).png 同理,T2、T3、T4全部进入到条件队列中阻塞,等待唤醒,这个时候入口的等待队列已经为空,没有线程再去争用锁 image (4).png

2. PT1生产两个冰墩墩,T1、T2抢到冰墩墩

PT1生产者线程进入到管程中,对共享变量amount进行加2操作,并且唤醒所有处在条件队列中的线程 T1、T2、T3、T4被唤醒,T1、T2先后重新进入管程(并不是同时进入,使用的方式是重新转移到等待队列,等待被唤醒)执行while(amount == 0)成功,获得到了冰墩墩,T3、T4再次await进入到条件队列中,等待下次被唤醒 image (6).png

  • PT1生产者线程通过signalAll唤醒T1、T2、T3、T4线程,转移到等待队列中,PT1 unlock释放锁 T1、T2、T3、T4争用锁后重新执行之前被阻塞的逻辑(最关键的一步image (7).png
  • T3、T4再次await进入到条件队列中,等待下次被唤醒 image (8).png 注:MESA管程需要while判断竞态条件,根据这个例子可以看出如果四个线程被唤醒向下执行,那么amount很可能T1、T2时候变为0了,T3、T4没有重新判断导致超卖
3. PT2生产两个冰墩墩,T3、T4抢到冰墩墩

PT2生产者线程进入到管程中,对共享变量amount进行加2操作,并且唤醒所有处在条件队列中的线程 image (9).png T3、T4被唤醒,T3、T4执行while(amount == 0)成功,获得到了冰墩墩 image (10).png

通过ReentrantLock走进AQS的世界

通过上面的例子,我们对通过MESA管程的运行机制和思想来理解冰墩墩的示例,那么在Java并发包中,Doug Lea使用这个思想实现了所有类型锁的核心AQS,接下通过ReentrantLock的具体实现,走进AQS的世界。注:本文讲的例子是AQS独占模式非公平锁,非共享模式公平锁

AQS中的相关核心ADT介绍

image (11).png

  • AQS是个抽象模板类,ReentrantLock中Sync抽象类继承AQS,NonfairSync与FairSync实现抽象Sync类,示例代码中Lock lock = new ReentrantLock(),sync成员变量就是NonfairSync对象 image (12).png
  • Node类是AQS和Condition的队列节点类,是核心ADT,CLH同步队列就是管程模型中的等待队列,条件队列顾名思义则是管程模型中的条件队列,如下图所示 image (13).png image (14).png
  • condition对象通过lock.newCondition()创建的ConditionObject类型对象,那么它如何与lock关联上的呢?通过lock创建CondtionObject对象,其实CondtionObject是通过lock的成员变量sync创建的,上面已经说过了sync的类型NonfairSync是AQS的抽象实现类Sync的抽象实现类,所以CondtionObject对象通过内部类的方式可以间接使用AQS的CLH队列数据,这样就可以实现,自己的条件队列像CLH队列转移数据 image (15).png

通过示例理解AQS

还是按照管程的冰墩墩示例,来详细解读AQS的原理

T1、T2、T3、T4全部没抢到冰墩墩

  • T1 lock.lock()成功(并不是一定是该情况,只是举例一种情况),此时amount=0,所以也没抢到

    image (27).png lock.lock()通过自己的成员变量sync调用NonfairSync的lock方法,首先进行CAS state = 1操作,如果设置成功代表获取锁成功,把当前线程设置到AQS中 image (26).png

  • T2、T3、T4失败进入CLH等待队列,AQS acquire(int arg)方法的出现(非常重要的方法,也是不好理解的方法image (25).png AQS acquire方法主要流程:

    • 获取同步状态:调用的是sync#tryAcquire的具体实现,也就是尝试CAS state = 1
    • 入队操作:addWaiter的具体逻辑,向队列的中添加节点
    • 申请入队操作:队列节点创建完成会,如果前驱节点是头结点,会再次尝试CAS state = 1,再次失败会进入是否线程进入等待状态(Lock.park(thread)) 申请入队操作有点复杂,下面是流程:
    final boolean acquireQueued(final Node node, int arg) {
      // 标记是否成功拿到资源
      boolean failed = true;
      try {
        // 标记等待过程中是否中断过
        boolean interrupted = false;
        // 开始自旋,要么获取锁,要么中断
        for (;;) {
          // 获取当前节点的前驱节点
          final Node p = node.predecessor();
          // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
          if (p == head && tryAcquire(arg)) {
            // 获取锁成功,头指针移动到当前node
            setHead(node);
            p.next = null; // help GC
            failed = false;
            return interrupted;
          }
          // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析
          if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
            interrupted = true;
        }
      } finally {
        if (failed)
          cancelAcquire(node);
      }
    }
    // 靠前驱节点判断当前线程是否应该被阻塞
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
      // 获取头结点的节点状态
      int ws = pred.waitStatus;
      // 说明头结点处于唤醒状态
      if (ws == Node.SIGNAL)
        return true; 
      // 通过枚举值我们知道waitStatus>0是取消状态
      if (ws > 0) {
        do {
          // 循环向前查找取消节点,把取消节点从队列中剔除
          node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
      } else {
        // 设置前任节点等待状态为SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
      }
      return false;
    }
    

    image (24).png waitStatus: image (23).png 以上分析了AQS#acquire详细的流程,回到示例中,T2、T3、T4进入到阻塞队列 image (22).png

  • T1执行示例代码中的condition.await();

    • 创建条件节点,如果队列不存在的时候初始化队列,如果存在则把当前节点加入到条件队列中(addConditionWaiter())
    • 释放当前线程占有的锁,通过fullyRelease(node)间接执行AQS release操作
    • 挂起当前线程(LockSupport.park(this)) T1释放了锁,T2、T3、T4同理的逻辑,也都会执行condition.await(),并且都进入到条件队列中 image (21).png

PT1生产两个冰墩墩,T1、T2抢到冰墩墩

随着消费者T1、T2、T3、T4都await,AQS state=0,生产者PT1进入管程内,开始执行生产冰墩墩 image (20).png PT1执行完amount+2操作,生产了两个冰墩墩后,执行condition.signalAll(非阻塞),直接执行lock.unlock操作,释放了锁,下面来看下condition.signalAll与lock.unlock的实现

  • PT1生产者线程执行condition.signalAll,将条件队列中的T1、T2、T3、T4全部转移到CLH队列中
    • 将条件队列中的节点转移到同步队列中,涉及到间接访问AQS CLH队列头结点(上面已经说过如何间接访问),来完成转移
    • 转移到同步队列中的节点waitStatus设置为-1 image (19).png
  • PT1生产者线程执行lock.unlock操作
    • 先释放锁;
    • 通过head找到节点找到后继节点,然后唤醒节点的线程;
      image (18).png 当前CLH队列的head后继节点是T1线程,所以PT1释放锁后,持有锁最有可能是T1(有可能这个时候T5进来了,T1就有可能没有CAS state = 1),同理T2、T3、T4都会依次被唤醒,但是T3、T4因为被唤醒后,while (amount == 0)时失败,重新进入到条件队列,T1、T2抢到冰墩墩。 image (17).png

PT2生产两个冰墩墩,T3、T4抢到冰墩墩

PT2生产者生产冰墩墩过程,T3、T4被重新唤醒的过程和上一步完全类似,重复上面的过程最后得到如下结果 image (16).png

总结

管程是操作系统中的概念,可以看下下面北京大学的操作系统关于MESA管程的课程,管程是解决并发的万能钥匙,Java中无论是synchronized还是Lock(基于AQS)都是对MESA管程做了微小的改造,利用语言本身的特性来实现“锁”的功能。

参考资料