引言
读-复制更新(Read-Copy Update,RCU)是一种同步机制,于2002年10月被加入到Linux内核中。RCU最常被描述为读写锁的一种替代方案,但它也被以多种其他方式加以应用。RCU的显著特点是:RCU读者并不直接与RCU更新者进行同步,这使得RCU的读路径极其快速,并且允许RCU读者在与RCU更新者并发执行时依然能够完成有用的工作。
由此引出了一个问题:“究竟什么是RCU?”本文档将从Linux内核中RCU API的角度来回答这一问题。
1.RCU拥有一系列“等待完成”的应用程序编程接口(API)
2.RCU具有发布-订阅和版本维护的API
3.那么,到底什么是RCU?
这些问题之后将紧接着是参考文献部分以及对快速小测验的答案。
RCU拥有一系列“等待完成”的应用程序编程接口
对于“什么是RCU”这个问题,最直接的回答是:RCU是一种在Linux内核中使用的应用程序编程接口(API),本节中的两个表格对此进行了概括(第一个表格展示了RCU读取者等待部分的API,第二个表格展示了发布/订阅相关的API)。或者更准确地说,RCU是一组API家族,如第一个表格所示,其中每一列对应RCU API家族的一个成员。
如果你刚开始接触RCU,建议你先专注于下表中的某一列。例如,如果你主要想了解RCU在Linux内核中的使用方式,“RCU经典(RCU Classic)”是一个不错的起点,因为它被使用的频率最高。另一方面,如果你想从本质上理解RCU,“顺序化RCU(SRCU)”的API是最为简单的。其余的列可以留到以后再深入了解。
如果你已经熟悉RCU,那么下面这两个表格将可以作为非常实用的参考内容。
属性 | RCU Classic(经典RCU) | RCU BH(Bottom Half RCU) | RCU Sched(调度器RCU) | Realtime RCU(实时RCU) | SRCU(可睡眠RCU) | QRCU(快速RCU) |
---|---|---|---|---|---|---|
用途 | 原始设计 | 防止DDoS攻击 | 等待硬中断和NMI | 实时响应 | 支持读侧睡眠 | 支持读侧睡眠且宽限期延迟极低 |
可用性 | 2.5.43 | 2.6.9 | 2.6.12 | 2005年8月加入-rt补丁 | 2.6.19 | 尚未合并进主线内核 |
读端原语 |
rcu_read_lock() rcu_read_unlock() |
rcu_read_lock_bh() rcu_read_unlock_bh() |
preempt_disable() preempt_enable() 及其相关函数 |
rcu_read_lock() rcu_read_unlock() |
srcu_read_lock() srcu_read_unlock() |
qrcu_read_lock() qrcu_read_unlock() |
更新端同步原语 |
synchronize_rcu() synchronize_net() |
—— | synchronize_sched() |
synchronize_rcu() synchronize_net() |
synchronize_srcu() | synchronize_qrcu() |
更新端异步/回调原语 | call_rcu() | call_rcu_bh() | —— | call_rcu() | N/A | N/A |
等待所有回调完成原语 | rcu_barrier() | rcu_barrier() | —— | —— | N/A | N/A |
读端限制 | 不允许阻塞 | 不允许启用中断 | 不允许阻塞 | 可被抢占,获取锁时可能阻塞 | 不允许在srcu_read_lock()中调用synchronize_srcu() | 不允许在qrcu_read_lock()中调用synchronize_qrcu() |
读端开销 | 抢占启用/禁用(非PREEMPT下无开销) | BH启用/禁用 | 抢占启用/禁用(非PREEMPT下无开销) | 简单指令,中断启用/禁用 | 简单指令,抢占启用/禁用 | 原子变量递增/递减 |
异步更新端开销(如call_rcu()) | <1微秒 | <1微秒 | —— | <1微秒 | N/A | N/A |
宽限期延迟(grace period latency) | 几十毫秒 | 几十毫秒 | 几十毫秒 | 几十毫秒 | 几十毫秒 | 在无读者时为几十纳秒 |
非PREEMPT_RT下的实现 | 经典RCU | RCU BH | 经典RCU | N/A | SRCU | N/A |
PREEMPT_RT下的实现 | N/A | 实时RCU | 约束在所有CPU上调度 | 实时RCU | SRCU | N/A |
快速小测验1:为什么上面表格中的一些单元格是绿的?
“RCU经典(RCU Classic)”列对应于最初的RCU实现,其中RCU读侧临界区由rcu_read_lock()和rcu_read_unlock()界定,这些函数可以嵌套使用。相应的同步更新端原语为synchronize_rcu(),以及其同义词synchronize_net(),它们会等待所有当前正在执行的RCU读侧临界区完成。这种等待的时间长度被称为“宽限期(grace period)”。异步更新端原语call_rcu()则会在接下来的一个宽限期之后调用一个指定的函数并传入指定的参数。例如,call_rcu(p,f); 会导致在下一个宽限期结束后调用“RCU回调函数”f(p)。在某些情况下,例如移除使用了call_rcu()的模块时,必须等待所有未处理的RCU回调函数完成。rcu_barrier()原语就是用来完成这项任务的。
在“RCU BH(Bottom Half)”列中,rcu_read_lock_bh()和rcu_read_unlock_bh()用于界定RCU读侧临界区,并且call_rcu_bh()会在下一个宽限期后调用指定的函数和参数。请注意,RCU BH没有提供同步接口synchronize_rcu_bh(),尽管如果需要的话可以很容易地添加一个。
快速小测验2:如果你混用不同的API会发生什么?例如,假设你使用rcu_read_lock()和rcu_read_unlock()来界定RCU读侧临界区,但随后使用call_rcu_bh()提交一个RCU回调函数?
在“RCU Sched”列中,任何禁用抢占的操作都会被视为RCU读侧临界区,而synchronize_sched()则会等待对应的RCU宽限期。这个RCU API家族是在2.6.12版本内核中加入的,它将旧的synchronize_kernel() API拆分为现在的synchronize_rcu()(用于RCU Classic)和synchronize_sched()(用于RCU Sched)。请注意,RCU Sched并没有提供异步的call_rcu_sched()接口,尽管如有需要也可以添加。
快速小测验3:如果你混合使用RCU Classic和RCU Sched会发生什么?
“实时RCU(Realtime RCU)”列的API与RCU Classic相同,唯一的区别在于RCU读侧临界区可以被抢占,并且在获取自旋锁时可能会阻塞。实时RCU的设计在LWN文章《The design of preemptible read-copy-update》中有详细描述。
快速小测验4:如果你混合使用Realtime RCU和RCU Classic会发生什么?
“SRCU”列展示了一种特殊的RCU API,允许在RCU读侧临界区中进行通用的睡眠操作,这一点在LWN的文章《Sleepable RCU》中有介绍。当然,在SRCU读侧临界区中使用synchronize_srcu()可能会导致自身死锁,因此应防止这样做。SRCU与早期RCU实现的不同之处在于,调用者需要为每个单独的SRCU用途分配一个srcu_struct结构。这种方法防止了SRCU读侧临界区阻塞不相关的synchronize_srcu()调用。此外,在这种RCU变体中,srcu_read_lock()返回一个值,该值必须传递给对应的srcu_read_unlock()函数。
“QRCU”列展示了一个与SRCU具有相同API结构的RCU实现,但在没有读者的情况下优化了极低延迟的宽限期,如LWN文章《Using Promela and Spin to verify parallel algorithms》中所述。与SRCU一样,使用synchronize_qrcu()也可能导致自身死锁,因此应防止。虽然QRCU尚未被接受进入Linux内核,但由于它是唯一一种能够实现亚微秒级宽限期延迟的RCU实现,因此值得一提。
快速小测验5:为什么SRCU和QRCU都没有提供异步的call_srcu()或call_qrcu()接口?
快速小测验6:在什么条件下可以在SRCU读侧临界区中安全地使用synchronize_srcu()?
目前Linux内核中存在数量惊人的多种RCU API及其实现。人们希望未来能减少这种多样性,这从以下事实可以看出:当前Linux内核的某次构建最多只有三种实现支持四个API(因为RCU Classic和Realtime RCU共享相同的API)。然而,要做到这一点仍需仔细检查和分析,就像我们对待众多锁定API时所做的那样。
RCU具有发布-订阅和版本维护的应用程序编程接口(API)
幸运的是,下表中展示的RCU发布-订阅和版本维护原语适用于之前讨论的所有RCU变体。这种共通性在某些情况下可以允许更多的代码共享,从而减少原本可能出现的API泛滥问题。
类别 | 原语 | 可用性 | 开销 |
---|---|---|---|
列表遍历 | list_for_each_entry_rcu() | 2.5.59 | 简单指令(Alpha上需内存屏障) |
列表更新 | list_add_rcu() | 2.5.44 | 内存屏障 |
list_add_tail_rcu() | 2.5.44 | 内存屏障 | |
list_del_rcu() | 2.5.44 | 简单指令 | |
list_replace_rcu() | 2.6.9 | 内存屏障 | |
list_splice_init_rcu() | 2.6.21 | 宽限期延迟 | |
哈希链表遍历 | hlist_for_each_entry_rcu() | 2.6.8 | 简单指令(Alpha上需内存屏障) |
哈希链表更新 | hlist_add_after_rcu() | 2.6.14 | 内存屏障 |
hlist_add_before_rcu() | 2.6.14 | 内存屏障 | |
hlist_add_head_rcu() | 2.5.64 | 内存屏障 | |
hlist_del_rcu() | 2.5.64 | 简单指令 |
第一组类别操作的是Linux中的struct list_head列表,这是一种循环双链表结构。list_for_each_entry_rcu()原语以类型安全的方式遍历受RCU保护的链表,同时确保在插入新元素与遍历并发进行时的内存顺序一致性。在非Alpha下,该原语相较于list_for_each_entry()几乎不产生额外性能开销。list_add_rcu()、list_add_tail_rcu()和list_replace_rcu()原语与其非RCU版本类似,但在弱序机器上会引入额外的内存屏障开销。list_del_rcu()原语也与其非RCU版本类似,但奇怪的是它略快于后者,因为它只“毒化”prev指针,而非像list_del()那样同时毒化prev和next指针。最后,list_splice_init_rcu()原语与其非RCU版本相似,但会引入一个完整的宽限期延迟。这个宽限期的目的是允许RCU读取者完成对源链表的遍历,然后再将其完全从链表头断开——如果省略这一步,可能导致这些读取者无法结束其遍历操作。
快速小测验7:为什么list_del_rcu()没有同时毒化next和prev指针?
第二组类别操作的是Linux中的struct hlist_head结构,这是一种线性链表。struct hlist_head相比struct list_head的一个优势在于,它只需要一个指针作为链表头,这在大型哈希表中可以节省大量内存。表中的struct hlist_head相关原语与其非RCU版本的关系与struct list_head原语类似。
最后一组类别直接作用于指针,可用于创建受RCU保护的非链表数据结构,例如受RCU保护的数组和树。rcu_assign_pointer()原语确保在弱序机器上,任何之前的初始化操作都会在指针赋值之前保持正确的顺序。同样地,rcu_dereference()原语确保后续代码在解引用该指针时,在Alpha CPU上能看到对应rcu_assign_pointer()之前初始化代码的效果。在非Alpha CPU上,rcu_dereference()则用于标记哪些指针解引用受到RCU保护。
快速小测验8:通常来说,任何需要通过rcu_dereference()解引用的指针都应该使用rcu_assign_pointer()来更新。这个规则有没有例外?
快速小测验9:由于这些遍历和更新原语可以与任意RCU API家族成员一起使用,这种灵活性是否带来了某些负面影响?
那么,究竟什么是RCU?
快速小测验的答案如下:
快速小测验1:为什么上面表格中的一些单元格是绿的?
答:绿的API成员(rcu_read_lock()、rcu_read_unlock()和call_rcu())是Paul E. McKenney在90年代中期所知道的唯一Linux RCU API成员。在此期间,他错误地认为自己了解了所有关于RCU的知识。
返回到快速小测验1。
快速小测验2:如果你混用不同的API会发生什么?例如,假设你使用rcu_read_lock()和rcu_read_unlock()来界定RCU读侧临界区,但随后使用call_rcu_bh()提交一个RCU回调函数?
答:如果恰好在调用call_rcu_bh()时没有由rcu_read_lock_bh()和rcu_read_unlock_bh()界定的RCU读侧临界区,那么RCU有权立即调用该回调函数,可能会释放仍然被RCU读侧临界区使用的数据结构!这不是理论上的可能性:由rcu_read_lock()和rcu_read_unlock()界定的长时间运行的RCU读侧临界区确实可能遭遇这种失败模式。
在-rt内核中,这种漏洞消失,因为在这种内核中,RCU Classic和RCU BH都映射到一个共同的实现上。
返回到快速小测验2。
快速小测验3:如果你混合使用RCU Classic和RCU Sched会发生什么
答:在非抢占式或抢占式内核中,这种混合“偶然”有效,因为在这些内核构建中,RCU Classic和RCU Sched映射到相同的实现。然而,在使用-rt补丁集的PREEMPT_RT构建中,这种组合是致命的,因为实时RCU的读侧临界区可以被抢占,这将允许synchronize_sched()在RCU读侧临界区到达其rcu_read_unlock()调用之前返回。这反过来可能导致数据结构在读侧临界区完成之前被释放,从而大大增加你的内核经历的精算风险。
实际上,RCU Classic和RCU Sched之间的分离是由对可抢占RCU读侧临界区的需求所激发的
返回到快速小测验3。
快速小测验4:如果你混合使用Realtime RCU和RCU Classic会发生什么?
答:这取决于你自己,因为你需要编码修改内核以使这样的混合成为可能。目前,任何运行RCU Classic的内核都无法访问实时RCU,反之亦然。
返回到快速小测验4
快速小测验5:为什么SRCU和QRCU都没有提供异步的call_srcu()或call_qrcu()接口?
答:考虑到异步接口,单个任务可以注册任意数量的SRCU或QRCU回调,从而消耗大量的内存。相比之下,当前的同步接口synchronize_srcu()和synchronize_qrcu()要求给定任务必须等待一个特定宽限期结束后才能开始等待下一个宽限期。
返回到快速小测验5。
快速小测验6:在什么条件下可以在SRCU读侧临界区内安全地使用synchronize_srcu()?
答:原则上,你可以使用与某个srcu_struct关联的synchronize_srcu()在一个使用其他srcu_struct的SRCU读侧临界区内使用。但在实践中,这样做几乎肯定是个坏主意。
返回到快速小测验6。
快速小测验7:为什么list_del_rcu()不毒化next和prev指针?
答:毒化next指针会干扰并发的RCU读取者,他们必须使用这个指针。然而,RCU读取者禁止使用prev指针,因此它可以安全地被毒化。
返回到快速小测验7。
快速小测验8:通常来说,任何需要通过rcu_dereference()解引用的指针都应该使用rcu_assign_pointer()来更新。这个规则有没有例外?
答:一种例外情况是在初始化一个多元素链接数据结构时,当该结构不可访问于其他CPU时作为一个整体进行初始化,然后使用单个rcu_assign_pointer()将全局指针指向此数据结构。初始化期间的指针分配不需要使用rcu_assign_pointer(),但是在此结构全局可见后的任何此类分配必须使用rcu_assign_pointer()。
返回到快速小测验8。
快速小测验9:由于这些遍历和更新原语可以与任意RCU API家族成员一起使用,这种灵活性是否带来了某些负面影响?
答:对于自动化代码检查工具如“sparse”(或者实际上是人类)来说,有时很难确定给定的RCU遍历原语对应哪类RCU读侧临界区。例如:
rcu_read_lock();
preempt_disable();
p = rcu_dereference(global_pointer);
/* . . . */
preempt_enable();
rcu_read_unlock();
这里的rcu_dereference()原语是在RCU Classic还是RCU Sched临界区内?你需要做什么来确定这一点?