05_AQS-共享模式补充——java并发系列(五)

172 阅读9分钟

AQS共享模式

本文对上一章中,共享模式做一个补充。上一章提到,AQS最开始的版本,是没有PROPAGATE状态的,有BUG,本文解释一下,这个BUG是怎么回事儿,是如何通过PROPAGATE状态解决这个BUG的。 参考:

1. 获取共享锁

来看下共享版本的acquireQueued源码:

    /**
     * 调用子类的tryAcquireShared方法,失败了,调用doAcquireShared,尝试入队等待
     */
    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
    
    /**
     * 共享版本的acquireQueued
     */
    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);//模式为Node.SHARED(共享)
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        //重点区别,获取锁成功后,调用的是setHeadAndPropagate,而非setHead
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

简单过下代码,与独占模式的acquireQueued区别不多:

  • 入队列时,节点的状态为Node.SHARED模式,这点没什么好解释的。
  • 获取锁成功后,调用的是setHeadAndPropagate,而非setHead。这是核心区别,独占模式下,获取锁成功后,退出队列即可,因为不可能有其他线程,能够成功获取锁了;共享模式下不同,还可以,因此需要向后传播(Propagate)。

接下来,看下setHeadAndPropagate方法

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    // 注意这里都是或,也就是说,即使propagate<=0,
    // 只要head(不管是旧的还是最新的)的状态<0,就会进入下面代码块
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        // next = null,无法获取通过next获取下一节点,保守策略,执行doReleaseShared
        // 或者下个节点是共享模式,执行doReleaseShared
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

上面代码中,(propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0)这个条件判断,十分让人疑惑,注意这里都是或,也就是下列条件任意一个成立,就会尝试唤醒后续节点

  • propagate > 0,propagate大于0,也就是执行tryAcquireShared方法成功后还有剩余资源;
  • h == null || h.waitStatus < 0 ,在执行setHead方法前的head,状态小于0(我也不知道h==null是啥场景);
  • (h = head) == null || h.waitStatus < 0),在执行setHead方法后的head,状态小于0(由于可能存在并发,其实这个head也不一定是参数中的Node节点)。

propagate大于0,还有剩余资源,执行doReleaseShared唤醒队列中的线程,可以理解,为什么即使剩余资源等于0,在特定条件下,也会执行呢? 其实,在在1.6较早版本中,只有propagate大于0,才会尝试唤醒后继节点,是有缺陷的:

bug: bugs.openjdk.java.net/browse/JDK-… fix: github.com/openjdk/jdk… 在fix的链接中,可以看到修改的细节。我们这里看下这个bug是如何发生的。

2. bug详情

先看下之前有bug的源码,setHeadAndPropagate方法,唤醒后继的条件很简单,就是有剩余资源且当前节点状态不为0(也就是SIGNAL)。

private void setHeadAndPropagate(Node node, int propagate) {
    setHead(node);
    if (propagate > 0 && node.waitStatus != 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            unparkSuccessor(node);
    }
}

而释放锁的方法,也很简单:

public final boolean releaseShared(long arg) {
    if (tryReleaseShared(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

在这个版本中,共享模式资源的release,与独占模式的release,除调用的释放资源的方法不同外,没有什么区别。下面解释一下,bug触发的场景,具体代码在上面的链接中有,使用Semaphore触发的。

  1. 我们假设现在有两个线程,以共享的模式,在队列中等待: image.png
  2. 某个线程释放了一个资源
    • 执行unparkSuccessor方法,把head节点waitStatus改为0,并唤醒T1节点。
    • T1节点被唤醒后,调用tryAcquireShared方法,返回0,获取资源成功(state=0),此时剩余资源为0,但还没有出队列。 image.png
  3. 某持有资源的线程又释放了一个资源(state=1),判断head的waitStatus为0,不会唤醒后继节点。 image.png
  4. T1线程调用setHeadAndPropagate方法出队列,T1节点变为哨兵节点,但因为之前调用tryAcquireShared方法时返回0,导致执行setHeadAndPropagate方法时,不会唤醒后继节点。 image.png

这就导致本应被唤醒的T2,无法被唤醒。根本原因是,setHeadAndPropagate方法中,判断是否需要唤醒后继节点时,使用的是之前执行tryAcquireShared方法,那个时刻的propagate状态,在判断状态时,可能已经不准确了(可能更多也可能更少了)。如果tryAcquireShared返回的propagate=0,即使其他线程归还了资源,该线程(例子中的T1)线程,是无法感知到的,也就无法唤醒后继节点。其实上面栗子中的第二步,哨兵的节点状态,并不十分重要,即使是SIGNAL,也可能后续还是唤醒了T1这个已经活跃的线程。

我们再举一个跟上面类似的栗子:

  1. 初始状态时,也是两个节点,但是T1没有阻塞,T2阻塞了 image.png
  2. 线程T3持有资源的线程释放了一个资源(state=1),还没有执行unparkSuccessor方法,因为T1还没有阻塞,直接获取到了该资源(state=0),执行到setHeadAndPropagate的setHead之前 image.png
  3. 线程T4又释放了一个资源(state=1),线程T3/T4都判断,waitStatus = 1,调用unparkSuccessor方法,唤醒T1,当然没什么效果,因为T1本来就是活跃状态。 image.png
  4. T1线程调用setHeadAndPropagate方法出队列,T1节点变为哨兵节点,但因为之前调用tryAcquireShared方法时返回0,导致执行setHeadAndPropagate方法时,不会唤醒后继节点。 image.png

❄️ 可不可以在setHeadAndPropagate直接读state的状态呢?

不可以,理论上AQS并没有规定state的含义,没有规定state代表资源的数量,state的含义,完全由子类定义,因此不应该直接使用state状态作为资源。

3 PROPAGATE(-3)状态

上面的栗子中,由于线程T1在setHeadAndPropagate方法中,判断剩余资源时,无法获取最新的资源数量,即使有新的线程归还了资源,T1也无法得知。

李狗哥,通过新增PROPAGATE状态,来解决这个bug。当线程归还资源后,如果head节点状态为0,就改为PROPAGATE;而T1线程判断head的状态,如果为PROPAGATE,则不管剩余资源是多少,都尝试释放资源。

先看下最新的释放共享锁源码:

/**
 * 释放共享锁,核心封装在doReleaseShared中。
 */
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
/**
 * 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;
            // 状态为SIGNAL,唤醒后继节点
            if (ws == Node.SIGNAL) {
                // CAS失败重新循环(head不变,且状态不变,下次循环进入else,会把状态改为PROPAGATE)
                // 一个线程unparkSuccessor就可以,毕竟多个线程同时unpark同一个线程,也没什么用
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            // 状态==0,则CAS改为PROPAGATE,告知其他在执行setHeadAndPropagate的线程,有新资源释放,无条件唤醒
            // CAS失败,continue重新循环
            else if (ws == 0 && 
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        // 如果头结点改变了,继续循环,防止head的状态被改为0
        if (h == head)                   // loop if head changed
            break;
    }
}

重点解释下doReleaseShared方法,这个doReleaseShared会并发,可能是多个线程释放资源,也可能是队列中的节点获取锁成功,也在调用doReleaseShared方法,就像上文中bug复现的场景那样。

  • head状态为Node.SIGNAL,CAS改状态为0,争抢执行unparkSuccessor机会。CAS失败,说明存在并发,continue重新循环,如果head和head的状态没有改变,则进入下面的else,尝试修改状态为PROPAGATE(避免例子2)。
  • head状态为0,则CAS改状态为PROPAGATE,告知其他在执行setHeadAndPropagate的线程,有新资源释放,无条件唤醒。CAS失败continue重新循环:
    • 这段代码并发修改失败了,重试确认下就好;
    • 与取消并发了,取消会把它的前置节点的状态,从0改为SIGNAL。
  • 循环结尾,如果头节点没变,才会退出,也就会说,如果head节点改变,会重新判断。

简单概括下,doReleaseShared,如果head节点为SIGNAL,就执行unparkSuccessor,如果是0,就改为PROPAGATE,告知其他在执行setHeadAndPropagate的线程,有新资源释放,无条件唤醒。

现在回过头来,看下最新的setHeadAndPropagate方法:

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.
     */
    // 注意这里都是或,也就是说,即使propagate<=0,
    // 只要head(不管是旧的还是最新的)的状态<0,就会进入下面代码块
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        // next = null,无法获取通过next获取下一节点,保守策略,执行doReleaseShared
        // 或者下个节点是共享模式,执行doReleaseShared
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

这回就没那么难理解了
```java
if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0){
    ...            
}

这段代码就是说,即使剩余资源小于0,在头结点(无论在setHead之前,还是之后的),如果状态小于0,就执行doReleaseShared方法。

❄️ 为什么不判断h.waitStatus == PROPAGATE,而是小于0呢

因为PROPAGATE状态也可能被改为SIGNAL,节点取消,就有这种逻辑。