AQS共享模式

356 阅读7分钟

AQS共享模式和独占模式的流程类似,这里不赘述共享模式下的获取锁、释放锁的流程,只说明一下AQS在共享模式下都遇到了哪些问题,又是如何解决的。

共享模式遇到的挑战

先看下共享模式获取锁和释放锁的代码:

/**
 * Release action for shared mode -- signals successor and ensures
 * propagation. (Note: For exclusive mode, release just amounts
 * to calling unparkSuccessor of head if it needs signal.)
 */
private void doReleaseShared() {
    /*
     * Ensure that a release propagates, even if there are other
     * in-progress acquires/releases.  This proceeds in the usual
     * way of trying to unparkSuccessor of head if it needs
     * signal. But if it does not, status is set to PROPAGATE to
     * ensure that upon release, propagation continues.
     * Additionally, we must loop in case a new node is added
     * while we are doing this. Also, unlike other uses of
     * unparkSuccessor, we need to know if CAS to reset status
     * fails, if so rechecking.
     */
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

/**
 * Sets head of queue, and checks if successor may be waiting
 * in shared mode, if so propagating if either propagate > 0 or
 * PROPAGATE status was set.
 *
 * @param node the node
 * @param propagate the return value from a tryAcquireShared
 */
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    /*
     * Try to signal next queued node if:
     *   Propagation was indicated by caller,
     *     or was recorded (as h.waitStatus either before
     *     or after setHead) by a previous operation
     *     (note: this uses sign-check of waitStatus because
     *      PROPAGATE status may transition to SIGNAL.)
     * and
     *   The next node is waiting in shared mode,
     *     or we don't know, because it appears null
     *
     * The conservatism in both of these checks may cause
     * unnecessary wake-ups, but only when there are multiple
     * racing acquires/releases, so most need signals now or soon
     * anyway.
     */
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

从代码上看,相比独占模式,共享模式获取、释放锁的代码变复杂了。独占模式和共享模式的区别在于,独占模式表示,同一时间只能由一个线程获取到锁,也只能获取到锁的线程来释放锁。而共享模式表示,同一时间可以有多个线程获取到锁,也可以有多个线程去释放锁。因为可以有多个线程去获取和释放锁,所以在处理acquire和share的时候,共享模式对并发时的细节处理也相对复杂。

先假设不处理上述的并发问题,按照独占模式的思路去写共享模式,那么上述较复杂的两段的代码setHeadAndPropagate和doReleaseShared方法,应该写成:

private void setHeadAndPropagate(Node node, int propagate) {
		// 获取到锁,出队
  	setHead(node);
	  // 共享模式,如果还有共享资源,那么需要继续唤醒后续的结点
  	// 这需要建立在结点的状态是SIGNAL状态,表示后续的结点需要唤醒
    if (propagate > 0 && node.waitStatus != 0) {
        Node s = node.next;
      	// 后续的结点是共享结点才需要被唤醒,独占模式的结点不需要被唤醒
      	// 这里加非空判断的目的,是为了防止s.isShared()的时候出现空指针,
	      // 那既然是后继结点是null了,为什么还要继续唤醒后继结点呢?这么做是为了防止极端情况下,在执行到第7行时,s=node.next已经赋值完了,s为null,但是这个时候刚好有共享结点接到队尾,这个时候就会出现虽然共享资源没有耗尽,但是却无法唤醒这个结点的问题
        if (s == null || s.isShared())
            doReleaseShared();
}
  
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

这样写会有什么问题呢?

从独占模式的流程分析里我们知道,头结点的出队是在acquire中完成的,而且由于独占模式只允许同一时间有一个线程占有锁,因此出队操作没有并发问题。而现在在共享模式下,允许同一时间有多个线程占有锁,那么出队操作就会面临并发出队带来的问题。

那出队操作是在什么情况下引起的呢?头结点出队,是由releaseShared方法唤醒头结点的后继结点所代表的线程,然后通过将后继结点置为头结点,从而实现出队操作。也就是说,当出现并发调用releaseShared方法的时候,就有可能出现并发出队的问题。所以,在releaseShared时,头结点发生变更,就可能出现并发问题。

所以,针对这个情况,可以有下面的问题的归类:

image-20220123164715516.png 为了更好地描述后续的问题,假设队列中的结点如图:

image-20220121081056484.png

releaseShared在setHead之前

在unparkSuccessor方法里,出队的结点,当它的waitStatus小于0时,是会被赋值为0的。考虑下面的时序:

image-20220121081216625.png

那如何解决这个问题呢?

出现这个问题的时候,不是会将新头结点的waitStatus置为0嘛,那就让它在releaseShared的时候不变成0。当发现为0的时候,就将它置为一个小于0的值。waitStatus的状态值为负的只有3种,SIGNAL,CONDITION,PROPAGATE,设置为SIGNAL不合适,因为release的时候就是要将头结点的状态从SIGNAL变成0。CONDITION表示的是条件队列,也不符合这个场景。所以只能使用PROPAGATE这个状态,这也就是为什么waitStatus会多出一个PROPAGATE状态的原因。那原来的releaseShared方法就可以改成下面这样:

private void doReleaseShared() {
  Node h = head;
  if (h != null && h != tail) {
    int ws = h.waitStatus;
    if (ws == Node.SIGNAL) {
      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
        continue;            // loop to recheck cases
      unparkSuccessor(h);
    }
    else if (ws == 0) {
      compareAndSetWaitStatus(h, 0, Node.PROPAGATE); 
    }
  }
}

但是这样改了之后还不够,CAS只能设置一次,不一定能成功。考虑这样一种情况,当判断ws == 0为真后,在执行 compareAndSetWaitStatus(h, 0, Node.PROPAGATE) 之前,此时队列中进来一个结点,然后将它的前置结点置为了SIGNAL,接着再执行compareAndSetWaitStatus(h, 0, Node.PROPAGATE) ,这样CAS肯定会失败。头结点的状态还是没有变化。

因此为了保证它的状态能更新成功,这里要使用循环+CAS的方式,确保在releaseShared的时候,头结点的更新是成功的。那这里可以改成:

private void doReleaseShared() {
  for(;;) {
    Node h = head;
    if (h != null && h != tail) {
      int ws = h.waitStatus;
      if (ws == Node.SIGNAL) {
        if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
          continue;            // loop to recheck cases
        unparkSuccessor(h);
      }
      else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) {
        continue;
      }
    }
  }
}

那循环的终止条件是什么呢?回顾这么修改的原因,是由于在并发releaseShared的过程中,头结点发生了变更,后继结点成为了头结点,导致后续的结点无法被唤醒。那么也就是说,只要头结点没有变化,就不会有这个问题,现在头结点变了,才要用循环+CAS来改变它的状态。因此这里就容易推导出,当在循环的过程中,头结点都没变化,循环就可以结束,否则都要用这种方式来处理releaseShared过程中头结点不是原来的结点的情况。因此,releaseShared的代码就可以改为:

private void doReleaseShared() {
  for(;;) {
    Node h = head;
    if (h != null && h != tail) {
      int ws = h.waitStatus;
      if (ws == Node.SIGNAL) {
        if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
          continue;            // loop to recheck cases
        unparkSuccessor(h);
      }
      else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) {
        continue;
      }
    }
    if (h == head) {
      break;
    }
  }
}

至此,就能推导出,为什么doReleaseShared的写法会变得这么复杂,就是为了解决上述的这个问题。

releaseShared在setHead之后

考虑这样一种情况:

image-20220121132702038.png

doReleaseShared在setHead方法执行之后执行,那么此时在doReleaseShared记录的头结点就不是node1而是node2了。从上面的时刻表可以看到,如果按照独占模式那样的写法,也会出现无法唤醒后继结点的情况。

此外,判断propagate > 0 && head.waitStatus != 0的情况,也有问题。因为存在多线程releaseShared的情况,在执行到这个判断条件时,即使此时propagate不大于0,也可能会出现执行完这个判断之后,有线程调用了releaseShared导致同步状态值又大于0的情况。在这种情况也是应该唤醒后继线程的。

如何解决呢?

对于第一个问题,releaseShared只能从头结点开始定位,对于这种情况再从releaseShared开始尝试解决这个问题的话,就有点显得力不从心了,所以只能从acquireShared去想办法。如果按无并发release的情况,前任头结点node1的状态,应该由release方法去改变。但现在由于并发调用了release,让release方法错误地将头结点记录成了node2,也就是说,当出现了这个问题时,因为release方法已经无法对node1更新状态了,所以前任头结点node1的状态依然为SIGNAL或PROPAGATE,那么加上对它的状态值的判断,就可以规避这个问题。

对于第二个问题,propagate <= 0,但满足新旧两个头结点的状态值任意一个 < 0时,也应该唤醒后继线程,让它们去尝试获取锁。所以原来的 propagate > 0 && head.waitStatus != 0 的判断,应该改成 propagate > 0 || oldHead.waitStatus < 0 ||newHead.waitStatus < 0

综合上述两个问题,可以推导出acquireShared这个方法里,setHeadAndPropagate的写法就是为了解决上述两个问题而被改造成这样的。

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // 记录前任头结点,后续需要判断状态,为了解决第一个问题
    setHead(node);
  	// 判断条件从&&改成||,为了解决第2个问题
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

对于共享模式中遇到的这些坑,我们可以从这个链接下发现其中的端倪:bugs.openjdk.java.net/browse/JDK-…

总结时刻

虽然共享模式和独占模式的流程大致相同,但是基于它本身的特点,需要更多对并发细节的处理。在阅读共享模式的源码时,对releaseShared和setHeadAndPropagate的写法一度无法理解,直到搜索资料时,看到这个bug记录才开始豁然开朗。在分析的过程中,逐渐意识到,所谓的并发问题,都是因为变化而导致的。那么分析变化产生的原因,才能知道是什么并发场景才会引起问题。只要能运用MECE原则,穷尽所有引起变化的场景,就能分析清楚并发带来的问题都有哪些,找到了问题,自然也就能对症下药。

对源码的分析,加深了对AQS本身的理解,这对我们提升程序的鲁棒性固然重要,但更重要的是要从源码分析中体会到分析的思路,体会到对自己的哪些能力、或者哪些方面的效率有提升,这是源码分析带来的更深刻的影响。