读写自旋锁详解

来源:互联网 发布:生物医学数据挖掘 pdf 编辑:程序博客网 时间:2024/04/28 16:11

读写自旋锁简介

什么是读写自旋锁

      自旋锁(Spinlock)是一种常用的互斥(Mutual Exclusion)同步原语(Synchronization Primitive),试图进入临界区(Critical Section)的线程使用忙等待(Busy Waiting)的方式检测锁的状态,若锁未被持有则尝试获取。这种忙等待的做法无谓地消耗了处理器资源,故而只适用于临界区非常短小的代码片段,例如 Linux 内核的中断处理函数。

       由于互斥的特点,使用自旋锁的代码毫无线程并发性可言,多处理器系统的性能受到限制。通过观察线程在临界区的访问行为,我们发现有些线程只是简单地读取信息,并不修改任何东西,那么允许它们同时进入临界区不会有任何危险,反而能大大提高系统的并发性。这种将线程区分为读者和写者、多个读者允许同时访问共享资源、申请线程在等待期内依然使用忙等待方式的锁,我们称之为读写自旋锁(Reader-Writer Spinlock)。

读写自旋锁的属性

       上面提及的共享资源可以是简单的单一变量或多个变量,也可以是像文件这样的复杂数据结构。为了防止错误地使用读写自旋锁而引发的 bug,我们假定每个共享资源关联一把唯一的读写自旋锁,线程只允许按照类似大象装冰箱的方式访问共享资源:

  1. 申请锁。
  2. 获得锁后,读写共享资源。
  3. 释放锁。

       有些用户态实现的读写锁支持线程在持有锁的情况下继续申请相同类型的锁,以及读者在持有锁的情况下变换身份成写者。这 2 个特性对于适用于短小临界区的读写自旋锁而言并无实际意义,因此本文不作讨论。

对于线程的执行,我们假设:

  1. 系统存在一个全局时钟,我们讨论的时间是离散的,不是连续的、数学意义上的时间。
  2. 任意时刻,系统中活跃线程的总数目是有限的。
  3. 线程的执行不会因为调度、缺页异常等原因无限期地被延迟。理论上,线程的执行可以被系统无限期地延迟,因此任何互斥算法都有死锁的危险。我们希望排除系统的干扰,集中关注算法及具体实现本身。
  4. 线程对共享资源的访问在有限步骤内结束。
  5. 当线程释放锁时,我们希望:线程在有限步骤内释放锁。
  6. 因为每个程序步骤花费有限时间,所以如果满足上述 5 个条件,那么:获得锁的线程必然在有限时间内将锁释放掉。

       我们说某个读写自旋锁算法是正确的,是指该锁满足如下三个属性:

1. 互斥。任意时刻读者和写者不能同时访问共享资源(即获得锁);任意时刻只能有至多一个写者访问共享资源。

2. 读者并发。在满足“互斥”的前提下,多个读者可以同时访问共享资源。

3. 无死锁(Freedom from Deadlock)。如果线程 A 试图获取锁,那么某个线程必将获得锁,这个线程可能是 A 自己;如果线程 A 试图但是却永远没有获得锁,那么某个或某些线程必定无限次地获得锁。

读写自旋锁主要用于比较短小的代码片段,线程等待期间不应该进入睡眠状态,因为睡眠 / 唤醒操作相当耗时,大大延长了获得锁的等待时间,所以我们要求:

4. 忙等待。申请锁的线程必须不断地查询是否发生退出等待的事件,不能进入睡眠状态。这个要求只是描述线程执行锁申请操作未成功时的行为,并不涉及锁自身的正确性。

    “无死锁”属性告诉我们,从全局来看一定会有申请线程获得锁,但对于某个或某些申请线程而言,它们可能永远无法获得锁,这种现象称为饥饿(Starvation)。一种原因源于计算机体系结构的特点:例如在使用基于单一共享变量的读写自旋锁的多核系统中,如果锁的持有者 A 所处的处理器和等待者 B 所处的处理器相邻(也许还能共享二级缓存),B 更容易获知锁被释放,增大获得锁的几率,而距离较远的处理器上的线程则难与之 PK,导致饥饿的发生。还有一种原因源于设计策略,即读写自旋锁刻意偏好某类角色的线程。

      为了提高并发性,读写自旋锁可以选择偏好读者,即读者能够优先获得锁:

1. 读者优先(Reader Preference)。如果锁被读者持有,那么新来的读者可以立即获得锁,无需忙等待。至于当锁被“写者持有”或“未被持有”时,新来的读者是否可以“夹塞”到正在等待的写者之前,依赖于具体实现。

如果读者持续不断地到来,等待的写者很可能永远无法获得锁,导致饥饿。在现实中,写者的数目一般较读者少许多,而且到来的频率很低,因此读写自旋锁可以选择偏好写者来有效地缓解饥饿现象:

2. 写者优先(Writer Preference)。写者必须在后到的读者 / 写者之前获得锁。因为在写者之前到来的等待线程数目是有限的,所以可以保证写者的等待时间有个合理的上界。但是多个读者之间获得锁的顺序不确定,且先到的读者不一定能在后到的写者之前获得锁。可见,如果写者持续到来,读者仍然可能产生饥饿。

       为了彻底消除饥饿现象,完美的读写自旋锁还需满足下面任一属性:

3. 无饥饿(Freedom from Starvation)。如果线程 A 试图获取锁,那么 A 必定能在有限时间内获得锁。当然,这个“有限时间”也许相当漫长。

4. 公平(Fairness)。我们把“锁申请”操作的执行分为两个阶段:准备阶段(Doorway Section),能在有限程序步骤结束;等待阶段(Waiting Section),也许永远无法结束等待阶段一旦结束,线程即获得读写自旋锁。如果线程 A 和 B 同时申请锁,但是 A 的等待阶段完成于 B 之前,那么公平读写自旋锁保证 A 在 B 之前获得锁。如果 A 和 B 的等待阶段在时间上有重叠,那么它们获得锁的顺序是不确定的(在第二章中我们彻底取消“重叠”概念)。

    “公平”意味着申请锁的线程必定在有限时间内获得锁。若不然,假设 A 申请一个公平读写自旋锁但是永远不能获得,那么在 A 之后完成准备阶段的线程显然也永远不能获得锁。而在 A 之前或“重叠”地完成等待阶段的申请线程数目是有限的,可见必然发生了“死锁”,矛盾。同时这也说明释放锁的时间也是有限的。使用公平读写自旋锁杜绝了饥饿现象的发生,如果假定线程访问共享资源和释放锁的时间有一个合理的上界,那么锁申请线程的等待时间只与前面等待的线程数目有关,不依赖其它因素。

以自动机的观点看读写自旋锁

      上章关于读写自旋锁的定义和描述虽然通俗易懂,但是并不精确,很多细节比较含糊。例如,读者和写者这种角色到底是什么含义?“先来”,“后到”,“新来”以及“同时到来”如何界定?申请和释放锁的过程到底是怎样的?

现在,我们集中精力思考一下读写自旋锁到底是什么东西?读写自旋锁其实就是一个有限状态自动机(Finite State Machine)。自动机模型是一种强大的武器,可以帮助我们精确描述和理解各种算法。在给出严格定义之前,我们先规范一下上节中出现的各种概念:

1. 首先,我们把读写自旋锁看成一个独立的 串行系统,线程对锁函数的调用本质上是向其独立地提交操作(Operation)。操作必须是基本的,语义清晰的。所谓“基本”,是指任一种类操作的执行效果都不能由其它一种或多种操作的执行累积而成。

2. 读写自旋锁的函数调用的全过程现在可以建模为:线程提交了一个操作,然后等待读写自旋锁在某个时刻选择并执行该操作。我们举个读者申请锁的例子来具体说明。前面提到申请锁分成两个阶段,其中准备阶段我们认为线程向读写自旋锁提交了一个“读者申请”的操作。读者在等待阶段不停地测试锁的最新状态,其实就是在等待读写自旋锁的选择。最终读者在被许可的情况下“原子地”更新锁的状态,从而获得锁,说明读写自旋锁在某个合适的时刻选择并执行了该“读者申请”的操作。一旦某个操作被选中,它将不受干扰地在有限时间内成功完成并且在执行过程中读写自旋锁不能选择其它的操作。读者可能会有些奇怪,直观上锁的释放操作似乎是立即执行,难道也需要“等待”么?为了保证锁状态的一致性(Consistency),某些实现的释放函数使用了忙等待方式(参见本文的第一个实现),亦或由于调度、处理器相对速度等原因,总之锁的释放操作同样有一个不确定的等待执行的延时,因此可以和其它操作统一到相同的执行模型中。在操作成功提交至执行完毕这段时间内,线程不能睡眠。

3. 某个线程对锁的一次使用既可以用读者身份申请,也可以用写者身份申请,但是不能以两种身份同时申请。可见“角色”实质上是线程分别提交了“读者申请”或“写者申请”的操作,而不能提交类似“读者写者同时申请”的操作。

4. 读者 / 写者可以不停地到来 / 离去,这意味着线程能够持续地向读写自旋锁提交各种操作,但是每次只能提交一个。只有当上次提交的操作被执行后,线程才被允许提交新操作。读写自旋锁有能力知道某个操作是哪个线程提交的。

5. 线程对锁的使用必须采用前面提及的规范化流程,这是指线程必须提交配对的“申请”/“释放”操作,即“申请”操作成功执行后,线程应当在有限时间内提交相应的“释放”操作,且在此之前不准提交其它操作。

6. 关于读者 / 写者先来后到的顺序问题,我们转换成确定操作的提交顺序。我们认为操作的提交效果是“瞬间”产生的,即使多个线程在所谓的“同一时刻”提交操作,这些操作彼此之间也有严格的先后顺序,不存在两个或多个操作是“同时”提交成功的。在现实中,提交显然是需要一定时间的,不同线程的提交过程可能在时间上重叠,但是我们认为总可以按照一种策略规定它们的提交顺序,虽然这可能影响锁的实际执行过程,但并不影响正确性;对于同一线程提交的各个操作,它们彼此之间显然有着严格的时序关系,当然能够确定提交顺序。在此,我们彻底取消同时性的概念。令 A(t) 为在时间段 (0, t] 内所有提交的操作构成的集合,A(t) 中的任两个操作 o1和 o2,要么 o1在 o2之前提交,要么 o1在 o2之后提交,这种提交顺序是一种全序关系(Total Order)。

读写自旋锁的形式化定义是一个 6 元组(Q,O,T,S,q0,qf),其中:

  1. Q = {q0,q1,…,qn},是一个有限集合,称为状态集。状态 qi描述了读写自旋锁在某时刻 t0所处于的一种真实状况。
  2. O = {o0,o1,…,om},是一个有限集合,称为操作种类集。
  3. T:Q x O -> Q 是转移函数。T 是一个偏函数(Partial Function),即 T 的定义域是 Q x O 的子集。如果 T 在 (q, o) 有定义,即存在 q ’ = T(q, o),我们称状态 q 允许操作 o,在状态 q 可以执行操作 o,成功完成后读写自旋锁转换到状态 q ’;反之,如果 T 在 (q, o) 没有定义,我们称状态 q 不允许操作 o,说明在状态 q 不能执行操作 o,例如在锁被写者持有时,不能选择 “读者申请获取锁”的操作。
  4. S 是选择函数,从已提交但未执行的操作实例集合中选择一个让读写自旋锁执行,后文详细讨论。由于任意时刻活跃线程的总数目是有限的,这个集合必然是有限集,因此我们认为 S 的每一次选择能在有限步骤内结束。
  5. q0是初始状态。
  6. qf是结束状态,对于任一种操作 o,T 在 (qf, o) 无定义。也就是说到达该状态后,读写自旋锁不再执行任何操作。

我们先画出与定义等价的状态图,然后描述 6 元组具体是什么。

图 1. 读写自旋锁的状态图

  1. 状态图中的每个圆圈代表一个状态。状态集合 Q 至少应该有 3 个状态:“未被持有”,“读者持有”和“写者持有”。因为可能执行“析构”操作,所以还需要增加一个结束状态“停止”。除此之外不需要新的状态。
  2. 有向边上的文字代表了一种操作。读写自旋锁需要 6 种操作: “初始化”,“析构”,“读者申请”, “读者释放”, “写者申请”和“写者释放”。操作后面括号内的文字,例如“最后持有者”,只是辅助理解,并不表示一种新的操作。
  3. 有向边及其上的操作定义了转移函数。如果一条有向边从状态 q 射向 q ’,且标注的操作是 o,那么表明状态 q 允许 o,且 q ’ = T(q, o)。
  4. 初始状态是“未被持有”。
  5. 结束状态是“停止”,双圆圈表示,该状态不射出任何有向边,表明此后锁停止执行任何操作。

       结合状态图,我们描述读写自旋锁的工作原理:

1. 我们规定在时刻 0 执行全局唯一一次的“初始化”操作,将锁置为初始状态“未被持有”,图中即为那条没有起点、标注“初始化“操作的有向边。如果决定停止使用读写自旋锁,则执行全局唯一一次的“析构”操作,将锁置为结束状态“停止”。

2. 读写自旋锁可以被看成一个从初始状态“未被持有”开始依次“吃”操作、不断转换状态的串行机器。令 W(t) 为时间段 (0, t] 内已提交但未执行的操作构成的集合,W(t) 是 所有提交的操作集合 A(t) 的子集。在时刻 t,如果锁准备执行新的操作,假设当前处于状态 q,W(t) 不是空集且存在状态 q 允许的操作,那么读写自旋锁使用选择函数 S 在 W(t) 集合中选出一个来执行,执行完成后将自身状态置为 q ’ = T(q, o)。

3. 我们称序列 < qI1,oI1,qI2,oI2,…,oIn,qI(n+1)> 是读写自旋锁在 t 时刻的执行序列,如果:

  1. oIk是操作,1 <= k <= (n + 1)。且 oI1,oI2,…,oIn属于集合 A(t)。
  2. qIk是状态,1 <= k <= (n + 1)。
  3. 读写自旋锁在 t 时刻的状态是 qI(n+1)
  4. qI1= q0
  5. T 在 (qIk, oIk) 有定义,且 qI(k+1)= T(qIk, oIk)(1 <= k <= n)。

3. 假如执行序列的最后一个状态 qI(n+1)不是结束状态 qf,且在时刻 t0,W(t0) 为空或者 qI(n+1)不允许 W(t0) 中的任一个操作 o,我们称读写自旋锁在时刻 t0处于潜在死锁状态。这并不表明读写自旋锁真的死锁了,因为随后线程可以提交新的操作,使其继续工作下去。例如 qI(n+1)是“写者持有”状态,而 W(t0) 中全是“读者申请”的操作。但是我们知道锁的持有者一会定在 t0之后的有限时间内提交“写者释放”操作,届时读写自旋锁可以选择执行它,将状态置为“未被持有”,而现存的“读者申请”的操作随后也可被执行了。

4. 如果存在 t0> 0,且对于任意 t >= t0,读写自旋锁在时刻 t 都处于潜在死锁状态,我们称读写自旋锁从时刻 t0开始“死锁”。

以下是状态图正确性的证明概要:

1. 互斥。从图可知,状态“读者持有”只能转换到自身和“未被持有”,不能转换到“写者持有”,同时状态“写者持有”只能转换到“未被持有”,不能转换到“读者持有”,所以锁一旦被持有,另一种角色的线程只有等到“未被持有”的状态才有机会获得锁,因此读者和写者不可能同时获得锁。状态“写者持有”不允许“写者申请”操作,故而任何时刻只有至多一个写者获得锁。

2. 读者并发。状态“读者持有”允许“读者申请”操作,因此可以有多个读者同时持有锁。

3. 无死锁。证明依赖第一章中关于线程执行的 3 个假设。反证法,假设对任意 t >= t0,锁在时刻 t 都处于潜在死锁状态。令 q 为 t0时刻锁的状态,分 3 种情况讨论:

  1. “未被持有”。如果线程 A 在 t1> t0的时刻提交“读者申请”或“写者申请”的操作,那么锁在 t1时刻并不处于潜在死锁状态。
  2. “读者持有”。持有者必须在某个 t1> t0的时刻提交“读者释放”的操作,那么锁在 t1时刻并不处于潜在死锁状态。
  3. “写者持有”。持有者必须在某个 t1> t0的时刻提交“写者释放”的操作,那么锁在 t1时刻并不处于潜在死锁状态。

       从线程 A 申请锁的角度来看,由状态图知对于任意时刻 t0,不论锁在 t0的状态如何,总存在 t1> t0,锁在时刻 t1必定处于“未被持有”的状态,那么在时刻 t1允许锁申请操作,不是 A 就是别的线程获得锁。如果 A 永远不能获得锁,说明锁一旦处于“未被持有”的状态,就选择了别的线程提交的锁申请操作,那么某个或某些线程必然无限次地获得锁。

上面提到读写自旋锁有一种选择未执行的操作的能力,即选择函数 S,正是这个函数的差异,导致锁展现不同属性:

1. 读者优先。在任意时刻 t,如果锁处于状态“读者持有”,S 以大于 0 的概率选择一个尚未执行的“读者申请”操作。这意味着:首先,即使有先提交但尚未执行的“写者申请”操作,“读者申请”操作可以被优先执行;其次,没有刻意规定如何选“读者申请”操作,因此多个“读者申请”操作间的执行顺序是不确定的;最后,不排除连续选择“读者释放”操作,使得锁状态迅速变为“未被持有”,只不过这种几率很小。

2. 写者优先。在任意时刻 t,如果 o1是尚未执行的“写者申请”操作,o2是尚未执行的“读者申请”或“写者申请”操作,且 o1在 o2之前提交,那么 S 保证一定在 o2之前选择 o1

3. 无饥饿。如果线程提交了操作 o,那么 S 必定在有限时间内选择 o。即存在时刻 t,读写自旋锁在 t 的执行序列 < qI1,oI1,qI2,oI2,…,oIn,qI(n+1)> 满足 o = oIn。狭义上, o 限定为“读者申请”或“写者申请”操作。

4. 公平。如果操作 o1在 o2之前提交,那么 S 保证一定在在 o2之前选择执行 o1。狭义上,o1和 o2限定为“读者申请”或“写者申请”操作。

       读写自旋锁的实现细节

      上节阐述的自动机模型是个抽象的机器,用于帮助我们理解读写自旋锁的工作原理,但是忽略了很多实现的关键细节:

1. 操作的执行者。如果按照上节的描述,为读写自旋锁创建专门的操作执行线程,那么锁的实际性能将会比较低下,因此我们要求申请线程自己执行提交的操作。

2. 操作类别的区分。可以提供多个调用接口来区分不同种类的操作,避免使用额外变量存放类别信息。

3. 确定操作的提交顺序,即线程的到来的先后关系。写者优先和公平读写自旋锁需要这个信息。可以有 3 种方法:

  1. 假定系统有一个非常精确的实时时钟,线程到来的时刻用于确定顺序。但是寻找直接后继者比较困难,因为事先无法预知线程到来的精确时间。
  2. 参考银行的做法,即每个到来的线程领取一张号码牌,号码的大小决定先后关系。
  3. 将线程组织成一个先进先出(FIFO)的队列,具体实现可以使用单向链表,双向链表等。

4. 在状态 q,确定操作(线程)是否被允许执行。这有 2 个条件:首先 q 必须允许该操作;其次对于写者优先和公平读写自旋锁,不存在先提交但尚未执行的写者(读者 / 写者)申请操作。可以有 3 种方法:

  1. 不停地主动查询这 2 个条件。
  2. 被动等待前一个执行线程通知。
  3. 主动 / 被动相结合。

5. 选择执行的线程。在状态 q,如果存在多个被允许执行的线程,那么它们必须达成一致(Consensus),保证只有一个线程执行成功,否则会破坏锁状态的一致性。有 2 种简单方法:

  1. 互斥执行。原子指令(总线级别的互斥),或使用锁(高级互斥原语)。
  2. 投机执行。线程不管三七二十一先执行再说,然后检查是否成功。如果不成功,可能需要执行回滚操作。

6. 因为多个读者可以同时持有锁,那么读者释放锁时,有可能需要知道自己是不是最后一个持有者(例如通知后面的写者)。一个简单的方法是用共享计数器保存当前持有锁的读者数目。如果我们对具体数目并不关心,只是想知道计数器是大于 0 还是等于 0,那么用一种称为“非零指示器”(Non-Zero Indicator)的数据结构效果更好。还可以使用双向链表等特殊数据结构。

本系列文章所论述的算法只关注共享内存的系统。相关代码全部用 C 语言编写,主要目的是为了印证读写自旋锁的原理,故而性能并非最优,有兴趣的读者朋友可以尝试用汇编语言改写。代码中出现的 atomic_t 数据结构,cpu_relax()、atomic_*() 等函数引自 Linux 内核 [1]。

读写自旋锁的接口

    我们将操作种类集合的 6 种操作定义为各种读写自旋锁提供给用户调用的通用接口函数:

  1. init_lock(),初始化锁。
  2. destroy_lock(),析构锁。
  3. reader_lock(),读者申请获取锁。
  4. reader_unlock(),读者释放锁。
  5. writer_lock(),写者申请获取锁。
  6. writer_unlock(),写者释放锁。

       一般而言,destroy_lock() 可以简单地将动态分配的锁结构释放掉,或调用 init_lock() 将锁的状态置为初始值;且在系统的存活期内,读写自旋锁不会被析构。所以在后续文章的代码中,我们没有附上 destroy_lock() 的实现。

读者优先的读写自旋锁

      我们先不考虑性能,搞出一个可用的实现再说。首先,用一个整型变量 status 来记录当前状态;另一个整型变量 nr_readers 来记录同时持有锁的读者数量,只有当 nr_readers 为 0 的时候,锁才被读者彻底释放。此外不需要额外变量。

其次,我们使用高级互斥原语-普通的自旋锁,决定线程的执行顺序。读写自旋锁居然在内部使用普通自旋锁,这看起来有点古怪,还能够提高读者的并发性么?

我们需要留心的是,从合适状态出现到取得自旋锁之间可能发生状态转换,所以取得自旋锁之后还需检查一下当前状态。

清单 1. 基于自旋锁的读者优先实现
 #define STATUS_AVAILABE 0  #define STATUS_READER 1  #define STATUS_WRITER 2  typedef struct {  volatile int status;  volatile int nr_readers;  spinlock_t sl;  } rwlock_t;  void init_lock(rwlock_t *lock)  {  lock->status = STATUS_AVAILABE;  lock->nr_readers = 0;  spin_lock_init(&lock->sl);  }  void reader_lock(rwlock_t *lock)  {  while (TRUE) {  while (lock->status == STATUS_WRITER)  cpu_relax();  spin_lock(&lock->sl);  if (lock->status != STATUS_WRITER) {  if (lock->status == STATUS_AVAILABE)  lock->status = STATUS_READER;  lock->nr_readers++;  spin_unlock(&lock->sl);  return;  }  spin_unlock(&lock->sl);  }  }  void reader_unlock(rwlock_t *lock)  {  spin_lock(&lock->sl);  if (--lock->nr_readers == 0)  lock->status = STATUS_AVAILABE;  spin_unlock(&lock->sl);  }  void writer_lock(rwlock_t *lock)  {  while (TRUE) {  while (lock->status != STATUS_AVAILABE)  cpu_relax();  spin_lock(&lock->sl);  if (lock->status == STATUS_ AVAILABE) {  lock->status = STATUS_WRITER;  spin_unlock(&lock->sl);  return;  }  spin_unlock(&lock->sl);  }  }  void writer_unlock(rwlock_t *lock)  {  spin_lock(&lock->sl);  lock->status = STATUS_AVAILABE;  //(a)  spin_unlock(&lock->sl);  }

如果底层体系结构能原子地执行代码 (a),那么无需先取得内部的自旋锁。上述实现内部使用了一把大锁,故而正确性容易得到保证,下面我们分析一下不足之处:

  1. 锁数据结构用到的 3 个域:lock,status 和 nr_readers,即使它们可以放到同一缓存行(Cache Line)中,多条非连续的写指令也可能带来较多的缓存无效化开销。
  2. 如果线程访问共享资源的操作相对短小,那么锁自身的开销会比较大。此时对于读者而言,其并发性基本被内部的自旋锁限制。
  3. 至少 reader_unlock() 要先获得内部的自旋锁,所以无法保证在较短的时间内结束(理论上可能永远无法结束),导致整体吞吐量降低。

      上述代码的主要问题是变量和生成的指令太多,无法高效地执行。一个很自然的改进想法是把多个变量合并成单一变量,这样就有可能用一条原子指令更新状态 [3],而无需使用高级同步原语。同时也能使得锁的释放操作在有限步骤内完成,于是保证获得锁的线程必然在有限时间内将锁释放掉(后文列出的代码均满足这一特性)。我们观察到:

  1. status 只需要 2 个可能值,因为 nr_readers 大于 0 即可表示锁被读者持有,因此 status 用一个 bit 即可。
  2. status 和 nr_readers 可以合并成一个变量。
  3. 锁被写者持有时,nr_readers 也可以用于记录等待的读者数目。

       基于上述 3 点,我们将锁的数据结构简化为一个整型成员 rdr_cnt_and_flag。rdr_cnt_and_flag 最低位代表 status,其余位代表 nr_readers(当然也可以用最高位代表 status):

  1. rdr_cnt_and_flag 等于 0,表示锁无人持有。
  2. rdr_cnt_and_flag 大于 0 且最低位为 0,表示有 rdr_cnt_and_flag / 2 个读者同时持有锁。
  3. rdr_cnt_and_flag 等于 1,表示锁为写者持有且无等待读者。
  4. rdr_cnt_and_flag 大于 0 且最低位为 1,表示锁为写者持有且有 (rdr_cnt_and_flag - 1) / 2 个等待读者。
清单 2. 基于简单共享变量的读者优先实现
 #define WAFLAG 1  #define RC_INCR 2  typedef struct {  atomic_t rdr_cnt_and_flag;  } rwlock_t;  void init_lock(rwlock_t *lock)  {  lock->rdr_cnt_and_flag = ATOMIC_INIT(0);   //(a)  }  void reader_lock(rwlock_t *lock)  {  atomic_add(RC_INCR, &lock->rdr_cnt_and_flag);  //(b)  while ((atomic_read(&lock->rdr_cnt_and_flag) & WAFLAG) != 0)  //(c)  cpu_relax();  }  void reader_unlock(rwlock_t *lock)  {  atomic_sub(RC_INCR, &lock->rdr_cnt_and_flag);  //(d)  }  void writer_lock(rwlock_t *lock)  {  while (atomic_cmpxchg(&lock->rdr_cnt_and_flag, 0, WAFLAG) != 0) //(e)  while (atomic_read(&lock->rdr_cnt_and_flag) != 0)  //(f)  cpu_relax();  }  void writer_unlock(rwlock_t *lock)  {  atomic_dec(&lock->rdr_cnt_and_flag);  //(g)  }

       这个实现明显优于前者,每个函数的原子指令数和总指令数都非常少。reader_lock() 只执行一条原子加法指令,系统开销相当之小,而且不必像某些实现那样在尝试失败的情况下需要执行一个回滚操作(例如 Linux 内核实现的读写自旋锁)。

我们给出代码正确性的简要证明:

  1. 互斥。我们以 reader_lock() 和 writer_lock() 成功前最后一条原子操作(atomic_read(&lock->rdr_cnt_and_flag) 和 atomic_cmpxchg(&lock->rdr_cnt_and_flag, 0, 1))的执行顺序作为锁的获得顺序。假定在时刻 t,有 A1,A2,…,An这 n(> = 1) 个线程同时持有锁,满足:在 A1之前获得锁的线程此时都已释放锁;对于 1 <= k <= (n – 1),Ak在 A(k+1)前获得锁。可能存在线程 B,B 在某个 Ai和 A(i+1)之间获得锁,但在时刻 t,B 已经释放锁。分情况讨论:
    1. A1是读者。如果 A2– An中有写者,假定 Am是第一个写者,由于锁释放后线程对 rdr_cnt_and_flag 的总贡献为 0,Am执行到代码 (e) 时因为尚有读者持有锁,rdr_cnt_and_flag 必定大于 0 且最低位是 0,因此 Am无法跳出该处的循环。可见 A2– An中没有写者。
    2. A1是写者。A1获得锁的前提条件是 rdr_cnt_and_flag 等于 0,也就是说在 A1之前获得锁的线程已经释放了锁。如果 n > 1,不论 A2角色如何,A2都无法通过代码 (c) 或 (f),因为此时 rdr_cnt_and_flag 必然等于 1。可见 n 只能等于 1。

综上可知,读者和写者不可能同时持有锁,任何时刻至多只有一个写者持有锁。

  1. 读者并发。从代码 (b) - (c) 可看出,只要没有写者持有锁,多个读者都能结束循环,从而获得锁。
  2. 无死锁。从锁申请角度来证明,假定申请线程为 A,分情况讨论:
    1. A 是读者,如果在代码 (c) 处循环,这说明某个写者持有锁,但是写者必然在有限时间内将锁释放,届时 rdr_cnt_and_flag 的最低位必然为 0,那么 A 将立即获得锁。
    2. A 是写者,如果无法获得锁,说明 A 在代码 (e) 或 (f) 处循环。这表明总是有读者或写者持有锁,但是持有者迟早得释放锁,只能说明某个或某些线程必定无限次地获得锁。
  3. 读者优先。新来的读者并不检查是否有等待的写者,当读者持有锁时,显然能够通过 (c) 的条件,马上获得锁;锁被写者持有或未被持有时,新来的读者通过代码 (b) 实现了“加塞”,能够抢占先来的等待写者;

这个实现的不足之处有 4 点:

  1. 读者在任何情况下都能“加塞”到等待写者之前,如果读者持续到来,写者很难有机会获得锁。
  2. 如果几乎“同时”申请锁的读者的数目要远多于写者,执行完代码 (b) 后读者获得锁的概率总是比较大,那么随后检查共享变量 rdr_cnt_and_flag 的代价将比较大,因为该变量被连续改写,需等到缓存更新后才能取到最新值。
  3. 如果在写者持有锁期间,读者持续到来,那么 rdr_cnt_and_flag 会被不断修改,增加读者间的缓存同步开销。
  4. 读者必须执行额外的逻辑与操作才能知道是否有写者持有锁。

       如果我们用 rdr_cnt_and_flag 的最高位表示 status,其余位代表 nr_readers,那么有写者持有锁时,rdr_cnt_and_flag 必然是个负数(因为不可能同时有 0x8000000 个或更多的读者“同时”申请锁),检查起来比较快捷,于是上述 2、4 不足之处可以得到改进。代码如下:

清单 3. 基于简单共享变量的读者优先实现 2
 #define WAFLAG (int)0x80000000  #define RC_INCR 1  typedef struct {  atomic_t rdr_cnt_and_flag;  } rwlock_t;  void init_lock(rwlock_t *lock)  {  lock->rdr_cnt_and_flag = ATOMIC_INIT(0);         //(a)  }  static inline int my_atomic_inc_negative(atomic_t *v)  {  unsigned char c;  asm volatile(LOCK_PREFIX "incl %0; sets %1"     : "+m" (v->counter), "=qm" (c)      : : "memory");  return c;  }  void reader_lock(rwlock_t *lock)  {  int sign = my_atomic_inc_negative(&lock->rdr_cnt_and_flag); //(b)  if (sign) //(c)  while (atomic_read(&lock->rdr_cnt_and_flag) < 0) //(d)  cpu_relax();  }  void reader_unlock(rwlock_t *lock)  {  atomic_dec(&lock->rdr_cnt_and_flag); //(e)  }  void writer_lock(rwlock_t *lock)  {  while (atomic_cmpxchg(&lock->rdr_cnt_and_flag, 0, WAFLAG) != 0) //(f)  while (atomic_read(&lock->rdr_cnt_and_flag) != 0)  //(g)  cpu_relax();  }  void writer_unlock(rwlock_t *lock)  {  atomic_add(-WAFLAG, &lock->rdr_cnt_and_flag);  //(h)  }

       因为 nr_readers 在低 31 位,读者到来时使用原子递增指令即可,比原子加法指令要快。执行完毕后我们可以先观察一下 EFLAGS 或 RFLAGS 寄存器的 SF 位,如果为 0,说明 rdr_cnt_and_flag 的新值是非负数(只能是正数,参见前面的描述),即说明没有写者持有锁,这比再次检查 rdr_cnt_and_flag 更高效。我们把这 2 个操作合并在一个 my_atomic_inc_negative 内联函数中,用汇编指令实现。

写者优先读写自旋锁

      我们在清单 2 的代码基础上实现写者优先的读写自旋锁,关键之处是用号码分配的方式确定写者的到来顺序 [3]。使用 2 个整型变量,一个用于存放下一个分配的写者号码 writer_requests,一个用于存放下一个允许执行的写者号码 writer_completions。初始化的时候,将二者均置为 0。

      有兴趣的读者朋友也可以在清单 3 的代码基础上实现写者优先的读写自旋锁。

      写者到来的时候以当前的 writer_requests 值作为自己的号码 id,并原子地递增 writer_requests。当 id == writer_completions 时,表明先来的写者已经全部离开,但是可能有读者持有锁,因此写者还得检查持有锁的读者数目 nr_readers 是否为 0。写者释放锁的时候,增加 writer_completions,通知下一个等待写者。对写者 W 而言,所谓在 W 后面到来的申请线程是指在 W 获得号码之后开始执行 reader_lock() 和 writer_lock() 的线程。

对读者而言,当 writer_requests == writer_completions 时,表明当前已经没有写者,即无写者持有锁,也无等待写者,但这并不表示读者可以马上获得锁,因为可能在准备尝试获取锁的时候又有新的写者到来并获得锁。

清单 4. 基于简单共享变量的写者优先实现
 #define WAFLAG 1  #define RC_INCR 2  typedef struct {  atomic_t rdr_cnt_and_flag __cacheline_aligned_in_smp;  atomic_t writer_requests __cacheline_aligned_in_smp;  atomic_t writer_completions __cacheline_aligned_in_smp;  } rwlock_t;  void init_lock(rwlock_t *lock)  {  lock->rdr_cnt_and_flag = ATOMIC_INIT(0);  lock->writer_requests = ATOMIC_INIT(0);  lock->writer_completions = ATOMIC_INIT(0);  }  void reader_lock(rwlock_t *lock)  {  while (atomic_read(&lock->writer_completions) !=  //(a)  atomic_read(&lock->writer_requests))  cpu_relax();  atomic_add(RC_INCR, &lock->rdr_cnt_and_flag);          //(b)  while ((atomic_read(&lock->rdr_cnt_and_flag) & WAFLAG) != 0)    //(c)  cpu_relax();  }  void reader_unlock(rwlock_t *lock)  {  atomic_sub(RC_INCR, &lock->rdr_cnt_and_flag);          //(d)  }  void writer_lock(rwlock_t *lock)  {  int id = atomic_inc_return(&lock->writer_requests);  //(e)  while (atomic_read(&lock->writer_completions) != id)  //(f)  cpu_relax();  while (atomic_cmpxchg(&lock->rdr_cnt_and_flag, 0, WAFLAG) != 0) //(g)  while (atomic_read(&lock->rdr_cnt_and_flag) != 0)  cpu_relax();  }  void writer_unlock(rwlock_t *lock)  {  atomic_dec(&lock->rdr_cnt_and_flag);  //(h)  lock->writer_completions.counter++;  //(i)  }

       代码 (i) 不用写成原子递增操作 atomic_inc(&lock->writer_completions),因为此时有且仅有一个线程对 writer_completions 赋值。atomic_t 结构中的 counter 域有 volatile 关键字修饰,所以 (i) 一旦执行完毕,其它处理器就能感知 writer_completions 的新值。代码 (h) 是个原子操作,隐含了一个内存屏障,所以 (h) 的执行效果必定发生在 (i) 之前。

      如果写者的数目和到来的频率较大,那么 writer_requests 和 writer_completions 这 2 个共享变量也会被频繁修改,如果它们和 rdr_cnt_and_flag 被放置在同一缓存行中,将增加处理器间缓存的同步开销。解决这种伪共享(False Sharing)问题的一种简单优化方法是用 __cacheline_aligned_in_smp 宏将锁结构中的 3 个共享变量放置在不同的缓存行中。

      从代码 (e) 可知,每个写者都会取得一个号码 id,id 从 0 开始,中间显然不会遗漏,申请的顺序就是取得 id 的顺序,也就是说 id 小的写者较早申请。我们可以假定 Wn是 id 为 n 的写者,不失一般性,可以认为每个写者都是不同的。我们以 reader_lock() 和 writer_lock() 成功前最后一条原子操作(atomic_read(&lock->rdr_cnt_and_flag) 和 atomic_cmpxchg(&lock->rdr_cnt_and_flag, 0, 1))的执行顺序作为锁的获得顺序。

      后面的证明还依赖这个事实:对于任意 n >= 0,Wn必定在有限时间内执行一次对 writer_completions 的递增操作,将其从 n 变为 (n + 1),且当 n > 0 时,W(n – 1)在 Wn完成该操作。证明请参阅下节关于公平读写自旋锁的论述。

      我们给出代码正确性的简要证明:

  1. 互斥。和读者优先的读写自旋锁的证明一样。
  2. 读者并发。从代码 (b) - (c) 可看出,只要先到达的写者都已离开且当前没有写者持有锁,多个读者都能结束循环,从而获得锁。
  3. 无死锁。从锁申请角度来证明,假定申请线程为 A,分情况讨论:
    1. A 是读者,通过了代码 (a) 处循环。这说明通过时已无写者,但当执行代码 (b) 前,可能有新的写者到来并获得锁,不过一旦其释放,A 必定能获得锁。
    2. A 是读者,无等待写者,锁被写者持有,导致无法通过代码 (a) 处循环。不过锁迟早被释放,因此 A 在有限时间内能通过 (a) 处循环,与上种情况相同。
    3. A 是读者,至少有一个等待写者,导致无法通过代码 (a) 处循环,等待写者可能是比 A 晚来的。假定号码最小的等待写者是 B,我们证明 B 或别的线程能获得锁,请参考 d。
    4. A 是写者,前面无等待写者。如果锁被写者持有,锁一旦释放后,A 就能跳出代码 (f) 处的循环。锁处于其它 2 种状态时,A 当然能通过 (f)。不过此时 A 不一定能通过代码 (g) 处的循环,因为可能已经有读者持有锁或者比 A 早到的等待读者抢先获得了锁。但是这些持有锁的读者数目是有限的,且必定在有限时间内释放锁,因此 A 一定能获得锁。
    5. A 是写者,前面有至少一个等待写者。假定号码最小的等待写者是 B,我们证明 B 或别的线程能获得锁,请参考 d。
  4. 写者优先。一旦写者 W 完成代码 (e) 而获得分配的号码,那么在释放锁之前,writer_completions 必定小于 writer_requests,那么后到的读者不能退出代码 (a) 处的循环,可见 W 一定在后到的读者之前获得锁。同理,后到的写者会在代码 (f) 处不断循环,W 一定在后到的写者之前获得锁。

公平读写自旋锁

       现实中,公平读写自旋锁可以保证当线程提交一个申请操作后,其等待时间有个可控的上界。这个优良特性,往往受到用户的青睐。

笔者在上节基础上实现了这个公平读写自旋锁。还是使用号码分配的方式为所有的读者 / 写者定序,但是这有个缺陷:线程无法知道直接后继的角色。如果读者 A 的直接后继 B 是个读者,那么 A 获得锁后,B 应该马上获得锁,而不是等到 A 释放锁之后。A 如何及时通知 B 呢?我们采用“事不关己,高高挂起”的态度,即读者获得锁后,立刻增加 completions 值,剩下的事丢给后继者自行处理。如果直接后继是写者咋办?写者此时还应判断是否仍有读者持有锁,即检查 nr_readers 是否大于 0。对写者而言,释放锁的时候才增加 completions 值。

清单 5. 基于简单共享变量的公平实现
 typedef struct {  atomic_t requests __cacheline_aligned_in_smp;  atomic_t completions __cacheline_aligned_in_smp;  atomic_t nr_readers __cacheline_aligned_in_smp;  } rwlock_t;  void init_lock(rwlock_t *lock)  {  lock->requests = ATOMIC_INIT(0);  lock->completions = ATOMIC_INIT(0);  lock->nr_readers = ATOMIC_INIT(0);  }  void reader_lock(rwlock_t *lock)  {  int id = atomic_inc_return(&lock->requests);  //(a)  while (atomic_read(&lock->completions) != id)          //(b)  cpu_relax();  atomic_inc(&lock->nr_readers);  //(c)  lock->completions.counter++;  //(d)  }  void reader_unlock(rwlock_t *lock)  {  atomic_dec(&lock->nr_readers);  //(e)  }  void writer_lock(rwlock_t *lock)  {  int id = atomic_inc_return(&lock->requests);  //(f)  while (atomic_read(&lock->completions) != id)          //(g)  cpu_relax();  while (atomic_read(&lock->nr_readers) > 0)  //(h)  cpu_relax();  }  void writer_unlock(rwlock_t *lock)  {  lock->completions.counter++;  //(i)  }

       代码 (c) 必须用原子递增操作,因为此时可能有读者正在执行代码 (e) 释放锁。(d) 和 (i) 不用写成原子递增操作,因为此时有且仅有一个线程对 completions 赋值。由于所有的线程均在 completions 这个共享变量上自旋、写者还需在 nr_readers 上自旋且读者不断修改 nr_readers,因此处理器间的同步开销比较大,将这 3 个共享变量放置在不同的缓存行中可以提高性能。

      从代码 (a) 和 (f) 可知,每个申请线程都会取得一个号码 id,id 从 0 开始,中间显然不会遗漏,申请的顺序就是取得 id 的顺序,也就是说 id 小的线程较早申请。我们可以假定 An是 id 为 n 的申请线程,不失一般性,可以认为每个申请线程都是不同的。

先证明:对于任意 n >= 0,An必定在有限时间内执行一次对 completions 的递增操作,将其从 n 变为 (n + 1),且当 n > 0 时,A(n – 1)在 An完成该操作。采用第二数学归纳法:

  1. n 等于 0、1 或 2 时,显然成立。
  2. 假设 n < k(k >= 2) 时成立。
  3. 当 n = k 时,只有当 A(k – 1)执行完对 completions 的递增操作后, completions 才变为 k。此时 A1,…,A(k – 1)已不能再对该变量进行操作,而 Ak之后的线程要么还没到来,要么在代码 (b) 或 (g) 处循环,只有 Ak才有可能跳出这两处的循环。如果 Ak是读者,那么它很快就可以对 completions 执行递增操作;如果 Ak是写者,它还得先等 nr_readers 为 0。nr_readers 必定在有限时间内变为 0,因为读者迟早要释放锁。然后 Ak在有限时间内执行 writer_lock() 时将 completions 递增。不论哪种情况,Ak必定在有限时间内执行一次对 completions 的递增操作,将其从 k 变为 (k + 1)。显然 Ak的执行在 A(k – 1)之后。

       因为线程对 completions 的递增操作完成时意味着已经获得了锁,所以上面这个命题告诉我们申请线程必定在有限时间内获得锁,且获得锁的顺序和取得 id 的顺序一致,即“无死锁”和“公平”。

       对于“互斥”性,我们假定在时刻 t,有 AI1,AI2,…,AIn这 n(> = 1) 个线程同时持有锁,且在 AI1之前获得锁的线程此时都已释放锁。因为申请线程依次获得锁,所以 I1 < I2 < … < In。可能存在线程 B,B 在某个 Ai和 A(i+1)之间获得锁,但在时刻 t,B 已经释放锁。分情况讨论:

  1. AI1是读者。如果 AI2– AIn中有写者,假定 AIm是第一个写者,由于锁释放后线程对 nr_readers 的总贡献为 0(实际上写者根本不修改这个变量),AIm执行到代码 (g) 时因为尚有读者持有锁,nr_readers 必定大于 0,因此 AIm无法跳出该处的循环。可见 AI2– AIn中没有写者。
  2. AI1是写者。因为写者持有锁的时候并不对 completions 执行递增操作,所以此时 AI1之后的申请线程不可能获得锁,于是 n 只能等于 1。

       综上可知,读者和写者不可能同时持有锁,任何时刻至多只有一个写者持有锁。

一旦读者 A 获得锁,由代码 (d) 知它会递增 completions,如果 A 的直接后继 B 是个读者,那么 B 可以跳出代码 (b) 处的循环,从而获得锁。可见“读者并发”也是成立的。

基于简单共享变量的读写自旋锁的不足

      本系列文章的第 2 部分中给出的实现都基于简单共享变量,简洁实用,但在大规模多核、NUMA 系统上可扩展性较差。我们说某个读写自旋锁的实现是可扩展的,通俗地讲是指在线程访问模式(读者写者数目之比、各自到来的频率及持有锁的时间)不变的前提下增加处理器的个数,线程的吞吐量(单位时间内获得锁的线程数目)也随之大幅增加。如果能接近线性(甚至由于缓存的影响使得超线性)增加,我们说该实现是高可扩展的。

基于简单共享变量的读写自旋锁的可扩展性较差本质上是因为操作共享变量的代价过大:如果系统不能保证缓存的一致性,读写共享变量导致总线的流量激增;即使提供了一致性的缓存,频繁的写操作也会增大处理器间的缓存同步开销。具体而言有 3 点:

  1. 所有等待线程均在某个(些)共享变量上自旋。
  2. 线程需要通过共享变量来分享一些信息,比如需要一个 nr_readers 的整型变量记录同时持有锁的读者。
  3. 这些共享变量频繁被修改。

因此我们可以针对上述 3 点,分别提出相应的改进策略:

  1. 所有等待线程均在局部变量上自旋。
  2. 尽量减少共享变量的数目。
  3. 如果必须使用共享变量,那么考虑使用该变量的可扩展实现。

基于单向链表的公平读写自旋锁

首先我们考虑如何让等待线程只在局部变量上自旋,这涉及四个问题:

  1. 线程到来时是否需要自旋?如果锁被写者持有或尚有有等待线程,显然新来的线程只能选择自旋等待;如果锁无人持有,线程无需自旋;如果只有读者持有且新来的线程也是读者,那么为了提高并发性,新来的读者最好不用等待。
  2. 谁负责通知某个等待线程结束自旋?这个问题比较容易回答,应该由持有锁的线程负责通知。
  3. 何时通知?持有锁的写者必须在释放锁的时候才能通知下一个候选持有者:而读者在持获得锁后应该检查一下下一个候选者是否也是读者,若是,则立即通知。
  4. 如何通知?这个问题要求我们能够拥有某种能力可以找到下一个候选持有者,即需要使用特殊的数据结构。

       我们很容易想到可以用单向链表将申请线程组织成一个队列,每个线程能够通过指针寻找到自己的所有后继,而且线程申请锁的顺序与线程在队列中的位置保持一致,因此用单向链表可以实现一个公平的读写自旋锁 [3]。

凭空想象算法似乎有些困难,我们还是先在草稿纸上画一个示意图,有了感性认识之后再考虑数据结构和算法的细节。

图 1. 基于单向链表的公平读写自旋锁示意图

       从图上可以看到,为了构建单向链表,每个线程必须有一个自己私有的链表节点数据结构,里面至少包含三个域:自身角色 type、自旋变量 waiting 和指向直接后继的 next 指针。我们还需要一个指向链表尾部的指针 tail,新到的线程通过它可以获得其直接前驱,从而将自己插入链表。这个 tail 指针应当放到锁的数据结构中。

值得注意的是,插入链表这个动作并不是原子的,至少需要 2 个操作:原子地获得 tail 指针并将其指向自己;将直接前驱的 next 指针指向自己。但是线程的直接前驱随时可能释放锁,并继续申请锁而重用其数据结构,因此需要约束线程的行为以保证单向链表的一致性。我们规定:线程释放锁之前必须检查自身是否存在直接后继线程,如果存在则保证在直接后继插入链表之后才能释放锁。这可以通过检查 tail 和 next 指针实现。

连续的读者可以同时持有锁,但是它们并不一定按照申请的顺序释放锁,故而难以在释放锁的时候继续保持这些读者间的链表结构。这是因为我们使用的是单向链表,释放锁的读者很难用简单高效的方法将其节点数据结构从链表中摘下 [4](即使保存了直接前驱的指针,也可能已经失效),而且该读者也许需要继续申请锁而重用该数据结构,导致其尚未释放锁的直接前驱读者也不能再使用 next 指针遍历后继。单向链表的结构被破坏带来如下 2 个挑战:

  1. 从图 2 可以看到,如果 A1– A3都释放锁的时候,应该通知 A4这个写者。但是 A1– A3如何知道自己是最后一个持有者?
  2. 如果 A2知道自己最后释放锁,但是 A4并不是它的直接后继,A2如何通知 A4呢?

       一种直观简单的解决方案是用一个计数器 nr_readers 记录当前同时持有锁的数目,读者获得锁前原子递增 nr_readers;释放锁的时候原子递减,如果递减后 nr_readers 为 0,表示自己此刻是最后一个持有锁的读者。此外还需要一个记录等待线程中的第一个写者的指针 next_writer。这两个变量也应当放到锁的数据结构中。

读者并发还带来一个棘手的问题。还是以图 2 为例,当读者 A1获得锁时,如果此时知道 A2已经到来了(通过检查自己的 next 指针),那么应该由 A1通知 A2。但是可能 A2到来的时候 A1已经在访问共享资源或者正在释放锁,那么 A2应该自行获得锁。因为获得锁涉及到原子递增 nr_readers,所以 A1和 A2必须达成一致。我们的方案是:读者到来时先检查其直接前驱读者是否还在自旋,若是,则原子地在其前驱的节点数据结构中标记一个特殊值。如果标记成功,表明前驱尚未结束自旋,那么由前驱负责通知;如果标记失败,说明前驱已经退出自旋而获得锁,那么读者直接获得锁。标记值可以选得有意义一些,这儿我们选择线程的角色,于是线程很容易知道直接后继的类型,而不用通过 next 指针进一步查看。我们在节点数据结构中增加变量 successor_type。

       下面我们详细阐述读写自旋锁的算法。假设申请线程为 A。

A 是读者,申请锁:

  1. A 使用原子交换操作将读写自旋锁的 tail 指针指向自己的 qnode 结构以确定在链表中的位置,并返回原来的 tail 值作为自己的直接前驱 pred。即使多个线程同时申请锁,由于交换操作的原子性,每个执行线程的申请顺序将会被唯一确定,不会出现不一致的现象。
  2. 如果 pred 等于 NULL,说明锁无人持有或只有读者持有(A 的直接前驱是读者,且已经释放锁),那么 A 可以立即持有锁,跳到第 4 步。
  3. 此时 pred 不等于 NULL。如果 pred 是写者,A 只能依赖 pred 在释放锁的时候通知自己,所以 A 所要做的只是将 pred->next 指向自己的,然后自旋等待。如果 pred 是读者,那么需要按照前面描述的那样,向 pred 标记 A 的读者角色。如果标记成功,则取得锁,跳到第 4 步;否则自旋等待。
  4. A 准备结束函数调用,但还需要检查一下自己的 successor_type 变量,如果直接后继是读者,那么先等它将链表构建完整,然后通知其结束自旋。

A 是读者,释放锁:

  1. A 先检查是否存在直接后继。这可以用原子比较 tail 指针是否还是指向自己并更新为 NULL 的操作实现。如果成功地将 tail 指针更新为 NULL,说明没有直接后继,那么跳到第 3 步。
  2. A 有直接后继 B,先需要等待 B 将链表构建完整,然后检查 B 是不是写者,若是,则将锁结构中的 next_writer 指针指向 B。
  3. A 判断自己是否是最后一个持有者,若是,且 next_writer 指针不为 NULL,则应该通知 next_writer。具体做法是:A 原子递减 nr_readers,如果 nr_readers 新值大于 0,说明还有读者持有锁,于是 A 直接拍拍屁股走人。如果 nr_readers 新值等于 0,这并不能说明 A 在后面通知 next_writer 时还是最后一个持有者,因为在 A 执行后续操作的间隙中可能依次来了新的读者 C 和 D。假定读者 D 以极快的速度获得并执行释放锁,将 tail 指针置为 NULL。此刻又来了写者 W,因为 W 发现 tail 为 NULL,那么 W 必须将 next_writer 置为自己,否则 A 或 D 无法通知 W。现在轮到 A 继续执行了,A 发现 next_writer 不为 NULL,于是试图通知 W 结束自旋,但是 C 仍然持有锁,导致错误。这个例子说明通知 next_writer 前还需要再检查一下 nr_readers 的值,如果 nr_reader 仍然等于 0,才能说明是最后一个持有锁的读者。如果 C 也迅速释放锁,那么 A 和 C 可能同时试图通知 W。通知一个已经退出自旋的线程是危险的,因为该线程可能重新申请锁,而导致提前退出自旋。因此 A 通知 W 时,需要检测 next_writer 指针是否没有变化并原子地将其置为 NULL,这样 C 会发现已经有人通知过 W 了。

A 是写者,申请锁:

  1. A 使用原子交换操作将读写自旋锁的 tail 指针指向自己的 qnode 结构以确定在链表中的位置,并返回原来的 tail 值作为自己的直接前驱 pred。
  2. 如果 pred 等于 NULL,说明锁无人持有或仍有读者持有。A 先将 next_writer 指向自己,然后检查 nr_readers,如果 nr_readers 大于 0,说明尚有读者持有锁,那么自旋等待。如果 nr_readers 等于 0,也有可能某个(些)读者正在释放锁,因此 A 需要检测 next_writer 指针是否仍然指向自己并原子地将其置为 NULL。如果成功置为 NULL,获得锁并返回;否则等待前面的读者通知自己。
  3. 如果 pred 不等于 NULL,那么 A 标记自己的角色并将链表构建好,然后自旋等待。

A 是写者,释放锁:

  1. A 先检查是否存在直接后继。这可以用原子比较 tail 指针是否还是指向自己并更新为 NULL 的操作实现。如果成功地将 tail 指针更新为 NULL,说明没有直接后继,那么直接返回。
  2. A 有直接后继 B,先需要等待 B 将链表构建完整,然后通过 next 指针通知 B 结束自旋。如果 B 是读者,还需要先原子递增 nr_readers。

       具体代码如清单 5 所示,有兴趣的读者朋友可以尝试证明一下正确性。

清单 1. 基于单向链表的公平读写自旋锁的实现
 #define NONE 0  #define READER 1  #define WRITER 2  typedef struct _qnode{  int type;  union {  volatile int state;  //(1)  struct {  volatile short waiting;  volatile short successor_type;  };  };  _qnode *volatile next;  } __cacheline_aligned_in_smp qnode;  typedef struct {  qnode *volatile tail;  qnode *volatile next_writer;  atomic_t nr_readers;  } rwlock_t  void init_lock(rwlock_t *lock)  {  lock->tail = NULL;  lock->next_writer = NULL;  lock->nr_readers = ATOMIC_INIT(0);  }  void reader_lock(rwlock_t *lock)  {  qnode *me = get_myself();     //(2)  qnode *pred = me;  me->type = READER;  me->next = NULL;  me->state = 1;// successor_type == NONE && waiting == 1  xchg(&lock->tail, pred);  if (pred == NULL) {  atomic_inc(&lock->nr_readers);  me->waiting = 0;  } else {  If ((pred->type == WRITER)  || (cmpxchg(&pred->state, 1, 0x00010001) == 1)} { //(3)  pred->next = me;  while (me->waiting)  cpu_relax();  } else {  atomic_inc(&lock->nr_readers);  pred->next = me;  me->waiting = 0;  }  }  if (me->successor_type == READER) {  while (me->next == NULL)  cpu_relax();  atomic_inc(&lock->nr_readers);  me->next->waiting = 0;  }  }  void reader_unlock(rwlock_t *lock)  {  qnode *w;  qnode *me = get_myself();  if ((me->next != NULL)              || (cmpxchg(&lock->tail, me, NULL) != me)) {              while (me->next == NULL)          cpu_relax();              if (me->successor_type == WRITER)          lock->next_writer = me->next;  }  if ((atomic_dec_return(&lock->nr_readers) == 1)       && ((w = lock->next_writer) != NULL)               && (atomic_read(lock->nr_readers) == 0)               && (cmpxchg(&lock->next_writer, w, NULL) == w)){           w->waiting = 0;  }  void writer_lock(rwlock_t *lock)  {  qnode *me = get_myself();          qnode *pred = me;  me->type = WRITER;  me->next = NULL;  me->state = 1;  xchg(&lock->tail, pred);  if (pred == NULL) {  lock->next_writer = me;  if ((atomic_read(lock->nr_readers) == 0)       && (cmpxchg(&lock->next_writer, me, NULL) == me))           me->waiting = 0;  } else {                 pred->successor_type = WRITER;                 smp_wmb();  //(4)  pred->next = me;          }          while (me->waiting)  cpu_relax();  }  void writer_unlock(rwlock_t *lock)  {  qnode *me = get_myself();  if ((me->next != NULL)              || (cmpxchg(&lock->tail, me, NULL) != me)) {              while (me->next == NULL)          cpu_relax();      if (me->next->type == READER)          atomic_inc(&lock->nr_readers);              me->next->waiting = 0;  }  }
  1. 使用 union 结构是为了方便后面将 successor_type 和 waiting 合在一起做原子操作。
  2. 如果事先知道线程的数目,例如代码用于中断上下文,qnode 可以包装在 lock 数据结构中,每个线程一个;否则,可以使用线程本地存储区(Thread Local Storage,使用 gcc 关键字 __thread)分配 qnode。我们不关心 qnode 的分配细节,假定有个 get_myself() 函数可以获得当前线程的 qnode。
  3. cmpxchg 原子操作将 [successor_type,waiting] 原子地从 [NONE,TRUE] 改变为 [READER,TRUE]。
  4. 此处的“Write Memory Barrier”目的是确保对 successor_type 的赋值先完成。因为这两个赋值没有相关性,如果处理器乱序执行写指令,且对 next 的赋值先完成,那么链表就已构建完毕,前驱可能随时释放锁导致 pred 指针失效。

进一步提高读写自旋锁的可扩展性

       基于单向链表的读写自旋锁并不完美,因为线程还是得操作额外的共享变量 nr_readers 和 next_writer,这是由于读者释放锁的时候无法继续保持单向链表的结构。一种改进想法是使用双向链表 [5],因为双向链表的插入和删除操作能够做得相对高效。于是读者释放锁的时候可以将自己的节点结构从双向链表中删除,从而继续保持链表的结构。读者通过判断前驱指针是否为 NULL 就知道自己是不是最后一个持有者,而且也再不需要 next_writer 指针。但是该算法中对双向链表的操作使用了节点级别的细粒度普通自旋锁,在连续读者较多且几乎同时释放读写自旋锁的情况下,同步开销依然巨大。

       第二种想法是为每个线程分配一个局部的普通自旋锁,读者只需获得自己的自旋锁即可,而写者必须将包括自己在内的所有人的自旋锁都获得才行 [6]。这个算法显然是个读者优先的实现,在写者较少的情况下可扩展性相当理想。不足之处有两点:一是写者得知道其它线程的自旋锁在哪儿,因此比较适用于固定线程的场景;其次是读者数目越多,对写者也就越不公平。

我们看到:一般而言,在一段可观测的时间内,读者数量远远大于写者,很多时候甚至没有写者。因此在基于单向链表的实现中,只有共享变量 nr_readers 才是一个明显的瓶颈。进一步分析可知,我们其实并不需要知道 nr_readers 的具体值,只是想了解 nr_readers 是否大于 0 还是等于 0,于是 Sun 的研究人员提出使用一种称为可扩展非零指示器(Scalable Non-Zero Indicator)的数据结构,大大降低了线程间的同步开销。

0 0
原创粉丝点击