简介

RCU(Read-Copy Update),是 Linux 中比较重要的一种同步机制。顾名思义就是“读,拷贝更新”,再直白点是“随意读,但更新数据的时候,需要先复制一份副本,在副本上完成修改,最后再用新的数据替换掉旧的数据”。

这是 Linux 内核实现的一种针对“读多写少”的共享数据的同步机制。不同于其他的同步机制,它允许多个读者同时访问共享数据,而且读者的性能不会受影响(“随意读”),读者与写者之间也不需要同步机制(但需要“复制后再写”);但如果存在多个写者时,在写者把更新后的“副本”覆盖到原数据时,写者与写者之间需要利用其他同步机制保证同步。

这样讲似乎比较抽象,那么结合一个实例来看或许会更加直观。

假设有一个单向链表,其中包含一个由指针p指向的节点:

linux RCU自我理解_#define

现在,我们要使用RCU机制来更新这个节点的数据,那么首先需要分配一段新的内存空间(由指针q指向),用于存放这个copy。

linux RCU自我理解_链表_02

然后将p指向的节点数据,以及它和下一节点[11, 4, 8]的关系,都完整地copy到q指向的内存区域中。

linux RCU自我理解_数据_03

接下来,writer会修改这个copy中的数据(将[5, 6, 7]修改为[5, 2, 3])。

linux RCU自我理解_链表_04

修改完成之后,writer就可以将这个更新“发布”了(publish),对于reader来说就“可见”了。因此,pubulish之后才开始读取操作的reader(比如读节点[1, 2, 3]的下一个节点),得到的就是新的数据[5, 2, 3](图中红色边框表示有reader在引用)。

linux RCU自我理解_#define_05

而在publish之前就开始读取操作的reader则不受影响,依然使用旧的数据[5, 6, 7]。

linux RCU自我理解_链表_06

等到所有引用旧数据区的reader都完成了相关操作,writer才会释放由p指向的内存区域。

linux RCU自我理解_数据_07

可见,在此期间,reader如果读取这个节点的数据,得到的要么全是旧的数据,要么全是新的数据,反正不会是「半新半旧」的数据,数据的一致性是可以保证的。

应用场景

适用于多读少写(多读一写)的场景中;RCU主要针对的数据对象是链表,目的是提高遍历读取数据的效率,为了达到目的使用RCU机制读取数据的时候不对链表进行耗时的加锁操作。在 Linux kernel 中还专门提供了一个头文件(include/linux/rculist.h),提供了利用 RCU 机制对链表进行增删查改操作的接口。

基本概念

宽限期Grace period

linux RCU自我理解_#define_08

/* `p` 指向一块受 RCU 保护的共享数据 */

/* reader */
rcu_read_lock();
p1 = rcu_dereference(p);
if (p1 != NULL) {
printk("%d\n", p1->field);
}
rcu_read_unlock();

/* free the memory */
p2 = p;
if (p2 != NULL) {
p = NULL;
synchronize_rcu();
kfree(p2);
}

每个读者的方块表示获得 p 的引用(第5行代码)到读端临界区结束的时间周期;

t1 表示 p = NULL 的时间;t2 表示 synchronize_rcu() 调用开始的时间;t3 表示 synchronize_rcu() 返回的时间。

我们先看 Reader1,2,3,虽然这 3 个读者的结束时间不一样,但都在 t1 前获得了 p 地址的引用。

t2 时调用 synchronize_rcu(),这时 Reader1 的读端临界区已结束,但 Reader2,3 还处于读端临界区,因此必须等到 Reader2,3 的读端临界区都结束,也就是 t3,t3 之后,就可以执行 kfree(p2) 释放内存。

synchronize_rcu() 阻塞的这一段时间(t2~t3期间),有个名字,叫做 Grace period。

Reader4,5,6,无论与 Grace period 的时间关系如何,由于获取引用的时间在 t1 之后,都无法获得 p 指针的引用,因此不会进入 p1 != NULL 的分支。

注:rcu_read_lock和rcu_read_unlock,这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期是否结束。

 

 

RCU实现过程

RCU 通过保存对象的多个副本来保障读操作的连续性,并保证在预定的读方临界区没有完成之前不会释放这个对象。RCU定义并使用高效、可伸缩的机制来发布并读取 对象的新版本,并延长旧版本们的寿命(和读写锁相比,就是新的更改延迟生效)。

在RCU的实现过程中,我们主要解决以下问题:

  • 等待已有的RCU读者完成 (用于删除)
  • 在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。
  • 订阅发布机制 (用于插入)
  • 在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。
  • 保证读取数据(链表)的完整性。
  • 新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。

订阅发布机制

RCU的一个关键特性是它可以安全地扫描数据,即使数据正被同时改写也没问题。要提供这种并发插入的能力,RCU使用了一种订阅发布机制。

举例说,考虑一 个被初始化为 NULL 的全局指针变量 gp 将要被修改为新分配并初始化的数据结构。

struct foo {
int a;
int b;
int c;
};
struct foo *gp = NULL;


写操作:
/* . . . */

p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;


读操作:
p = gp;
if (p != NULL) {
do_something_with(p->a, p->b, p->c);
}

 

写操作:

不幸的是,没有方法强制保证编译器和CPU能顺序执行最后四条语句。如果gp的赋值早于p的各个域的初始化的话,那么并发的读操作将访问到未初始化的变 量。内存屏障(barrier)可以用于保障操作的顺序,但内存屏障以难以使用而闻名。这样我们将他们封装到具有发布语义的 rcu_assign_pointer() 之中。最后的四条将成为这样:

p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gp, p);

#define __rcu_assign_pointer(p, v, space) \
({ \
smp_wmb(); \
(p) = (typeof(*v) __force space *)(v); \
})

rcu_assign_pointer() 将会发布新的结构,强制编译器和CPU在给p的各个域赋值之后再把指针赋值给gp。

 

读操作:

看起来不会受到顺序错乱的影响,不过十分不幸,是在有些机器(文中举例:DEC Alpha CPU机器)上或者值预测编译优化的情况下,fp->a,fp->b,fp->c会在p = gp还没执行的时候就预先判断运行,当他和foo_update同时运行的时候,可能导致传入dosomething 的一部分属于旧的gbl_ foo,而另外的属于新的。这样导致运行结果的错误

rcu_read_lock(); p1 = rcu_dereference(p); if (p1 != NULL) { // do something with p1, such as: printk("%d\n", p1->field); } rcu_read_unlock(); #define rcu_dereference(p) rcu_dereference_check(p, 0) #define __rcu_dereference_check(p, c, space) \ ({ \ typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \ rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" \ " usage"); \ rcu_dereference_sparse(p, space); \ smp_read_barrier_depends(); \ ((typeof(*p) __force __kernel *)(_________p1)); \ }) 第 3 行:声明指针 _p1 = p; 第 7 行:smp_read_barrier_depends(); 第 8 行:返回 _p1;

根据 rcu_dereference() 的实现,最终效果就是把一个指针赋值给另一个,那如果把上述第 2 行的 rcu_dereference() 直接写成 p1 = p 会怎样呢?在一般的处理器架构上是一点问题都没有的。 

但在 alpha 上,编译器的 value-speculation 优化选项据说可能会“猜测” p1 的值,然后重排指令先取值 p1->field~ 因此 Linux kernel 中,smp_read_barrier_depends() 的实现是架构相关的,arm、x86 等架构上是空实现,alpha 上则加了内存屏障,以保证先获得 p 真正的地址再做解引用。

 

rcu_read_lock();
p = rcu_dereference(gp);
if (p != NULL) {
do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();

根据 rcu_dereference() 的实现,最终效果就是把一个指针赋值给另一个,那如果把上述第 2 行的 rcu_dereference() 直接写成 = p 会怎样呢?在一般的处理器架构上是一点问题都没有的。


#define rcu_dereference(p) rcu_dereference_check(p, 0)

#define rcu_dereference_check(p, c) \
__rcu_dereference_check((p), (c) || rcu_read_lock_held(), __rcu)

#define __rcu_dereference_check(p, c, space) \
({ \
/* Dependency order vs. p above. */ \
typeof(*p) *________p1 = (typeof(*p) *__force)lockless_dereference(p); \
RCU_LOCKDEP_WARN(!(c), "suspicious rcu_dereference_check() usage"); \
rcu_dereference_sparse(p, space); \
((typeof(*p) __force __kernel *)(________p1)); \
})

#ifdef __CHECKER__
#define rcu_dereference_sparse(p, space) \
((void)(((typeof(*p) space *)p) == p))
#else /* #ifdef __CHECKER__ */
#define rcu_dereference_sparse(p, space)
#endif /* #else #ifdef __CHECKER__ */

#define lockless_dereference(p) \
({ \
typeof(p) _________p1 = READ_ONCE(p); \
typeof(*(p)) *___typecheck_p __maybe_unused; \
smp_read_barrier_depends(); /* Dependency order vs. p above. */ \
(_________p1); \
})

实现比较搞,最重要的还是smp_read_barrier_depends()这个函数,其实也就是加了优化屏障。

rcu_dereference() 原语可以被看作是订阅了指针指向的值,保证接下来的取值操作将会看到对应的发布操作(rcu_assign_pointer())发生之前被初始化的值。

rcu_read_lock() 和 rcu_read_unlock() 是需要的:他们定义了 RCU 读方临界区的范围。他们的目的将在下一节 解释,不过,他们不会自旋或阻塞,也不阻止 list_add_rcu() 的并发执行。事实上,对于非抢占内核,它们不产生任何代码。

现在我们就知道了,rcu_assign_pointer是发布,而rcu_dereference是订阅,合起来就是发布订阅机制。

虽然 rcu_assign_pointer() 和 rcu_dereference() 在理论上可以用于构建任意 RCU 保护的数据结构,但实际上,使用高层构造常常更好。因此,rcu_assign_pointer() 和 rcu_dereference() 原语被嵌入到了 Linux 的链表维护 API 中的特殊 RCU 变量之中了。

linux RCU自我理解_#define_09

读取数据的完整性

linux RCU自我理解_链表_10

在原list中加入一个节点new到A之前,所要做的第一步是将new的指针指向A节点,第二步才是将Head的指针指向new。

这样做的目的是当插入操作完成第一步的时候,对于链表的读取并不产生影响,而执行完第二步的时候,读线程如果读到new节点,也可以继续遍历链表。

如果把这个过程反过来,第一步head指向new,而这时一个线程读到new,由于new的指针指向的是Null,这样将导致读线程无法读取到A,B等后续节点。从以上过程中,可以看出RCU并不保证读线程读取到new节点。

 

linux RCU自我理解_#define_11

如图我们希望删除B,这时候要做的就是将A的指针指向C,保持B的指针,然后删除程序将进入宽限期检测。

由于B的内容并没有变更,读到B的线程仍然可以继续读取B的后续节点。B不能立即销毁,它必须等待宽限期结束后,才能进行相应销毁操作。由于A的节点已经指向了C,当宽限期开始之后所有的后续读操作通过A找到的是C,而B已经隐藏了,后续的读线程都不会读到它。这样就确保宽限期过后,删除B并不对系统造成影响。

 

RCU指针操作

RCU 指针API

RCU链表操作

增加链表项

参见:​​https://zhuanlan.zhihu.com/p/30583695​

访问链表项

删除链表项

linux RCU自我理解_#define_12

以grace period为界,整个更新操作被划分为了"removal"和"reclamation"两个阶段,writer的角色也被对应地划分为了updaterreclaimer

还是用链表操作的这个例子,removal阶段将一个节点从链表中移除,而等待所有reader解除对该节点的引用后,就进入回收/释放这个节点所占内存的reclamation阶段。

 

更改链表项

RCU 链表API

RCU和读写锁对比

相对于传统的在并发线程间不区分是读者还是写者的简单互斥性锁机制,或者是哪些允许并发读但同时不 允许写的读写锁,RCU 支持同时一个更新线程和多个读线程的并发。

参考

​https://cloud.tencent.com/developer/article/1054094​

​https://zhuanlan.zhihu.com/p/30583695​

​https://zhuanlan.zhihu.com/p/89439043​