写点什么

并发编程 -AQS 介绍和原理分析 (上)

用户头像
追风少年
关注
发布于: 2021 年 06 月 11 日
并发编程-AQS介绍和原理分析(上)

Lock 模型

AQS(AbstractQuenedSynchronizer 抽象队列式同步器)

  • 核心思想 &基本框架


如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。


AQS维护了一个volatile语义(支持多线程下的可见性)的共享资源变量state和一个FIFO线程等待队列CLH(多线程竞争state被阻塞时会进入此队列)。
复制代码


state

共享资源变量 state,三种访问方式:


  • getState()

  • setState(int newState)

  • compareAndSetState(int expect, int update)


资源共享的方式,两种:


  • 独占式(Exclusive)只有单个线程能够成功获取资源并执行,如 ReentrantLock。

  • 共享式(Shared)多个线程可成功获取资源并执行,如 Semaphore/CountDownLatch 等。

Node 节点 - CLH(Craig, Landin, and Hagersten locks)

CLH 锁其实就是一种是基于逻辑队列非线程饥饿的一种自旋公平锁(基于单向链表(隐式创建)的高性能、公平的自旋锁),由于是 Craig、Landin 和 Hagersten 三位大佬的发明,因此命名为 CLH 锁。AQS 内部的 FIFO 线程等待队列,通过内部类 Node 来实现


static final class Node {
// 表明节点在共享模式下等待的标记 static final Node SHARED = new Node(); // 表明节点在独占模式下等待的标记 static final Node EXCLUSIVE = null;
// 表征等待线程已取消的 static final int CANCELLED = 1; // 表征需要唤醒后续线程 static final int SIGNAL = -1; // 表征线程正在等待触发条件(condition) static final int CONDITION = -2; // 表征下一个acquireShared应无条件传播 static final int PROPAGATE = -3;
/** * SIGNAL: 当前节点释放state或者取消后,将通知后续节点竞争state。 * CANCELLED: 线程因timeout和interrupt而放弃竞争state,当前节点将与state彻底拜拜 * CONDITION: 表征当前节点处于条件队列中,它将不能用作同步队列节点,直到其waitStatus被重置为0 * PROPAGATE: 表征下一个acquireShared应无条件传播 * 0: None of the above */ volatile int waitStatus;
// 前继节点 volatile Node prev; // 后继节点 volatile Node next; // 持有的线程 volatile Thread thread; // 链接下一个等待条件触发的节点 Node nextWaiter;
// 返回节点是否处于Shared状态下 final boolean isShared() { return nextWaiter == SHARED; }
// 返回前继节点 final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; }
// Shared模式下的Node构造函数 Node() { }
// 用于addWaiter Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; }
// 用于Condition Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; }}
复制代码


题外话:关于锁的一些内容:简单的非公平自旋锁以及基于排队的公平自旋锁的实现 https://blog.csdn.net/dm_vincent/article/details/79677891CLH锁的原理和实现 https://blog.csdn.net/dm_vincent/article/details/79842501

实现

下面通过 AbstractQuenedSynchronizer(同步器)和 ReentrantLock(锁)来详细说明一下 AQS 的原理。除了共享和独占的特点外,重点关注它的三个特性:


  • 公平和非公平

  • 可中断

  • 条件(后续文章说明)

API

同步器是实现锁的关键,利用同步器将锁的语义实现,然后在锁的实现中聚合同步器。可以这样理解:


  • 锁的 API 是面向使用者的,它定义了与锁交互的公共行为,而每个锁需要完成特定的操作也是透过这些行为来完成的(比如:可以允许两个线程进行加锁,排除两个以上的线程),但是实现是依托给同步器来完成

  • 同步器面向的是线程访问和资源控制,它定义了线程对资源是否能够获取以及线程的排队等操作。


锁和同步器很好的隔离了二者所需要关注的领域,严格意义上讲,同步器可以适用于除了锁以外的其他同步设施上(包括锁)

AQS 内部定义(实现)的方法

独占式


  • acquire(int)

  • acquireInterruptibly(int)

  • tryAcquireNanos(int,long)

  • release(int)


共享式


  • acquireShared(int)

  • acquireSharedInterruptibly(int)

  • tryAcquireSharedNanos(int,long)

  • releaseShared(int)

需要继承的锁自定义实现的方法

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回 true,失败则返回 false。

  • tryRelease(int):独占方式。尝试释放资源,成功则返回 true,失败则返回 false。

  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。

  • isHeldExclusively():该线程是否正在独占资源。只有用到 condition 才需要去实现它。


AQS 需要子类复写的方法均没有声明为 abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。当然,AQS 也支持子类同时实现独占和共享两种模式,如 ReentrantReadWriteLock。另外可以看到,一般 try 开头的都是需要锁实现的,但是tryAcquireNanos方法例外,它的作用使用实现可超时中断的锁

源码分析

独占锁的实现

/** * Acquires in exclusive mode, ignoring interrupts.  Implemented * by invoking at least once {@link #tryAcquire}, * returning on success.  Otherwise the thread is queued, possibly * repeatedly blocking and unblocking, invoking {@link * #tryAcquire} until success.  This method can be used * to implement method {@link Lock#lock}. * * @param arg the acquire argument.  This value is conveyed to *        {@link #tryAcquire} but is otherwise uninterpreted and *        can represent anything you like. */public final void acquire(int arg) {    if (!tryAcquire(arg) &&        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))        selfInterrupt();}
复制代码


上述逻辑主要包括:


  1. 尝试获取(调用 tryAcquire 更改状态,需要保证原子性);在 tryAcquire 方法中使用了同步器提供的对 state 操作的方法,利用 compareAndSet 保证只有一个线程能够对状态进行成功修改,而没有成功修改的线程将进入 sync 队列排队。

  2. 如果获取不到,将当前线程构造成节点 Node 并加入 sync 队列;进入队列的每个线程都是一个节点 Node,从而形成了一个双向队列,类似 CLH 队列,这样做的目的是线程间的通信会被限制在较小规模(也就是两个节点左右)。

  3. 再次尝试获取(acquireQueued),如果没有获取到那么将当前线程从线程调度器上摘下,进入等待状态。


tryAcquire 在 ReentrantLock 中的最终是在 FairSync 中


protected final boolean tryAcquire(int acquires) {    final Thread current = Thread.currentThread();    int c = getState();    if (c == 0) {        if (!hasQueuedPredecessors() &&            compareAndSetState(0, acquires)) {            setExclusiveOwnerThread(current);            return true;        }    }    else if (current == getExclusiveOwnerThread()) {        int nextc = c + acquires;        if (nextc < 0)            throw new Error("Maximum lock count exceeded");        setState(nextc);        return true;    }    return false;}//对于非公平锁也一样protected final boolean tryAcquire(int acquires) {    return nonfairTryAcquire(acquires);}
复制代码


addWaiter


private Node addWaiter(Node mode) {    Node node = new Node(Thread.currentThread(), mode);    // Try the fast path of enq; backup to full enq on failure    Node pred = tail;    if (pred != null) {        node.prev = pred;        if (compareAndSetTail(pred, node)) {            pred.next = node;            return node;        }    }    enq(node);    return node;}private Node enq(final Node node) {    for (;;) {        Node t = tail;        if (t == null) { // Must initialize            if (compareAndSetHead(new Node()))                tail = head;        } else {            node.prev = t;            if (compareAndSetTail(t, node)) {                t.next = node;                return t;            }        }    }}private final boolean compareAndSetHead(Node update) {    return unsafe.compareAndSwapObject(this, headOffset, null, update);}private final boolean compareAndSetTail(Node expect, Node update) {    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);}
复制代码


上述逻辑主要包括:


  1. 使用当前线程构造 Node;对于一个节点需要做的是将当节点前驱节点指向尾节点(current.prev = tail),尾节点指向它(tail = current),原有的尾节点的后继节点指向它(t.next = current)。

  2. 先行尝试在队尾添加;如果尾节点已经有了,然后做如下操作:(1)分配引用 T 指向尾节点;(2)将节点的前驱节点更新为尾节点(current.prev = tail);(3)如果尾节点是 T,那么将当尾节点设置为该节点(tail = current,原子更新);(4)T 的后继节点指向当前节点(T.next = current)。注意第 3 点是要求原子的。这样可以以最短路径 O(1)的效果来完成线程入队,是最大化减少开销的一种方式。

  3. 如果队尾添加失败或者是第一个入队的节点。如果是第 1 个节点,也就是 sync 队列没有初始化,那么会进入到 enq 这个方法,进入的线程可能有多个,或者说在 addWaiter 中没有成功入队的线程都将进入 enq 这个方法。可以看到 enq 的逻辑是确保进入的 Node 都会有机会顺序的添加到 sync 队列中,而加入的步骤如下:(1)如果尾节点为空,那么原子化的分配一个头节点,并将尾节点指向头节点,这一步是初始化;(2)然后是重复在 addWaiter 中做的工作,但是在一个 while(true)的循环中,直到当前节点入队为止。进入 sync 队列之后,接下来就是要进行锁的获取,或者说是访问控制了,只有一个线程能够在同一时刻继续的运行,而其他的进入等待状态。而每个线程都是一个独立的个体,它们自省的观察,当条件满足的时候(自己的前驱是头结点并且原子性的获取了状态),那么这个线程能够继续运行。


final boolean acquireQueued(final Node node, int arg) {    boolean failed = true;    try {        boolean interrupted = false;        for (;;) {            final Node p = node.predecessor();            if (p == head && tryAcquire(arg)) {                setHead(node);                p.next = null; // help GC                failed = false;                return interrupted;            }            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) /* * This node has already set status asking a release * to signal it, so it can safely park. */ return true; if (ws > 0) { /* * Predecessor was cancelled. Skip over predecessors and * indicate retry. */ do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { /* * waitStatus must be 0 or PROPAGATE. Indicate that we * need a signal, but don't park yet. Caller will need to * retry to make sure it cannot acquire before parking. */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false;}
复制代码


上述逻辑主要包括:


  1. 获取当前节点的前驱节点;需要获取当前节点的前驱节点,而头结点所对应的含义是当前站有锁且正在运行。

  2. 当前驱节点是头结点并且能够获取状态,代表该当前节点占有锁;如果满足上述条件,那么代表能够占有锁,根据节点对锁占有的含义,设置头结点为当前节点。

  3. 否则进入等待状态。如果没有轮到当前节点运行,那么将当前线程从线程调度器上摘下,也就是进入等待状态。

小总结

  1. 状态的维护;需要在锁定时,需要维护一个状态(int 类型),而对状态的操作是原子和非阻塞的,通过同步器提供的对状态访问的方法对状态进行操纵,并且利用 compareAndSet 来确保原子性的修改。

  2. 状态的获取;一旦成功的修改了状态,当前线程或者说节点,就被设置为头节点。

  3. sync 队列的维护。在获取资源未果的过程中条件不符合的情况下(不该自己,前驱节点不是头节点或者没有获取到资源)进入睡眠状态,停止线程调度器对当前节点线程的调度。这时引入的一个释放的问题,也就是说使睡眠中的 Node 或者说线程获得通知的关键,就是前驱节点的通知,而这一个过程就是释放,释放会通知它的后继节点从睡眠中返回准备运行。


锁的释放


public final boolean release(int arg) {    if (tryRelease(arg)) {        Node h = head;        if (h != null && h.waitStatus != 0)            unparkSuccessor(h);        return true;    }    return false;}
复制代码


上述逻辑主要包括:


  1. 尝试释放状态;tryRelease 能够保证原子化的将状态设置回去,当然需要使用 compareAndSet 来保证。如果释放状态成功过之后,将会进入后继节点的唤醒过程。

  2. 唤醒当前节点的后继节点所包含的线程。


tryRelease 在 ReentranLock-Sync 中的实现


protected final boolean tryRelease(int releases) {    int c = getState() - releases;    if (Thread.currentThread() != getExclusiveOwnerThread())        throw new IllegalMonitorStateException();    boolean free = false;    if (c == 0) {        free = true;        setExclusiveOwnerThread(null);    }    setState(c);    return free;}
复制代码


通过 LockSupport 的 unpark 方法将休眠中的线程唤醒,让其继续 acquire 状态。


private void unparkSuccessor(Node node) {  // 将状态设置为同步状态    int ws = node.waitStatus;    if (ws < 0)        compareAndSetWaitStatus(node, ws, 0);
// 获取当前节点的后继节点,如果满足状态,那么进行唤醒操作 // 如果没有满足状态,从尾部开始找寻符合要求的节点并将其唤醒 Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread);}
复制代码


上述逻辑主要包括,该方法取出了当前节点的 next 引用,然后对其线程(Node)进行了唤醒,这时就只有一个或合理个数的线程被唤醒,被唤醒的线程继续进行对资源的获取与争夺。回顾整个资源的获取和释放过程:在获取时,维护了一个 sync 队列,每个节点都是一个线程在进行自旋,而依据就是自己是否是首节点的后继并且能够获取资源;在释放时,仅仅需要将资源还回去,然后通知一下后继节点并将其唤醒。这里需要注意,队列的维护(首节点的更换)是依靠消费者(获取时)来完成的,也就是说在满足了自旋退出的条件时的一刻,这个节点就会被设置成为首节点

共享锁

简单来讲,读锁和读锁是可以共享的,其它情况,读锁和写锁,写锁和读锁、写锁和写锁,都是互斥的。


/** * Acquires in shared mode, ignoring interrupts.  Implemented by * first invoking at least once {@link #tryAcquireShared}, * returning on success.  Otherwise the thread is queued, possibly * repeatedly blocking and unblocking, invoking {@link * #tryAcquireShared} until success. * * @param arg the acquire argument.  This value is conveyed to *        {@link #tryAcquireShared} but is otherwise uninterpreted *        and can represent anything you like. */public final void acquireShared(int arg) {    if (tryAcquireShared(arg) < 0)        doAcquireShared(arg);}
private void doAcquireShared(int arg) { final Node node = addWaiter(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(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); }}
复制代码


上述逻辑主要包括:


  1. 尝试获取共享状态;调用 tryAcquireShared 来获取共享状态,该方法是非阻塞的,如果获取成功则立刻返回,也就表示获取共享锁成功。

  2. 获取失败进入 sync 队列;在获取共享状态失败后,当前时刻有可能是独占锁被其他线程所把持,那么将当前线程构造成为节点(共享模式)加入到 sync 队列中。

  3. 循环内判断退出队列条件;如果当前节点的前驱节点是头结点并且获取共享状态成功,这里和独占锁 acquire 的退出队列条件类似。

  4. 获取共享状态成功;在退出队列的条件上,和独占锁之间的主要区别在于获取共享状态成功之后的行为,而如果共享状态获取成功之后会判断后继节点是否是共享模式,如果是共享模式,那么就直接对其进行唤醒操作,也就是同时激发多个线程并发的运行。

  5. 获取共享状态失败。通过使用 LockSupport 将当前线程从线程调度器上摘下,进入休眠状态。


/** * Releases in shared mode.  Implemented by unblocking one or more * threads if {@link #tryReleaseShared} returns true. * * @param arg the release argument.  This value is conveyed to *        {@link #tryReleaseShared} but is otherwise uninterpreted *        and can represent anything you like. * @return the value returned from {@link #tryReleaseShared} */public final boolean releaseShared(int arg) {    if (tryReleaseShared(arg)) {        doReleaseShared();        return true;    }    return false;}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;    }}
复制代码


调用该方法释放共享状态,每次获取共享状态 acquireShared 都会操作状态,同样在共享锁释放的时候,也需要将状态释放。比如说,一个限定一定数量访问的同步工具,每次获取都是共享的,但是如果超过了一定的数量,将会阻塞后续的获取操作,只有当之前获取的消费者将状态释放才可以使阻塞的获取操作得以运行。

未完待续

  • 可中断(public final void acquireInterruptibly(int arg))

  • 超时控制 (private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException)

  • 条件中断

参考

http://ifeve.com/introduce-abstractqueuedsynchronizer/

发布于: 2021 年 06 月 11 日阅读数: 391
用户头像

追风少年

关注

昨夜雨疏风骤,却道代码依旧。 2018.11.13 加入

非典型性程序员一枚,某互联网大厂资深开发,专注【Java技术领域、分布式技术领域、云原生技术实践】,喜欢分享金融和日常生活。 对每一行文字负责,对每一行代码负责。欢迎来踩,公众号:帅气的追风少年

评论

发布
暂无评论
并发编程-AQS介绍和原理分析(上)