一直都对 Linux 的锁机制很感兴趣,也花过一段时间对各种类型的锁进行研究。本文对类似自旋锁、互斥锁、信号量的基本锁不进行深入介绍也默认读者能够通过查找迅速获得这些锁的一个基本使用场景和原理。很多时候,我们需要更为细粒度的锁,譬如对于读多写少的情况,我们希望读者与读者之前不需要锁,让他们能够尽快的 Pass,这个时候我们就需要用到一种更为快速的锁那就是读写锁,读写锁的设计逻辑就是读者与读者之间不需要互斥,而写者与写者或写者与读者之间需要上锁等待,这样,在大量的读的情况下,读的速率将会大大提升,当然,写的速率不能说下降太多,毕竟他的性能消耗与互斥锁/信号量的消耗是一样的。
读写锁的出现的确解决了不少的问题,相对于互斥等也没有降低太多写的效率,反而大大提升了读的效率。但是,读写锁机制上,读者虽然与读者不需要锁,然而读者还是需要与写者互斥,也就是在大量的写的情况下,读者部分并没有性能提升,反而增加了代码复杂度,得不偿失。为了彻底的解决读与写互斥问题,计算机大佬们引入了一种新的机制,那就是 RCU(Read-Copy Update)。
RCU 锁机制的原理
RCU 顾名思义就是读-拷贝-更新,所以就是基于他的名字命名的,对于 RCU 所保护的共享数据结构,读者不需要获得任何锁就可以访问他,但是写者在访问他时需要首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时候把指向原来数据结构的指针重新指向新的被修改了的数据,这个时机就是所有引用该数据的 CPU 都退出对该共享数据结构的访问。
所以 RCU 是一种改进型的读写锁,他不会让读者产生任何的锁竞争不存在任何开销,除了 alpha 架构之外,连内存屏障(Memory Barrier)都不需要,所以不会导致内存延迟,流水线停滞。另外一方面,由于不需要锁,所以使用起来也很方便,不需要考虑死锁的问题;但是对于写者而言,他的开销很比读写锁大,他需要延迟数据结构的释放以及复制被修改的数据结构,对于一些嵌入式设备等对内存消耗很敏感的场景可能不太适合,此外,写者与写者之前还需要采取额外的锁机制来互斥其他写者的并行操作。前文提到了写者在合适的时机通过 callback 来修改指针的指向,那么读者必然需要在特定的时候发出信号通知写者,垃圾收集器通过调用注册的回调来完成最后的数据释放和修改操作。RCU 和 rwlock 的不同之处在于:他即允许多个读者同时访问保护的数据,也允许多个读者和多个写者同时访问被保护的数据(PS:是否可以有多个写者并行访问取决于写者之间的同步机制,一般都是 spinlock),读者没有任何开销,而写者的同步开销取决于本身的同步机制。但是 RCU 不能代替 rwlock,因为如果写比较多,对读者的性能提高并不能弥补对写者的损失。
读者在访问被 RCU 保护的共享数据期间不能被阻塞,这是 RCU 机制实现的一个基本前提,也就是读者所在的 CPU 不能发生上下文切换,spinlock 和 rwlock 都有这样的前提条件
以下举例说明这一过程:
写者要从链表中删除元素 B,首先遍历该链表得到指向元素 B 的指针,然后修改元素的前一个元素的 next 指针指向元素 C,修改元素 B 的 next 指向的 C 元素的 prep 指针从指向 B 修改到指向 A,这期间有可能有读者访问该链表,修改指针指向的操作是原子,所以不需要同步,而元素 B 的指针并没有去修改,因为读者可能正在使用 B 元素来得到他的前一个元素或者后一个元素。写者完成这些操作之后注册一个回调函数以便在 grace period 之后删除元素 B,然后就认为已经完成了删除操作。垃圾收集器在检测到所有的 CPU 不再引用该链表后,即所有的 CPU 已经经历了 quiescent state 就表示 grace period 已经过去,可以调用写者注册的回调函数删除元素 B。
RCU 的实现机制
前文讲过对于读者,RCU 需要禁止 CPU 发生上下文切换,其实也就是无法抢占,那么获得读锁和释放锁的定义如下:
#define rcu_read_lock() preempt_disable()
#define rcu_read_unlock() preempt_enable()
当然,他还有另外一种变种:
#define rcu_read_lock_bh() local_bh_disable()
#define rcu_read_unlock_bh() local_bh_enable()
这个变种只在被保护的共享数据的修改是通过 call_rcu_bh 进行的情况下使用,因为 call_rcu_bh 将把 softirq 的执行完毕也认为是一个 quiescent state,因此如果修改是通过 call_rcu_bh 进行的,在进程上下文的读端临界区必须使用这一变种。
每一个 CPU 维护两个数据结构 rcu_data, rcu_bh_data,他们用于保存回调函数,函数 call_rcu 和函数 call_rcu_bh 用户注册回调函数,前者把回调函数注册到 rcu_data,后者将回调函数注册到 rcu_bh_data 中,在每一个数据结构上,回调函数被组成一个链表,采用先进先出 FIFO。
当 CPU 上发生上下文切换,函数 rcu_qsctr_inc 将被调用以标记该 CPU 已经经历了一次 quiescent state。该函数也会被时钟中断触发。
时钟中断触发垃圾收集器运行,他会检查:
是否在该 CPU 上有需要处理的回调函数并且已经经过了一个 grace_period
是否没有需要处理的回调函数但是有注册的回调函数
是否该 CPU 已经完成了回调函数的处理
是否该 CPU 正在等待一个 quiescent state 的到来
如果上面四个函数只要有一个满足,他将调用函数 rcu_check_callbacks,这个函数会检查 CPU 是否经历了一个 quiescent state,如果:(1)当前处于进程运行在用户态;(2)当前进程为 idle 且当前不处于运行 softirq 状态,也不再运行 IRQ 处理函数状态。那么该 CPU 已经经历了一个 quiescent state,因此通过调用函数 rcu_qsctr_inc 标记该 CPU 的数据结构 rcu_data 和 rcu_bh_data 的标记字段 passed_quiesc,已记录该 CPU 经历了一个 quiescent state;否则,如果当前不处于运行 softirq 状态,那么指标及该 CPU 的数据结构 rcu_bh_data 的标记字段 passed_quiesc,已记录该 CPU 已经经历了一个 quiescent state(这个标记只对 rcu_bh_data 有效)。然后,函数 rcu_check_callbacks 将调用 tasklet_schedule,他将调度为该 CPU 设置的 tasklet rcu_tasklet,每一个 CPU 都有一个对应的 rcu_tasklet。在时钟中断返回后,rcu_tasklet 将在 softirq 中断上下文运行。
rcu_tasklet 将运行函数 rcu_process_callbacks,该函数可能会运行:
开始一个新的 grace period;这通过函数 rcu_start_batch 实现
运行需要处理的回调函数;这通过调用函数 rcu_do_batch 实现
检查该 CPU 是否经历了一个 quiescent state;这通过函数 rcu_check_quiescent_state 实现
如果还没有开始 grace period,就调用 rcu_start_batch 开始一个新的 grace period。调用函数 rcu_check_quiescent_state 检查该 CPU 是否经历了一个 quiescent state,如果是且是最后一个经历 quiescent state 的 CPU,那么就结束当前这个 grace period,并开始新的 grace period。如果有完成的 grace period,那么就调用 rcu_do_batch 运行所需要处理的回调函数,函数 rcu_process_callbacks 将对该 CPU 的两个数据结构 rcu_data 和 rcu_bh_data 执行上述操作。
整个过程看起来很复杂,其实他的最终目标就是要找到一个合适的时间点执行回调函数链表。
意思就是需要类似在读者线程在访问共享数据前需要挂壁抢占,通过调用 rcu_read_lock 这样的函数。 ↩︎