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

  • AQS与CLH相关论文学习系列(一)- 排队式自旋锁思想启蒙
  • AQS与CLH相关论文学习系列(二)- MCS 锁
  • AQS与CLH相关论文学习系列(三)- 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与CLH相关论文学习系列(一)- 排队式自旋锁思想启蒙)

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

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

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

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 的使用方式如下。

slowDownRate=ttesttideal=ttestS∗Tserial+(1−S)∗Tparallel)=ttestS∗b∗n+(1−S)∗b∗max(1,np)\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​=tideal​ttest​​=S∗Tserial​+(1−S)∗Tparallel​)ttest​​=S∗b∗n+(1−S)∗b∗max(1,pn​)ttest​​​

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

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

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


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

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

  1. [jQuery学习系列四 ]4-Jquery学习四-事件操作

    [jQuery学习系列四 ]4-Jquery学习四-事件操作 前言: 今天看知乎偶然看到中国有哪些类似于TED的节目, 回答中的一些推荐我给记录下来了, 顺便也在这里贴一下: 一席 云集 听道 推酷 ...

  2. 李沐论文精读系列四:CLIP和改进工作串讲(LSeg、GroupViT、VLiD、 GLIPv1、 GLIPv2、CLIPasso)

    文章目录 一.CLIP 1.1 简介 1.1.1 前言 1.1.2 模型结构 1.1.3 模型效果 1.1.3.1 对自然分布偏移的鲁棒性 1.1.3.2 StyleCLIP 1.1.3.3 CLIP ...

  3. Identity Server4学习系列四之用户名密码获得访问令牌

    1.简介 Identity Server4支持用户名密码模式,允许调用客户端使用用户名密码来获得访问Api资源(遵循Auth 2.0协议)的Access Token,MS可能考虑兼容老的系统,实现了这 ...

  4. iOS路由设计(四)路由设计思路分析

    http://www.jianshu.com/p/76da56b3bd55 前言 随着用户的需求越来越多,对App的用户体验也变的要求越来越高.为了更好的应对各种需求,开发人员从软件工程的角度,将Ap ...

  5. android 键编译,Android 音视频学习系列 (四) 一键编译 32/64 位 FFmpeg 4.2.2

    前言 2020/5/20 增加了硬件解码编译脚本 编译环境 Centos + NDK20b + FFmpeg4.2.2 + Android-21/16 2020/4/26 更新了编译 64 位脚本 编 ...

  6. Java NIO学习系列四:NIO和IO对比

    前面的一些文章中我总结了一些Java IO和NIO相关的主要知识点,也是管中窥豹,IO类库已经功能很强大了,但是Java 为什么又要引入NIO,这是我一直不是很清楚的?前面也只是简单提及了一下:因为性 ...

  7. RabbitMQ入门学习系列(四) 发布订阅模式

    什么时发布订阅模式 把消息发送给多个订阅者.也就是有多个消费端都完整的接收生产者的消息 换句话说 把消息广播给多个消费者 消息模型的核心 RabbitMQ不发送消息给队列,生产者也不知道消息发送到队列 ...

  8. STM32-USB学习系列(四):USB-HID模拟鼠标功能

    一.整体步骤 使用STM32CubeMX 生成 HID 模版 自己定义mouseHID 结构体,然后通过发送鼠标报文控制鼠标的移动 二.STM32CubeMX 配置 芯片:STM32F407VG 使用 ...

  9. 机器视觉学习系列四:身份证识别

    项目背景:基于手机平台,识别身份证编号.姓名.年龄.地址,性别等: 具体实施方案: 1.基于身份证分类器检测身份证的位置,关于身份证分类器,采用的是HAAR+adaboost算法进行训练: 2.在已经 ...

最新文章

  1. memcache函数整理
  2. 北信源IPO,拟筹资开发企业级云安全管理平台
  3. C语言杂谈:指针与数组 (上) (转)
  4. 南通专转本计算机考试几级,江苏专转本考试了解多少?
  5. [云炬ThinkPython阅读笔记]2.5 运算顺序
  6. MySQL高级 - 查询缓存 - 开启查询缓存
  7. python暂停和恢复游戏_pygame游戏之旅 添加游戏暂停功能
  8. 【zookeeper】zookeeper znode 存储系统解密
  9. 5-Scala对象(Class)和类(Object)
  10. Windows与Linux下查看占用端口的进程
  11. [thinkphp] page类整合bootstrap分页样式
  12. L - Finding the Bases(KMP+dp)
  13. 现代办公自动化教程 计算机基础教育系列,新编办公自动化综合应用教程 高职计算机大类专业基础课 林婧 朱强第1章 现代办公自动化基础.ppt...
  14. php手机网站制作程序,phpcms制作手机WAP网站模板二次开发教程
  15. list对象转map
  16. 程序员成长为架构师必备的十项技能
  17. html取消波浪线,PPT文字下划波浪线如何去掉?
  18. 算法基础:基本数据结构的特点:队列 vs 栈
  19. php实现室内地图导航,室内三维地图引擎功能
  20. 量化交易入门笔记-策略常用对象

热门文章

  1. 【教学类-23-01】20221217《不会写学号的中班幼儿的学号描字贴》(中班描字)
  2. 金融级实人认证是什么?
  3. 架构升级、性能优化,高德技术专家 infoQ 全球架构师峰会开讲啦
  4. linux下学习db2
  5. SEO优化转战移动手机站
  6. 计算机网络 之 DNS (Domain Name System)域名服务器
  7. Java | 加密技术 | 摘要加密算法(不含原理)
  8. 最强大脑《联动归位》
  9. Opegnl ES之四边形绘制
  10. Ajax+JDBC+Json处理多个数据