AQS与CLH相关论文学习系列(四)- AQS的设计思路

本文是AQS与CLH相关论文学习系列第四篇。 系列其他文章链接如下

参考文章

  1. The java.util.concurrent Synchronizer Framework - 出自 DougLea 之手的 AQS 的设计论文

AQS 的设计初衷

DougLea 在论文中清晰地阐明了 AQS 的设计目的, 对于几乎任意一种同步器而言, 都可以基于它实现其他类型的同步器。例如, 我们可以基于一个 ReentrantLock 去实现 Semaphore, 反过来也可以。但是这种相互转化的实现往往会带来复杂度, 开销, 和不灵活性,不是一流的工程设计。 如果这些同步器概念本质上就没有一个比另一个更底层的话, 强迫开发者去基于其中一种同步器实现另一种显然是不合理的, 所以需要设计一个可以用于构建各种同步器的基础框架, 便于开发者在其基础上实现和定制自己所需的同步器。 由此, AQS应运而生。

AQS 可以用于实现包括锁在内的各类同步器 ,但是锁依旧是大家最熟悉的术语, 为了后续行文方便以及简化概念,文章中会穿插使用 “锁”与“同步器” 的概念, 不再特意强调 “同步器“ 和 “锁” 的区别, 请细心的读者不要纠结。

AQS 的功能需求

如之前所说, 由于 AQS 的设计初衷是要提供一个方便开发者在其基础上定制开发各类同步器, 所以需要分析各类同步器的共通可复用特性, 将其抽取出来, 由 AQS 封装实现, 外部仅需调用。

经过分析, Doug Lea 提炼出了各类同步器共通的两类方法:

  • acquire 操作
    • 当一个线程调用 acquire 操作后, 如果当前同步状态不允许该线程通过, 该线程会被阻塞。
  • release 操作
    • 当一个线程调用 release 操作后,可以更改同步状态, 使得一个或者多个线程解除阻塞。

在上述两类操作基础上, 还需要附加支持如下特性:

  • 非阻塞式的调用
  • 阻塞式调用情形下,允许设置超时
  • 允许通过中断的方式取消 acuqire 操作

AQS 的性能需求

既然是考虑性能需求, 就需要和 java 内置的同步锁(通常通过 synchronized 关键字使用)进行比较。

在 jdk1.5 之前, synchronized 性能一直是很大的问题,所以有不少文献文献分析过如何优化。 但是这些文献分析关注点主要集中于如下两个方面:

  • 如何最小化空间开销(因为 synchronized 关键字可以将任意的 java 对象当做一个锁使用, 不当的使用就可能导致空间浪费)
  • 如何在单处理器的低竞争度场景下(加锁区域可能同一时刻就只有一个线程在访问), 最小化时间开销

Doug Lea 认为上面这两方面对同步器而言都不重要, 原因如下

  • 程序员只会存在并发的场景下去使用同步器, 所以在这些情形下去尝试压缩空间收益并不高, 因为压缩节省的空间其实更容易在其他场景下被浪费。
  • 关键字的使用场景大部分都是多线程设计, 在这种设计下, 竞争肯定是时不时存在的, 所以 jvm 常规的针对零竞争场景的优化(先尝试使用偏向锁, 轻量级锁, 最后再使用重量级锁)会导致其他存在竞争的情形既没有享受到优化带来的性能提升, 又忍受了优化操作本身带来的额外开销,而且最终的重量级锁在激烈的竞争下, 还有可能产生锁的饥饿等待问题,某个线程通过同步区域的执行时长难以预估。 这种优化对于重度依赖 java.util.concurrent 包的服务器端程序肯定不是一种合适的策略。

所以, Doug Lea 指出 AQS 的性能需求主要是

扩展性(scalability): 当同步器处于重度竞争场景时, 依旧保持可预见的执行效率。 理想情况下, 无论多少个线程尝试通过一个同步点(synchronization point), 单个线程通过该同步点所需要的开销最好都是恒定的,不随竞争程度加剧而增加。

因此, AQS 的主要性能设计目标之一就是去尽可能缩短一个线程允许被通过某一同步点,但尚未通过前所消耗的等待时间( 也就是从一个线程释放锁 到 下一个线程成功获取锁 之间所消耗的时间)。 自旋仿佛是一个很好的策略, 因为可以第一时间检测到锁状态的改变, 但是可能会产生大量的 CPU 浪费以及内存的争抢开销, 所以也常常不采用这种方式。

另外由于关于同步器的使用有两种使用场景:

  • 一种应用希望最大化总吞吐率, 于此同时提供一定概率上避免饥饿的保证。
  • 一种应用(例如用于核心资源分配)可能希望有绝对的公平性保证,可以忍受总体吞吐率的降低。

这两个目标是相互冲突的, 所以框架的设计者需要将决策权留给用户, 允许用户进行定制, 使用不同的公平性策略。 同时由于同步器无论内部被设计得多么完善, 总会成为一些应用的性能瓶颈, 所以框架必须允许用户监控特定的基本操作, 使得用户能够发现和缓解瓶颈。 这种需求的一种实用解决方案就是提供一种检测机制, 供用户获得阻塞在同步器上的线程数量

AQS 的设计思路分析

基于前文提出的 AQS 的功能需求( 提供两种基本操作 acquire, release) 和性能需求(允许定制化的公平性策略, 尽可能缩短释放锁与获得锁之间所需时间, 且平衡 CPU,内存的资源消耗 ), Doug Lea 先提供了如下粗略的总体伪代码设计。

在这里插入图片描述
上述代码虽然简短, 但是已经体现了用于满足功能和性能需求的核心思路:

  1. 为锁的竞争线程进行排队, 以在必要时满足用户的锁的公平性需求
  2. 选择性地(possibly)阻塞正在等待获取锁的线程, 以确保竞争激烈时, 资源消耗在一个可预测的范围内, 同时又允许一定程度地自旋, 在轻度竞争场景下能够最小化锁的让渡时间。
  3. 释放锁时,可以根据情况决定唤醒一个或多个线程, 灵活平衡竞争效率和公平性的需求。

为了实现上述思路, 我们考虑如下问题:

  • 如何原子性地管理同步器状态
  • 如何阻塞和唤醒线程
  • 如何维护一个队列

AQS 同步状态表示

DougLea 选用了一个 volatile 类型的 32 bit 的 INT 变量表达同步器的状态, 这样既可以支持锁类型(Lock)的同步器状态表达,又可以支持计数器类型的同步器状态表达(Semephore), 笔者认为此处设计思路很直观,论文中虽有一定量的描述, 但这里不予赘述。 感兴趣读者可以自行阅读

AQS 如何阻塞和唤醒线程

DougLea 指出, 在 JSR166 之前, 都没有线程阻塞/唤醒 API 可以用于构建同步器( synchronzied 关键字除外)。 当时可用的只有 Thread.suspendThread.resume 这两个 API, 但是这两个 API 存在一个问题是, 如果针对一个线程先调用了resume 后调用了 suspend, 那这个 resume 操作就不会产生任何作用, 基于这种 API 去构建同步器显然会涉及到一个不可解决的问题:

  • 两个线程针对同一个状态变量(state)进行交互, 一个线程( threadA)负责监控\以决定是否进入阻塞状态, 另一个线程(threadB) 负责改变状态, 唤醒线程A。
  • 线程 B 没办法在不借助同步器的前提下, 判断自己在改变了 state 状态后, 是否需要调用 threadA.resume()

JSR 166 之后, java.util.concurrent.locks 包里引入了一个 LockSupport 类用于解决这个问题。 该类有两个方法:

  • LockSupport.park
  • LockSupport.unpark

LockSupport.park 操作可以阻塞当前进程, 但是如果在此之前 LockSupport.unpark 已经被调用 ,该操作就不会阻塞线程。但是, LockSupport.unpark 操作并不会计数, 假如 LockSupport.unpark 操作先于 LockSupport.park 被调用了多次, 也只会导致之后一次 LockSupport.park 的无效, 再次调用 LockSupport.park 就会使线程进入阻塞状态。

这两个还支持超时和中断, 这也就为 AQS 的支持超时和中断提供了实现基础。

AQS 如何改造CLH 队列(重要)

AQS 的核心设计就是阻塞线程的队列管理, 这里的队列被限制成了先入先出的队列, 并不支持基于优先级的同步管理。

Doug Lea 在论文中提到设计时主要借鉴目标分别考虑过 MCS 锁与 CLH 锁, 由于 CLH 锁更容易支持锁的超时, 最终选用 CLH 锁作为设计基础。 ( 笔者认为 CLH 锁从机制上其实是 MCS 锁的改进, 这里把两个所放在平等的地位上考量有点牵强)

关于 CLH 锁的细节,本系列文章在 AQS与CLH相关论文学习系列(三)- CLH 锁
已有详细描述。 这里不做重复,Doug Lea 将 AQS 的队列维护机制称为 CLH 的变体, 也就是说在 CLH 基础上进行了部分调整与修改, 后文主要介绍 AQS 对于 CLH 锁机制的调整思路。

为 CLH 队列结点添加前驱后继指针

CLH 锁的原始设计中, 每一个进程都有一个各自对应的队列结点, 但是队列中的结点相互之间其实都没有指针关联,整个队列的前驱后继关系依赖于每个进程的私有指针维系。 如下图
在这里插入图片描述
Doug Lea 在论文里写到是 Scoot and Schoerer 在论文 《Scalable Synchronization on Shared-Memory》 中贡献了通过在结点中添加 predecessor 指针的思路。 通过添加前驱指针 predecessor 指针, CLH 队列可以处理锁获取过程中的超时和取消:

  • 如果一个结点的前驱结点进程取消了对锁的等待, 那么当前结点其实可以利用 predecessor 指针去读取更前面的结点状态用于判断自己是否可以获得锁。

另外 Doug Lea 又为每个结点添加了 next 指针的维护, 用于帮助当前结点便捷的找到后继进程, 进行唤醒。

有趣的是, 笔者通过阅读 Craig 的原版 CLH 论文发现, 早在 Craig 最早发布的CLH 锁论文中就详细分析了如何通过添加指针使 CLH 队列成为一个事实上的双向链表, 以便支持锁超时以及基于优先级的锁调度等特性。 所以这种添加指针的思路严格意义上算不上什么创新, 只能说是新瓶装旧酒。 具体细节依旧可以参见博主的本系列文章AQS与CLH相关论文学习系列(三)- CLH 锁

如上描述, 一个 AQS 结点中就有了 prednext 两个指针,需要指出的是, 基于 CAS 原子操作, 我们没办法在并发场景下, 线程安全地同时更新两个指针。 我们只能按照 CLH 的设计方式, 利用如下伪代码完成结点的线程安全入队并更新
pred 指针。

do{
 	pred = tail;
} while (! tail.compareAndSet(pred, node)) 

 /*这里的tail.compareAndSet 的语义是原子性地完成如下操作
 atmoic{
	if(tail == pred)
	{
	    tail = node;
		return true;
	}else
	{
		return false;
	}
		
  } */

上面这段伪代码执行后, 一个结点就完成了线程安全地入队操作, next 指针的赋值在 AQS 的设计中,就是简单地通过在其后追加如下赋值语句完成

pred.next = node

这种操作会带来一个明显的问题, 即 node.next 指针是不可靠的, 当我们试图从队列头部通过 node.next 遍历整个队列时, 可能某个 nodenext 为空, 但该结点后续实际已经追加了一个甚至多个 node。

所以 next 指针只能被当做一个用于优化的路径, 当我们通过 next 指针找不到后继结点时, 如果需要精确地判断后继结点是否存在, 还必须再从队列尾部依靠 pred 指针反向遍历一下, 判断某个 next 之后是否真的尚未添加结点。

修改 CLH 锁获取的判定条件

CLH 的原始设计如下

  • 每个结点都有一个状态变量 state, 可以赋值两个常量(GRANTED, PENDING)
  • 每个线程可以自旋监控自己前驱结点的 state 变量, 用于判断自己是否能获取锁。
  • 释放锁时, 每个线程修改自己结点中的 state 为 GRANTED , 用于通知后继线程(如果有)它已经可以结束自旋。
    在这里插入图片描述

AQS 作为 CLH 的变体做了如下调整:

  • 原本每个结点中都存在的 state 变量被抽取出来, 作为整个队列都可见的一个公共变量
  • 添加了一个 head 指针, 每当有线程释放锁时, 就将头结点指向自己所对应的结点。
  • 一个线程通过判断 head 的位置, 决定自己是否可以去获得锁。 队列中的头结点线程可以通过 CAS 的方式去原子性地修改这个变量, 修改成功即可认为是获得了锁。
  • 释放锁时, 持有锁的线程把 head 指向自己的 node, 以通知后继线程可以去尝试获取锁

例如下图展示了 3 个线程中 Thread 1 由于自己的前驱节点是 head, 所以就有资格去尝试更新变量 state, 成功更新为即为获取了锁。如下图, Thread1 由于前驱结点是 head, 所以有资格尝试更新 state 变量, 成功更新为 GRANTED 后, 即获取了锁。

  • 提示: 下图中 node0 是队列初始化时的一个空结点, 不对应任意线程, 队列初始化时, head 和 tail 都指向它。
    在这里插入图片描述
    细心的同学肯定会产生疑问: 其实CLH 原版每个结点中的 state 变量和 AQS 中的 head 指针作用完全一样, 都是前驱线程通知后继线程使用的一个变量, 只不过换了一种表达形式。 既然已经可以通过 head 指针, 唯一允许一个线程去获得锁, 为什么还要用 tryAcquire 的方式, 而不是直接更新获得所。

这里也是 AQS 的特殊设计 , 即允许用户通过自定义 tryAcquire 方法, 让队列头部的线程和一个新来尚未入队的线程共同竞争锁, AQS 论文中关于此处也有说明, 即通过这种方式, 既能允许用户定制先入先出的公平锁, 也能可能存在饥饿的非公平锁。 如下图, 论文将新来尚未排队的线程称为 闯入线程(barging thread)
在这里插入图片描述

为 CLH 队列结点添加 status 变量

在这个基础上 , AQS 又在每个 node 中增加了一个 status 变量, 该变量用于保存如下丰富的控制信息

  • 某个线程是否已取消了锁的等待
  • 某个线程是否需要唤醒下一个线程
    • 当一个线程发现尚不能获取锁, 在调用 park 进入阻塞状态前, 先在前驱结点中的 status 变量中, 设置一个 bit 位为 1, 用于表达语义 “signal me”, 然后再次检查锁的状态, 如果发现锁状态依旧不可获取, 再调用park 操作。
    • 这样做的第一个好处是,一个线程可以尽可能避免不必要的 park 调用进入阻塞状态, 尽可能缩短一个锁释放与获得的间隙时间。
    • 这样做的第二个好处是, 当一个线程要释放锁时, 可以只在自己结点 status 变量中的 “signal me” bit 被设置为 1 时, 才去查找后继结点线程,调用 unpark 操作, 从而避免不必要的后继结点查找工作, 毕竟 next 指针并不可靠, 精确地找到后继结点还需要涉及到反向遍历。

AQS 的 acquire 与 release 雏形

基于前文的分析, AQS 的 acquire 和 release 操作可以基本用如下伪代码表达。(不考虑超时, 中断等特性)

  • acquire 操作
    在这里插入图片描述
  • release 操作
    在这里插入图片描述
    上面两段伪代码结合下面2张图应该基本就能展示 AQS 的核心原理了。
    在这里插入图片描述

AQS 的性能测量

虽然前文已经花大篇幅介绍了 AQS 与 CLH 队列的关系, 并阐述了核心设计思路。 但是论文中关于 AQS 的性能测试部分同样值得关注。 在软件领域中, 如何构建合理的测试来验证自己的系统是否能够达成设计目标有时比设计系统本身更为复杂。

回顾我们一开始提到的 AQS 性能设计目标是扩展性, 既在线程数增多, 竞争加剧的情况下, 单一线程通过同步区域的执行时间应当是稳定的。

Doug Lea 选用了非常系统化的性能测量方式

  • 每轮测试中可以创建 n 个线程
  • 每个线程去重复执行1依次方法 nextRandom
    在这里插入图片描述
  • 测试中,每个线程有一定概率 S 需要通过 AQS 实现 ReentrantLock 互斥地去执行 nextRandom 函数
  • 获得每轮测试的执行耗时

上述实验被部署到了不同架构, 不同处理器数量的计算机上运行, 并且最终利用了 Amdal’s Law 公式计算结果, 绘制图形。

不熟悉 Amdal’s Law 公式的同学建议阅读此处文章: Amdahl’s Law , 笔者认为改文章解释的非常清晰易懂

论文中对 Amdal’s Law 的使用方式如下。

s l o w D o w n R a t e = t t e s t t i d e a l = t t e s t S ∗ T s e r i a l + ( 1 − S ) ∗ T p a r a l l e l ) = t t e s t S ∗ b ∗ n + ( 1 − S ) ∗ b ∗ m a x ( 1 , n p ) \begin{aligned} slowDownRate &=\frac{t_{test}}{t_{ideal}} \\ &= \frac{t_{test}}{S*T_{serial}+(1-S)*T_{parallel})} \\ &= \frac{t_{test}}{S*b*n+(1-S)*b*max(1,\frac{n}{p})} \end{aligned} slowDownRate=tidealttest=STserial+(1S)Tparallel)ttest=Sbn+(1S)bmax(1,pn)ttest

上述公式计算了在使用 AQS 的实际测试时间与一个理想化的完美同步器锁执行时间的比率。 其中 b b b 代表单个线程执行 1 执行完一轮测试(包含1亿次循环) 锁耗费的时间, n n n 代表测试创建的线程数量, p p p 代表执行机器的处理器数量。

m a x ( 1 , n p ) max(1,\frac{n}{p}) max(1,pn)
主要用于体现, 当处理器数量超过线程数量时, 再增加处理器也没有办法提升可并行代码执行速度。

基于上述计算, Doug Lea 绘图中将结果图中的X轴和Y轴都以对数形式表达, 这样就使得不同机器的基准执行时间 b 的变化都不影响绘图效果, 可以把不同架构计算机上的执行结果等效比较

在这里插入图片描述
此处只截取一个1核处理器 1P 的结果图和4核处理器的结果图进行说明, 可以发现, AQS 基本实现了线程数量增多, 竞争加剧时, 总体执行时间稳定的性能目标, 至于4P 图中的拐点如何解释, Doug Lea 在论文里做了简要的猜想, 未必正确, 感兴趣的同学可以自行阅读
在这里插入图片描述

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值