• 深入理解抽象的队列同步器(AQS)的概念和用途
  • 发布于 2个月前
  • 188 热度
    0 评论
一、满是疑惑的 AQS 介绍
AQS,全称 Abstract Queued Synchronizer,是一个抽象的队列同步器,通过维护一个共享资源状态( Volatile Int State )和一个先进先出( FIFO )的线程等待队列来实现一个多线程访问共享资源的同步框架。

看完这段描述中,大家或多或少会有以下几个疑问:
1.什么是同步?
2.同步与互斥什么区别?
3.什么是同步器?
4.为什么用队列?
5.为什么是抽象的?

6.怎么访问共享资源?


本篇就围绕这些疑问,咱们一起从网络中收集收集资料,从 AQS 的概念和功能设计层面来一波挖呀挖。

二、挖一挖同步的概念?
操作系统引入并发程序设计技术,但并发执行的程序由于共享系统资源,在协同完成任务时还需要交互,从而产生进程/线程之间的相互依赖和相互制约的关系,即协作关系和竞争(互斥)关系。

小纸条:这里提到了竞争/互斥 和 协作 这几个关键字,其中把竞争关系也叫做了互斥关系;可是没有同步哇?

2.1 共享系统资源可能有问题
若并发程序共享一些资源,比如,内存,文件,数据库等。当多个进程/线程同时读写同一份共享资源的时候,可能会引起冲突,也可能出现一致性问题。

2.2 什么是互斥?

进程/线程之间的一般交互关系是竞争(互斥)关系,这是由于计算机中的资源有限,众多进程/线程需要共享资源,当两个进程/线程需要访问同一个独占型资源时,一个进程/线程向操作系统提出资源申请请求,另外一个进程/线程程只能等待资源被释放后再申请资源。在竞争关系中,由于进程间共享资源而产生制约关系,这是间接制约关系,又称为互斥关系(有点绕哈)。互斥机制是解决进程/线程间竞争关系的手段,进程/线程使用互斥机制向系统提出资源申请,谁先向系统提出申请,谁就能先执行。


2.3 什么是同步?

同步是并发进程/线程之间共同完成一项任务时直接发生的制约关系(互斥是间接制约),又称为协作关系。具有协作关系的进程/线程在执行的时间次序上,必须遵循确定规律,需要相互协作的进程/线程在某些关键点上协调各自的工作。当其中的一个到达关键点后,在尚未得到其协作进程/线程发来的消息或信号之前应阻塞自己,等待协作进程/线程发来消息或信号后方被唤醒并继续执行。


2.4 互斥是特殊的同步

进程/线程互斥关系是一种特殊的进程同步关系,即逐次使用互斥共享资源,也是对进程/线程使用资源次序上的一种协调。


2.5 强大的同步机制

同步机制是解决进程/线程间同步(这里包括协作和互斥)关系的手段,并让并发进程/线程基于某个条件来协调它们的活动,通过合适的方法排定执行的先后次序。


三、挖一挖 AQS 的设计?
1) 共享系统资源可能有问题,我们需要注意
.防止进程/线程之间有干涉

.防止出现一致性问题


2)使用同步来避免这类问题?

我们需要引入进程/线程“同步”机制,不能让线程们随意一窝蜂挤上去乱作一团,胡作非为


3)同步是什么呢?

前文中已介绍过,进程/线程同步的概念和其他“同步”不太一致,进程/线程同步指的是线程之间“协同步调”,即进程/线程之间按照规定的先后次序运行。


4)为什么用队列?

进程/线程之间按照规定的先后次序运行,即各线程之间要有个先来后到,这不就是“排队”的意思?不用队列用什么?


5)为什么叫同步器?
.同步是动作,面向对象的思想来说就是方法,那同步方法是谁的呢?同步器的呀 !
.同步动作如何执行依赖同步状态,谁来管控状态呢?同步器呀!

.那谁来管理队列呢?同步器呀!


6)为什么没有互斥器?
互斥,线程之间要以排队的效果,一个一个对共享资源进行操作,而不是同时进行操作。上文概念章节中已提及,常常把互斥看做一种特殊的同步,因为实际上同步机制既能解决互斥问题也能解决协作问题。


7)为什么是抽象的

同步机制能解决互斥问题
同步机制也能解决多样化的协作问题,常见的协作工具有Semaphore、CountDownLatch、CyclicBarrier等。

那么自然基于同步器实现的同步策略就有很多样,自然有差异,通过抽象即统一规范了放行、排队的主体逻辑,又提供了状态管控的扩展机制以灵活应对互斥问题和协作问题。


8)怎么访问共享资源?
互斥模式访问共享资源:
  .有线程竞争的情况下线程要排队休眠,并且同步器同一时刻只唤醒并放行一个线程以达到互斥的效果
  .无线程竞争的情况下,则自然没必要排队,只需由同步器来保障同一时刻只放行一个线程

  .从放行的效果来看,有非公平模式和公平模式(相对公平),非公平是说可以不排队


共享模式访问共享资源:
  .共享模式,见名知意,即可以同时多个线程共享访问共享资源
  .同步器同一时刻可放行多个线程,即达到多个线程共享的模式来访问共享资源

  .通常共享模式也有放行的线程数量也是有上限的,当达到上限的时候(准确的说是不满足放行条件的时候),线程需要排队休眠,当满足放行条件的时候,同步器同时唤醒并放行多个线程,达到共享的效果


Condition 协作模式访问共享资源:
这里的协作是特指利用 condition,当线程被同步器放行后,在运行过程中又遇到不具备继续运行的条件(需要其他线程把条件准备好)

比如消息队列场景下,利用不满和不空两个 condition,实现更灵活的生产和消费管控,队列满时不生产消息,队列不满时则生产消息,队列空时则不消费消息,队列不空时则消费消息。


注意:
1.共享模式:可放行多个线程,让多个线程并行访问资源

2.独占模式:只有一个线程可放行,访问资源


这里用到放行这个词,大家看到的较多的资料都不用这个词,为什么笔者这里这么说呢,因为大多资料讲的都是从锁的视角来解释独占模式下只允许一个线程抢锁。但共享模式下按照锁的视角来说则允许多个线程同时持锁,如果持锁了就不用排队等待,这在现实场景中很类似被放行,可继续执行 lock 后的逻辑。

四、挖一挖关键源码?
很多博主有针对 AQS 的源码实现,逐行解释其实现细节,但往往让读者老师看着很懵,所以笔者本篇的初衷是从功能层面,描述其主体逻辑和不同上下文下的扩展性,有了宏观视角,再结合其他博主的文章(后续笔者也可能整理一篇)可能会效果更好,希望能帮助大家。

4.1 管理队列
AQS 是由其 head 和 tail 属性维护一个双向队列,线程被包装成节点,然后在队列中抢占(注意这里用抢占一词)合适的位置。

公平锁,如果同步器不放行线程,线程就去队尾排队
非公平锁,不管是否有排队先插队试试,看同步器会不会放行,若不放行才去队尾排队休眠等待,待被唤醒后继续尝试让同步器放行
另外一个关键点队列管理的关键就是队首和队尾的值,即 AQS 对象中 head 和 tail 的赋值

这里的赋值操作出于性能考虑,赋值采用了性价比最高的自旋+CAS。

理论上只要保障其值变化的原子性和可见性,那么再基于其操作结果做后续的队列管理处理,就不会出现冲突。

或许你也遇到过在现实生活中排队情况时,有两个人都说我先来的而产生争执,但站在队列管理的角度来说,到底谁先来的其实不重要,重要的是作为的队列的管理员必须决议其中一个先排到队尾(谁先通过 CAS 给 tail 赋值成功),那么另外一个 CAS 就会失败,接下来继续确定新的队尾是谁,接着往队尾排就好,排队时遇到了冲突都如此决议即可(所以也非绝对公平对吧?)。


但 CAS 的机制也导致只能保障 head 或 tail 中其中一个值的赋值是安全的,而无法同时保障 head 和 tail 两个变量赋值的原子性。

所以双向队列方向肯定只有一个方向是绝对可靠的,实际上就是 tail->head 的方向是安全可靠的,也就是说当新节点加入队列后,可能有那么一瞬间无法从 head-> tail 方向遍历找到节点,那么此时可采用从 tail-> head 方向遍历。

为什么要维护 head -> tail 方向的链路呢?想象一下当你休眠后,你的前继节点完事后,直接唤醒作为其后继节点的你去访问共享资源;这种直接的操作在大多数情况下就要比从 tail -> head 方向遍历效率要高。

当然队列的管理还有线程节点在条件队列和同步队列之间的迁移,原理相对比较简单,此处暂不多说

4.2、acquire 方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
            acquireQueued(
              addWaiter(AbstractQueuedSynchronizer.Node.EXCLUSIVE)
          , arg)
        )
        selfInterrupt();
}
不难发现,这里有三个方法,他们非常核心,但在进入代码层面介绍之前先回顾一下上文所介绍的几个关键内容:
1.没必要排队的情况下,如无线程竞争时,则只需由同步器来保障按既定模式以及条件放行线程
2.需要排队的情况下,如有线程竞争时,线程要排好队后休眠,之后由同步器按照既定模式以及条件唤醒并放行线程
这 3 个逻辑便是围绕以上两条主体逻辑而做的实现,我们分别来看一下其核心逻辑

1)无需排队时
tryAcquire()
.出于性能考虑,如果上下文情况不需要排队则不用队列,比如互斥模式中无竞争的情况下,仅通过同步器的状态管控即可实现互斥效果,则不用排队。

.另外在其方法内一个很重要的逻辑是重入时累加引用计数。


2)可能需要排队时
addWaiter()

.如果不是重入,则将当前线程包装成节点排到队尾,tail 的 CAS 赋值一旦成功,那么队尾节点的前驱节点的关系就是确定的,从 tail -> head 方向的队列链路就是可靠的。


.但排队之前也尽可能尝试被放行,毕竟相比多做一次 CAS 操作,排队休眠的性能损耗要高的多。


acquireQueued()
.这个环节有休眠和被唤醒后申请同步器放行两个核心逻辑
.如果醒后申请放行失败,则继续休眠,所以源码中是个循环,直到成功才退出循环。
.若申请放行不成功,交代前驱节点唤醒自己(前驱节点打个标记),之后休眠释放掉时间片。当前驱节点释放后,通过 unpark 唤醒后继节点的线程;线程被唤醒后,继续申请放行;这一步是一个循环

另外从逻辑前后顺序来说,是先睡眠再申请放行还是先申请放行再睡眠呢,其实从循环执行的视角看都可以,但从性能的视角看稍有偏差,在第一次休眠前还有申请放行的机会。

注意:
三个方法内,都有申请放行,因为当前时间点不满足,而很可能下一个逻辑执行时就满足了放行的条件,所以有多次尝试;这几次尝试通常也被称为自旋。
用户评论