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

本文在如上两篇文章的基础上, 进一步学习 CLH 锁设计者 Craig, Landin and Hagersten 的论文。

参考文章

  1. Building FIFO and priority-queueing spin locks from atomic swap - CLH 队列之 Craig 论文

  2. Efficient Software Synchronization on Large Cache Coherent Multiprocessors CLH 队列之 Landin, Hagersten 论文长版

  3. Queue Locks on Cache Coherent Multiprocessors - CLH 队列之 Landin, Hagersten 论文缩减版

CLH 锁的基础算法介绍

为了更好地理解 CLH 锁, 笔者对【参考文章1】【参考文章2】【参考文章3】 都进行了阅读, 发现与 Landin and Hagersten 的论文相比,还是 Craig 的论文更为清晰易懂。 所以主要借助 Criag 文中的逻辑来学习 CLH 锁。

如之前所介绍的, MCS 锁是先于 CLH 锁提出的基于链表结构的排队锁, Criag 和 Landin and Hagersten 的论文中都有 MCS 锁论文的引用, CLH 锁其实就是在 MCS 锁基础上做了一些改进。

Craig 的论文很注重概念的清晰化定义, 在介绍算法前, 先单独明确了几个论文中频繁使用的概念

  • process: 论文中所描述的 process , 表达的是一个可调度在 CPU 上运行的实体, 对于某些系统来说, 这可能是一个线程。
  • 三种数据结构:
    • Lock
      • 对于每一个锁来说, 都会分配构建一个 Lock 类型的数据记录。 对于一般的锁的数据记录来说, 其内部通常会包含一个标识, 表达锁的是否已经被抢占的状态, 但是在链表式排队所的结构中, 这个锁记录就包含一个指针(这一点和 MCS 锁完全一样)
    • Process
      • 每个进程都会有一个 Process 类型的数据记录。其实就是进程级别的私有变量数据。
    • Request
      • 每当进程想请求一个锁时, 就需要将一个 Request 类型的数据记录加入对应锁的请求队列中,注意, 这个 Request 类型的数据记录甚至直接可以是 Process 记录本身, 因为锁队列中的结点其实就是一个进程获取锁的代表, 只要有一个可以与进程一对一关联的数据记录即可。 Request 概念上和 CMS 锁中提到的 qnode 一样, 都是锁队列中的结点类型数据。
      • Request 记录中包含一个状态变量 state , 取值为 PENDING 或 GRANTED

下图是论文中对这三种数据类型定义的伪代码。 有趣的是这一伪代码风格和 MCS 论文中的伪代码风格相近但不完全一致, 但 Craig 在论文中提到了 Pascal, 笔者查询后发现, 指针的表达方式确实是 Pascal 的语法,看伪代码别扭的同学可以先搜一下 pascal 的指针等语法, 应该会有帮助。

小提示

  • 伪代码方法声明method( varName : type), 等价于于 java 语法的 method(Class var)
    • ^someType 代表someType类型的指针, 按照笔者搜索结果, 好像是 VC++ 中的语法, 代表 managed pointer, 所指区域可以被垃圾回收器自动回收, 无需手动释放。
    • something^ 代表指针something 所指向的变量

AQS与CLH相关论文学习系列(三)- CLH 锁
数据结构图形化表达如下
AQS与CLH相关论文学习系列(三)- CLH 锁

下面是锁和进程的初始化伪代码
AQS与CLH相关论文学习系列(三)- CLH 锁
值得注意的是, 上面的 init_Lock 部分, 将指针 L.tail 指向了一个新分配的 Request 结点, 并且将该 Request 记录的 state 设置为了 GRANTED。 这里与 MCS 锁不同, 因为 MCS 锁的Lock 的指针起始状态是指向 nil (null) 的。

下图展示了 1个锁与 3 个进程的初始化后的状态
AQS与CLH相关论文学习系列(三)- CLH 锁

加锁操作的伪代码
AQS与CLH相关论文学习系列(三)- CLH 锁
加锁操作其实就是将本进程对应的 Request 结点的state 状态置为 pending, 然后本进程的 watch 指针 L 所指的队尾结点, 同时将 L 指针指向新加入的队尾结点, 整个操作利用了 fetch&store 这一原子指令确保并发入队的安全性。整个过程其实就是让当前进程监控 Lock 队列中原本的队尾 Request 结点, 根据队尾结点的 state 状态, 判断自己是否能获得锁, 将自己构建的 Request 入队为新的队尾, 供后继者监控。

下图为进程 P1P_1 执行了 request_lock 之后的状态, 此时由于 P1P_1 是第一个请求锁的进程, 它自旋监控的结点就是 Lock 初始化时创建的队尾 L 指向的 R0R_0 结点, 其状态是 Granted, 这样就代表 P1 应持有了锁,P1P_1 自己入队的结点 R1R_1 状态置为 PENDING, 供后继者入队监控
AQS与CLH相关论文学习系列(三)- CLH 锁

下图展示了 P1P_1 在未释放锁的情况下, P2P_2, P3P_3 也请求获取锁, 入队等待的状态。 看一个每一进程入队都是去 watch 监控 L 指向的队尾 Request, 然后将 L 指向自己所持有的 Request , 状态置为 PENDING, 供后续入队者监控 watch 。

  • 提示: 下图的绘制队尾在左, 队头在右 ,和我们平时的习惯队头在左, 队尾在右的习惯不太一样, 可能第一眼看起来会有点别扭
    AQS与CLH相关论文学习系列(三)- CLH 锁

至此加锁入队的操作就基本清楚了, 然后看下释放锁的操作, 伪代码如下
AQS与CLH相关论文学习系列(三)- CLH 锁

  • 锁持有进程在释放锁的时候, 只需要将自己持有的 myReq 记录中的 state 状态置为 GRANTED, 就相当于通知后继者可以通过自旋, 把锁的所有权让渡给了后继者

  • 另外这里还有一个非常有技巧性的操作是, 把自己原来 watch 监控的前驱结点 Request 变成自己持有的 myReq, 相当于废物利用, 回收了无用的 Request, 自己在下一次需要加锁时, 不需要重新再构建分配新的 Request

下图展示了P1P_1 进程把锁的持有权传递给了 P2P_2 后的状态, 此时 P2P_2 为持有锁的进程
AQS与CLH相关论文学习系列(三)- CLH 锁
下图则为 P2, P3 依次都获得并释放锁后, 无人持有锁的状态。注意, 整个数据结构的状态又恢复到了三个进程和锁初始化时等价的状态, 非常精巧
AQS与CLH相关论文学习系列(三)- CLH 锁
至此, 整个 CLH 队列的基本逻辑就已经清晰了, 由于上述部分都是 Craig 论文中的内容, 笔者也将 Landin and Hagersten 论文自己命名的 LH 锁的伪代码和部分图片摘录出来进行比较, 以证明两篇论文里说到的数据结构就是同一种, 这也是后人为什么将该数据结称为 CLH 锁的原因

AQS与CLH相关论文学习系列(三)- CLH 锁
下图与上图说的状态完全等价,P1持有锁, P2,P3 在等待, 只是队头方向画在了右边
AQS与CLH相关论文学习系列(三)- CLH 锁

CLH 锁与MCS 锁的对比分析

回过头来思考 CLH 锁与 MCS 锁的关系, 其实可以发现, CLH 锁相比 MCS 锁, 最明显的改进就是其释放锁的操作中, 没有自旋。 这很大程度上降低了锁的所有权转移过程的开销。

CLH 锁作为一个与 MCS 锁结构高度相似的锁, 之所以可以避免锁释放操作的自旋, 主要得益于如下设计思想的微调

同样都是每个进程对应于一个队列结点

  • MCS 锁判断一个进程是否已经获得锁的依据是进程本身持有的队列结点其中的某个值的状态
  • CLH 锁判断一个进程是否已经获得锁的依据是进程本身持有队列结点的前驱结点中某个值的状态

基于上述差别

  • MCS 锁的持有进程在让渡锁的所有权时, 由于需要关心自己的后继结点是否存在以及是否会被突然添加, 所以多了一些负担
  • MCS 锁在持有进程在让渡锁的所有权时,由于已经知道后继结点肯定只能监控自己在入队时就设置好的结点, 所以无需关心是否存在后继结点, 只需要修改自己预留给后继结点监控的队列结点状态即可。

从上述差别, 笔者其实想大胆的意淫一下, 虽然 Craig 和 Landin and Hagersten 都引用了 MCS 的论文, 但是都没有去详细分析自己设计的 CLH 锁与 MCS 锁之间的相似性和差异, 这或许反映了学术工作中为体现创新, 在行文过程中使用的一点小心思

CLH 锁如何添加优先级等特性

前面介绍的CLH锁算法实现了基本的先入先出自旋锁排队, 但是 Craig 大佬没有止步于此, 论文的后半部分进一步分析了如何为 CLH 锁添加如下高级特性:

  • 支持嵌套: 允许一个进程同时持有多把锁
  • 支持超时: 一个进程一段时间未获取到锁可以结束锁获取请求
  • 条件式获取锁:允许进程在锁可用的情况下才获取到锁, 否则就立刻返回
  • 支持抢占式获取锁: 在保证锁不会被非运行态的进程获取基础上, 允许调度器抢占掉某个运行态进程的锁获取机会。

Craig 大佬指出, 在下图基础版本的 CLH 锁的队列结构其实并不是一个常规意义的链表, 因为对于任意一个进程, 都无法顺着 L 指针或者完成整个队列的遍历。 链表中的结点其实没有前驱和后继指针相连, 前驱后继关系完全是通过进程的排队实现的。
AQS与CLH相关论文学习系列(三)- CLH 锁
在此基础上想要实现支持基于优先级队列顺序获取锁其实也不难, 只需要添加一些指针, 允许锁的持有进程可以遍历链表, 在释放锁的时候将锁传递给优先级最高的进程。

具体而言, 可以添加如下指针

  • 为锁队列添加一个头指针 head
  • 为队列结点添加一个指针 watcher, 指向正在监控watch 它的进程, 成为 process 中 watch 的反向指针
  • 为队列结点添加一个指针 myproc, 指向拥有它的进程,成为process 中 myreq 的反向指针
  • 注意下图中的 Process 结构中的 pri 表示的是代表进程优先级 priority 的值, 不是指针

AQS与CLH相关论文学习系列(三)- CLH 锁
AQS与CLH相关论文学习系列(三)- CLH 锁
锁的初始化操作论文省略了伪代码, 直接给了初始化后状态的图例表示如下, 包含了一个初始化完毕的进程和锁。P1P_1的优先级是 5 , 注意 P1P_1刚刚初始化的R1R_1 记录内部就已有了指向 P1P_1 的指针
AQS与CLH相关论文学习系列(三)- CLH 锁

下图是优先级队列加锁操作伪代码
AQS与CLH相关论文学习系列(三)- CLH 锁
与之前的加锁入队操作很类似, 只是在入队后, 增加了对 watcher 指针赋值的操作。

下图是优先级为 5 的 P1P_1 请求加锁, 完成了入队操作之后的状态, 整个数据结构变成了一个双向链表
AQS与CLH相关论文学习系列(三)- CLH 锁
下图展示了 4 个请求锁的进程 P0P_0 - P4P_4 均入队完成后的状态。

AQS与CLH相关论文学习系列(三)- CLH 锁
针对上述的链表结构, Craig 提出了一个 稳定 的概念, 这个稳定是指对于一段链表。 上图中绿框圈出的部分就是稳点的一段链表

  • 如果除了持有锁的进程, 其他进程都不会改动指针导致链表结点关系发生变化, 我们就称之为稳定。
  • 基于这种稳定的结构, 锁的持有进程可以放心并安全地顺着指针对链表进行遍历。
  • 只要当一个新入队的进程完成了 watcher 指针的设置, 那么从head 所指的头结点 Request 记录一路到这个新入队进程 myReq 所指向的 Request 记录, 都是稳定的。
  • 图中的 P4P_4 还没完成 watcher 指针的设置, 所以其 myReq 所指的 Request 记录 R4R_4 对于锁的持有进程从 head 开始是遍历不到的,它还没进入“稳定的部分”

基于上述的这种稳定链表结构,锁的持有进程已经具备了安全遍历链表的能力, 剩下的问题就变成了如何将锁释放给优先级最高的那个进程。 由于遍历链表的同时, 还可能会不断有新结点的加入, 当我们选出一个优先级最高的进程后, 很可能立刻有更高优先级别的进程进入, 我们只能暂时忽略这种情形, 将其留给下一轮锁的释放。

具体基于优先级锁的释放伪代码如下,看起来较长, 但其实按步骤分解并不复杂

  • 首先将持有锁进程自己的 process 记录移出链表
    • 自己可能处于链表的中部,也可能处于链表的头部, 如果是自己头部结点, 出队之后要更新 L 的 head 指针 )
  • 遍历链表找到优先级最高的进程
  • 将优先级最高的进程所watch 的 Request 记录中状态设置为 GRANTED
  • 释放锁的进程将 myReq 指针指向自己原本 watch 的 Request 记录, 实现废物利用, 省去下次请求锁时, Request 记录的创建必要。
    AQS与CLH相关论文学习系列(三)- CLH 锁
    AQS与CLH相关论文学习系列(三)- CLH 锁
    下图展示了队里头部的 P1P_1 将自己出队, 尚未将锁传递给 P3P_3 的状态
    AQS与CLH相关论文学习系列(三)- CLH 锁
    下图展示了处于队列中部的结点 P3P_3 将自己出队, 尚未将锁传递给 P4P_4 的状态 (虚线部分的指针是为了和其他还在链表中的结点指针进行区分, 避免干扰理解)
    AQS与CLH相关论文学习系列(三)- CLH 锁
    至此, 锁的优先级特性如何添加就说明完毕了。

CLH 锁嵌套的支持

考虑一下 CLH 锁机制下, 如果一个进程需要获得多把锁应该怎样实现。

此处我们假设有 L 个锁, P 个进程,一个进程需要获得其中 D 把锁才能访问特定资源。 最简单的方法是每个进程都需要参与 L 个队列的排队, 每个进程需要都要创建 L 个 Request 记录分别参与多个队列排队。 但是这样带来的空间消耗是 O(L+P*D)

论文作者想到了另一种空间复杂度为 O(L+P)的做法: 进程获得一个锁后, 如果需要请求新的锁,就把正在 watch 的 Request 记录据为己有, 作为一个新的 Request 去参加下一个锁的排队。

需要注意的是, 参与下一个队列排队前, 需要通过指针记录一下释放该锁时, 需要更新的 Request 记录。 假如后续需要重复这一过程, 要将所有释放锁时需更新的 Request 通过指针串成一个链表式的栈, 按照获得锁的逆序去更新这些记录, 相当于逆序地将所有获得的锁再依次释放。

下面通过图例更清晰地说明一下, 假设现在有两个锁 L0L_0LAL_A, 四个进程 P1P_1,P2P_2, PBP_B, PCP_C, 其中 P1P_1,P2P_2 在锁 L0L_0的队列中, PBP_B, PCP_C,在锁 LAL_A 的队列中。 P1P_1 正持有锁 L0L_0PBP_B 正持有锁 LAL_A
AQS与CLH相关论文学习系列(三)- CLH 锁

图例说明:

  • 上图中的 W 为 watch 指针, N 代表 nextToRelease 指针, M 代表 myReq 指针

然后假设, P1P_1 进程需要同时持有 L0L_0LAL_A, 现在发现自己已经获得第一把锁 L0L_0 后(acuire_lock 方法中通过自旋之后), 立刻通过 nextToRealse = myReq 记录自己释放锁 L0L_0时, 需要更新的 Request 记录 R1R_1, 并通过 myReq = watch 获取自己原本 watch 的 Request 记录 R0R _0记录的所有权,用这个新占有的 R0R _0去参与下一个锁 LAL_A 的排队。变成如下的状态

AQS与CLH相关论文学习系列(三)- CLH 锁
假设 PBP_BPCP_C 依次释放了锁, 最终 P1P_1也获得了 LAL_A 后, 就会变成下面这样。
AQS与CLH相关论文学习系列(三)- CLH 锁
上图的状态就是 P1P_1 同时获得了锁 L0L_0LAL_A 且还未释放的状态。 注意到, 释放锁时,P1P_1 顺着自己的 N (nextToRelease)指针,就可以依次更新 R0R_0, R1R_1 的状态为 Granted , 完成 L0L_0LAL_A 两个锁的释放

笔者认为, 上面种节约空间的做法提升了算法的复杂度,尤其是当多个进程持有多个不同的锁时, 整个数据结构指针交错纵横。 虽然节约了空间, 但是增大了认知负担, 如果空间不是那么紧张的话, 还是用简单的方案为妙, 易于维护。

CLH 锁请求超时的支持

对于排队式的简单自旋锁, 添加超时特性就很简单, 加入一个计时器, 超时还没有成功设置锁标志就结束循环。

但是对于排队式自旋锁而言, 超时的处理就没有那么简单, 如果简单的直接停止自旋, 那么自己 watch 的 Request 记录可能就会被前驱结点对应进程设置为 Granted, 但是此时进程已经停止了自旋, 后续排队的进程都会被阻碍在这里。所以我们需要一种安全的停止自旋方案。

此时回想一下, CLH 锁的设计中, 只有入队操作和持有锁进程的出队操作是安全的, 对于一个等待进程而言, 是否还有其他的机会可以安全的修改队列信息呢? 答案是有的,一个进程可以通过原子指令去修改自己正在监控 watch 的 Request 中的数据。

所以我们假设一个正在等待锁的进程(假设名叫 P3P_3), 它等待超时不想再继续等待了, 此时它必须在自己正在 watch 的 Request 记录(假设名叫 R2R_2)中留下两个信息

  • 自己不再等待锁了

  • 下一个 Request 记录的指针
    这个信息留存的写入操作是需要和一个潜在的锁释放进程(假设叫 P2P_2)竞争的, 因为 P2P_2 在释放锁时, 也会去更新 P3P_3 正在自旋监控的记录, 为了解决这个并发更新的问题,有两种方案。

    方案一:

    • P3P_3先将 R2R_2 中添加一个指针指向队列的下一个结点R3R_3 , 然后再利用原子指令 test-and-set 去并发更新这个 R2R_2 中的 state 状态。 如果成功的从 PENDING 更新成了 TIMEOUT, 锁的释放进程就可以根据这个信息, 直接去更新 R3R_3 的状态为 GRANTED。 如果 state 更新失败, 则说明锁已经被释放给了P3P_3P3P_3 可以选择直接返回获取锁成功, 或者直接把锁让渡给后面的进程, 返回获取锁失败。
      方案二:
    • 把自己不再等待锁的信息 和 下一个 Request 记录指针的信息打包到一个变量中, 例如一个 32bit 的整形变量, 最高两位用于表达状态(GRANTED,PENDING,TIMEOUT), 剩下的 30 bit 用于指针。 这样直接通过原子指令 swap 和锁的释放进程竞争更新这一变量即可。 swap 成功或失败后处理方式和方案一相同

上面这两种类似超时解决方案带来了相应的问题, 那就是一个等待超时的进程 P3P_3在脱离队列时, 如果成功将自己正在自旋的结点中的数据R2R_2更新为了 TIMEOUT, 并留下了下一个记录R3R_3的指针, 它不能立刻将这个 R2R_2 作为自己的 Request 带走去别的锁队列中排队, 因为释放锁的进程可能还要使用R2R_2中留存的信息。

为了解决这个问题, 释放锁的进程在使用完 R2R_2 记录后, 要写入一个信息, 用于告知 P3P_3 该记录已经使用完毕, 可以回收利用。 对于 P2P_2 则需要自旋等待直到R2R_2 被使用完毕, 或者去创建一个新的 Request 记录。 如果采用了新建记录的方案, 则最坏情况下, 空间复杂度会变成 O(L*P), 即每个进程创建了 L 个 Request 记录参与排队。

另外在上面这个场景中,如果超时的进程再次请求锁时, 恰好请求的是上次已经超时放弃的锁,假如发现自己 watch 的记录还未被使用完毕, 那还可以尝试恢复 watch 结点的状态到超时前的状态, 如果恢复成功就相当于离队之后, 回来发现还没排到自己, 直接插队回去了。

CLH 锁条件式获取的支持

条件式获取锁的语义其实就是 tryLock, 锁可得就占有锁, 锁不可得就立即返回失败, 对于 CLH 锁, 这个问题的解决可以简化为锁超时的情形, 只是超时时间很短。 此处不再赘述

CLH 锁抢占的支持

首先粘一段搜到的 Preemption 概念

抢占(Preemption)是指内核强行切换正在CPU上运行的进程,在抢占的过程中并不需要得到进程的配合,在随后的某个时刻被抢占的进程还可以恢复运行。 发生抢占的原因主要有:进程的时间片用完了,或者优先级更高的进程来争夺CPU了。

对于一系列等待锁的进程而言, 如果锁的持有者被抢占了 CPU 的执行, 显然其他被调度的进程只能做无用功, 相当于平白增添了所有排队者的等待时长。所以一个理想的情况时, 内核尽可能避免去抢占持有锁的进程执行权限, 但我们应该允许内核去抢占尚未获取锁的进程执行权限。

这个问题对于非排队自旋锁非常简单,因为锁是自己主动争抢的, 不是等待别人授予的, 所以抢占一个没获得锁的进程是安全的, 不会影响其他等待锁的进程。 但是对与排队锁就不一样, 如果一个队列中的锁被抢占了执行权限后, 锁的持有权被突然授予给了它, 它就变相影响了所有排在它后面的进程执行。

解决这个问题也可以用锁超时的处理思路, 让内核按照锁超时的方式去处理一个要被抢占的锁等待进程, 将其移出队列, 再次重新调度回来的时候还可以尝试将其恢复或者重新排队。

相关文章: