20.AQS原理

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了20.AQS原理相关的知识,希望对你有一定的参考价值。

重入锁的实现一个比较复杂的过程,涉及多个类和方法。而这还只是AQS的一小部分,要真正理解JUC体系 ,我们必须先梳理清楚AQS的问题。 AQS, Abstract Queued Synchronizer ,即抽象队列同步器。JUC里提供的线程工具中,很大一部分都基于AQS来实现的,甚至可以说AQS是整个JUC体系的根基。

1 如何设计一把锁

如果我们要自己设计一个锁,应该考虑哪些问题呢?

一个完整的锁,要保证多个线程访问时能正常访问和处理临界资源,如下图所示,此时至少应该满足如下条件:

1.满足互斥功能,也即竞争同一个共享变量时能保证安全的访问。

2.竞争失败的线程应该阻塞,并且能在资源被释放之后能被唤醒。这也要求,如果有N个线程被阻塞,则应该有一个容器来管理这些线程。

3.不同线程之间能够调整优先级(也称为公平非公平锁问题)。

4.要能满足重入的要求。

如果只有两三个线程 ,上面大部分操作还是比较好实现的,关键是多个线程同时竞争时该如何实现上述功能呢?我们该用哪种数据结构才能方便的满足该要求呢?答案是带头结点的双向链表:

 

在双链表中,我们定义了头结点head和tail用来快速访问首尾元素。获得资源的就是head指向的结点,执行完成后就可以被释放,对应的链表操作就是删除thread1对应的结点。如果有线程被阻塞,就将其打包成一个node连接到tail指向的位置上去。这样如果竞争线程比较多,就可以都连到队列上进行等待,这就实现了阻塞并等待的功能。

那如何实现优先级策略呢?假如某个新来的线程优先级高,要马上执行,此时我们只要将其连接到head位置就可以了,在上图对应的就是thread1执行完成之后马上执行这个优先级高的线程。(注意,这里是已经抢占资源的要先执行完,而不是直接中断正在执行的线程。)

那如何实现重入呢?这个更简单,我们只要在临界资源上增加一个state字段即可。如果当前资源空闲,那么state=0,否则就代表资源被某个线程抢占了还没释放。如果是重入,则继续将state的值增加即可,当然临界资源还会记录自己被哪个线程占用了。

这样,我们就大致设计了一个锁的基本结构,里面还有很多问题要进一步探讨,例如每个结点是如何等待的,如何唤醒的等等。接下来我们就详细看AQS是如何做的。

2 AQS的基本工作过程

2.1 AQS 基本结构

AQS是JUC的核心,无论是信号量还是可重入锁,背后都有AQS的影子。这些类的同步过程一般如下:

 

tryAcquire和tryRelease过程很好理解,就是CAS地修改AQS的state值,关键是doAcquire和doRelease如何管理众多线程的状态,又如何决定哪个线程可以获得锁。答案就是,AQS在其内部管理了一个链表,所有的线程都会被添加到这个链表中进行管理,这个链表在公平实现中又具有先进先出特性(非公平实现当然就是优先队列),这种思想也叫做CLH(三个人的姓名简称),而AQS在实现过程中体现了该思想,因此该队列也被称为CLH队列。

首先回顾一下doAcquireInterruptibly方法的代码:

private void doAcquireInterruptibly(int arg)
    throws InterruptedException 
    final Node node = addWaiter(Node.EXCLUSIVE);
    ...

可以看到,在方法最开始,首先调用了addWaiter方法,这个方法就是将当前线程添加到等待队列的过程,对链表都所了解的都能轻易看懂:

private Node addWaiter(Node mode) 
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) 
        node.prev = pred;
        if (compareAndSetTail(pred, node)) 
            pred.next = node;
            return node;
        
    
    enq(node);
    return node;

可见AQS是将线程包装成Node再入队的。Node是AQS里的一个内部类。从addWaiter可以看到,CLH队列的入队实际上就是把当前节点设置为队尾,那么相应的,出队就是处理队首节点。

在这里还有对前驱节点、后继节点的引用。前驱结点主要用在取消等待的场景:当前节点出队后,需要把后继结点连接到前驱结点后面;后继结点的作用是避免竞争,在doRelease方法中,当前节点出队后,会让自己的后继结点接替自己获取锁,有了明确的继承关系,就不会出现竞争了。

由此可见AQS是通过队列操作实现任务的管理,而且更方便:

  • 同步操作只涉及头节点、尾节点、当前节点、前驱结点、后继结点,对整个队列影响很小,明显优于整体加锁或者分段加锁。

  • 通过后继结点机制,明确了获得锁的顺序(除非出现信号量申请许可过多无法获取锁这种情况,此时只需要传播状态,继续向下寻找合适节点继承锁即可),避免了竞争。

2.2 核心结点Node

既然任务是通过队列来管理的,那每个任务自然就是队列中的一个个结点,那这些结点又是如何工作的呢?

AbstractQueuedSynchronizer类中的内容非常多,我们首先注意到内部有两个静态内部类Node和ConditionObject,这两个分别构造出了同步队列和条件队列。条件队列的问题我们后面再看,这里先看同步相关问题。

同步队列的基本结构如下,每个结点对应的就是一个被阻塞的线程,每个结点能够实现自旋等待的功能。

 上一节说,在同步队列中,如果当前线程获取资源失败,就会通过addWaiter()方法将当前线程放入队列的尾部,并且保持自选等待的状态,不断判断自己所在的结点是否是队列的头结点。如果自己所在的节点是头结点,那么就会不断尝试获取资源,如果成功,则通过acquire()方法退出同步队列,并在CPU里执行。以doAcquireInterruptibly为例,parkAndCheckInterrupt方法使用LockSupport令当前线程阻塞,直到收到信号被唤醒后,进入下一轮自旋:

private void doAcquireInterruptibly(int arg)
        throws InterruptedException 
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try 
            for (;;) //自旋
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) 
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return;
                
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            
         finally 
            if (failed)
                cancelAcquire(node);
        
    
 
private final boolean parkAndCheckInterrupt() 
    LockSupport.park(this);
    return Thread.interrupted();

那每个任务被封装成的结点,也就 静态内部类Node又是什么呢?Node的定义如下:

static final class Node 
        // 共享模式
        static final Node SHARED = new Node();
        // 独占(排它)模式
        static final Node EXCLUSIVE = null;

        // 线程已被取消,当首节点释放锁后,
        // 开始查找下一个 waitStatus < 0 的节点,
        // 如果遇到已取消的线程,则移除
        static final int CANCELLED =  1;
        // 当前线程的后继线程需要被unpark(唤醒)
        // 后继节点处于等待状态,当前节点(为-1)被取消或者中断时会通知后继节点,
        // 使后继节点的线程得以运行
        static final int SIGNAL    = -1;
        // 当前节点处于等待队列,节点线程等待在Condition上,
        // 当其他线程对condition执行signall方法时,
        // 等待队列转移到同步队列,加入到对同步状态的获取
        static final int CONDITION = -2;
        // 与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态
        static final int PROPAGATE = -3;

        // 当前节点的状态,默认是 0 
        volatile int waitStatus;
//双向链表
        volatile Node prev;
        volatile Node next;

        // 等待获取锁而自旋的线程
        volatile Thread thread;
        
        // Node既可以作为同步队列节点使用,也可以作为Condition的等待队列节点使用(将会在后面讲Condition时讲到)。
        // 在作为同步队列节点时,nextWaiter可能有两个值:EXCLUSIVE、SHARED标识当前节点是独占模式还是共享模式;
        // 在作为等待队列节点使用时,nextWaiter保存后继节点。
        Node nextWaiter;

        final boolean isShared() 
            return nextWaiter == SHARED;
        

        final Node predecessor() throws NullPointerException 
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        

        Node()     // Used to establish initial head or SHARED marker
        

        Node(Thread thread, Node mode)      // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        

        Node(Thread thread, int waitStatus)  // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        
    

 

从上面可以看到Node类的几个关键定义:

第一个是:Node类型的 prev和next说明该结构是一个双向链表,prev和next分别是前驱和后继结点。

第二个是:Node 类型的SHARED和EXCLUSIVE表示共享还是独占类型的结点。

第三个是:定义了四个常量CANCELLED、SIGNAL、CONDITION和PROPAGATE四个表示状态的常量。

  • CANCELLED:表示当前节点中的线程已经被取消。

  • SIGNAL:表示后继结点中的线程处于等待状态。

  • CONDITION:表示当前结点的线程在等待某个条件,也就是当前结点处于Condition队列中。

  • PROPAGATE:表示当前场景下能够执行后续的acquireShared操作。

  • 在默认情况下,waitStatus的取值为0,表示当前节点在sync队列中,等待获取锁。

第四个是:Node类中存在一个volatile类型的成员变量waitStatus,其取值就是上面的几个常量值。

AQS的实现类有好几种 ,这些类与AQS搭配能够实现很多强大的功能,例如:

 后面我们就分专题详细分析。

从本小节的分析可以看到,AQS本身是通过队列来管理线程,而每个结点在等待机制上是“自旋+阻塞+唤醒”的机制,相比于纯自旋,这种方式能够很好地兼顾性能与效率。

以上是关于20.AQS原理的主要内容,如果未能解决你的问题,请参考以下文章

操作系统| 进程同步详解(主要任务制约关系临界资源临界区同步机制遵循规则信号量机制信号量的应用)

薄膜产品技术亮点

linux 内核的futex - requeue 以及 requeue-pi

Synchronized原理

Linux信号量

浅析线性表的原理及简单实现