Linux中的RCU机制(read-copy-updata)
RCU(read-copy-updata)是Linux中比较重要的一种同步机制。RCU是一种极端的非对称读/写同步机制,**允许读操作在没有任何锁及原子操作的情况下进行。**这意味着在更新操作进行的时候读操作也可能在运行。读者临界代码不需要承担任何同步开销,不需要锁,不需要原子指令。在除alpha外的平台,还不需要内存屏障指令。
写者临界代码必须承担住主要的同步开销,为了适应读者的临界代码的要求,写着必须延迟销毁数据结构和维护数据结构的多个版本,必须使用同步机制–加锁,提供排序的更新。
读者必须提供信号,让写者决定何时完成数据结构销毁操作。(因为RCU需要保证每个读者读取到数据在修改前是一致的,所以数据的更新操作会延迟,延迟到什么时候,需要由读者来决定,当读者读取完数据退出临界区时,告知写者,这时写者就能够开始写操作)但是该信号可能被延迟,允许一个单信号操作服务多个读者RCU临界代码。RCU通常用非原子操作地增加一个本地计数器的方法发信号给写者,该方法开销很小(因为没有原子操作,所以可能会有一定的延迟)。
一种特殊的立即回收器用于观察读者发给写者的信号,一旦所有读者已发信号表示可以安全销毁时,垃圾回收器才开始执行销毁操作。垃圾回收器通常以类似于屏障计算的方式实现,或者在NUMA系统用一个联合树的形式实现。
**RCU通过延迟写操作来提供同步性能。**系统中数据读取操作远多于写操作,而RWlock(读写锁)机制在SMP对称多处理器环境下随着处理器增多性能会迅速下降,RCU机制改善了这种情况,**RCU机制的核心是写操作分为 “写” 和 “更新” 两步,**允许读操作在任何时候无阻碍访问,当系统有写操作是,更新动作一直延迟到对该数据的所有读操作完成为止。
-
补充一下:什么是smp对称多处理器对称多处理器结构 , 英文名称为 " Symmetrical Multi-Processing " , 简称 SMP ;
SMP 又称为 UMA , 全称 " Uniform Memory Access " , 中文名称 " 统一内存访问架构 " ;
在 " 对称多处理器结构 " 的 系统中 , 所有的 CPU 处理器 的 地位 都是 平等的 , 一般指的是 服务器 设备上 , 运行的 多个 CPU , 没有 主次/从属 关系 , 都是平等的 ;
这些处理器 共享 所有的设备资源 , 所有的资源 对 处理器 具有相同的 可访问性 , 如 : 磁盘 , 内存 , 总线 等 ; 多个 CPU 处理器 共享相同的物理内存 , 每个 CPU 访问相同的物理地址 , 所消耗的时间是相同的 ;
【Linux 内核】SMP 对称多处理器结构 ( SMP 对称多处理器结构概念 | SMP 对称多处理器结构的优势与缺陷 | Linux 内核兼容多处理器要求 )_smp结构-CSDN博客
RCU机制相关的基本概念
-
Live Variable
:一个变量在它被修改前可能被访问到,因此它的当前值可能影响到将来运行状态。 -
Dead Variable
:一个变量在它下一次被访问前将被修改,因此它的当前值对将来的运行状态没有影响。 -
Temporary Variable
:仅生存在临界部分的变量。 -
Permanent Variable
:一个在临界部分外的变量。 -
Read-Side Critical Section
:保护禁止其他CPU修改的代码区域,但允许多个CPU同时读。 -
Quiescent State
:一个线程的执行状态,在此期间,线程(执行写操作的线程)不持有对任何RCU保护的数据结构的引用。 -
Candidate Quiescent State
:它是代码中的一个点,在这个点上,所有在此线程以前使用的Temporary Variable
都是Dead Variable
。在所有RCURead-Side Critical Section
外面任何点都是该状态。 -
Observed Quiescent State
:尽管不可能观察所有的候选静止状态,但是还是可以很容易地观察部分候选静止状态,因为它们与一个永久操作系统状态的改变相关。(这个概念不是很懂,给大家看看gpt的回答)
gpt:RCU的 observed quiesecent state 是指在 RCU 中,读操作可以观察到的一种状态。当一个读操作开始时,它会首先检查是否存在一个观察到的静默状态(quiesecent state)。如果存在,那么读操作可以继续执行,否则需要等待直到观察到了一个静默状态。 观察到的静默状态(observed quiesecent state)是指在该状态下,没有任何正在进行的写操作对读操作产生影响。也就是说,读操作可以安全地访问数据,而不会受到并发写操作的干扰。 RCU通过一种被称为“延迟回收”的技术来实现 观察到的静默状态。延迟回收允许在没有引用的情况下延迟释放资源,以避免对正在进行的对操作造成影响。
-
Grace Period
宽限期:**这是RCU最重要的一个概念。**RCU提供了一个宽限期给并发访问(由RCU提供,但是宽限期的长短是有读者决定的),宽限期是指所有CPU当前正执行的RCU读操作的临界部分完成的时间(可能现在单纯看概念还不是很懂,没关系,后面会画个图来直观地呈现)。
RCU的组成
RCU由三个基本机制组成:
- 订阅发布机制 – 用于插入
- 等待已有的RCU读者完成 – 用于删除
- 维护多个最近更新的对象的版本 – 为读者维护
发布订阅机制
RCU的发布订阅机制是通过内存屏障(Memory Barrier)来实现的。
发布订阅机制两个关键的api
rcu_assign_pointer()
rcu_dereference()
1 struct foo {
2 int a;
3 int b;
4 int c;
5 };
6 struct foo *gp = NULL;
7
8 /* . . . */
9
10 p = kmalloc(sizeof(*p), GFP_KERNEL);
11 p->a = 1;
12 p->b = 2;
13 p->c = 3;
14 gp = p;
在上面的例子中,可以很明显的发现一些代码之间的依赖关系:p->a = 1
p->b = 2
p->c = 3
这三条指令都依赖于p = kmalloc(sizeof(*p), GFP_KERNEL);
的执行,所以在cpu的指令流水线上,后者一定会比前三者先进入流水线中;从可见性的角度上看就是,后面的指令对于前三者是可见的,后者对前三者是顺序执行。但是p->a = 1
p->b = 2
p->c = 3
这三条指令之间并没有依赖关系,包括后面的 gp = p
,所以,cpu执行这三条指令的时候(可能这三条指令还会被拆成多个微指令)是 乱序执行 的。
那它们乱序执行会出什么问题呢?假设现在先执行了 gp = p;
此时,在 p 的成员属性并没有被赋值,如果这个时候这个线程访问了 gp 的成员属性,后果很明显。所以我们需要保证 gp = p;
一定要发生在最后,这就需要 内存屏障 了,RCU将相关的内存屏障的操作封装在 具有发布语义的rcu_assign_pointer()
原语中。所以我们可以这样做
p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gp, p);
rcu_assign_pointer()
的作用很简单,只是将第二个参数赋值给第一个参数。这个宏函数的关键是第三行,它在这里设置了一个内存屏障,保证了赋值之前,p一定是有效的。这里怎么理解发布订阅呢?我的理解是:rcu_assign_pointer(describer, publisher)
,第一个参数订阅第二个参数,describer
需要等待publisher
被发布之后才能进行访问,(发布订阅机制在很多网络中间件都有支持,mq,redis…),那publisher
什么时候才能发布呢?当然是在所有对 publisher
进行修改的操作执行完之后在发布。
下面贴出 rcu_assign_pointer()
的主要实现:
#define __rcu_assign_pointer(p, v, space) \
({ \
smp_wmb(); \
(p) = (typeof(*v) __force space *)(v); \
})
下面来看看比较有意思的,在linux kernel中的rculist的插入
#define list_next_rcu(list) (*((struct list_head __rcu **)(&(list)->next)))
static inline void __list_add_rcu(struct list_head *new,
struct list_head *prev, struct list_head *next)
{
// 复制写
new->next = next;
new->prev = prev;
// 更新
rcu_assign_pointer(list_next_rcu(prev), new);
next->prev = new;
}
需要注意的是:RCU的发布订阅机制只能保证写操作不影响读操作,读操作与读操作之间需要一些其他的同步机制进行同步。
在看下面的例子
p = gp;
if (p != NULL) {
do_something_with(p->a, p->b, p->c);
}
尽管这段代码看起来不会受到顺序错乱的影响,不过十分不幸,DEC Alpha CPU 和投机性编译器优化可能会引发问题,不论你是否相信,这的确有可能会导致 p->a
p->b
p->c
的读取会在读取 p 之前!这种情况在投机性编译器优化的情况中最有可能会出现,编译器会揣测p的值,取出 p->a
p->b
和 p->c
,之后取出 p 的真实值来检查拽侧的正确性。(这是很正常的,在cpu中通常会通过 乱序执行 和 预测 来提高流水线的执行效率,笔者对计算机组成原理理解的十分浅显,所以只能想到这句话了)。
这时候, rcu_dereference()
就能避免这种情况的发生
// ruc_read_lock() 和 rcu_read_unlock() 定义了读临界区的范围
rcu_read_lock();
p = rcu_dereferecne(gp);
if (p != NULL) {
do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();
现在我们来看看 rcu_derefence()
的主要实现:
#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)); \
})
不难看出,rcu_derefence()
作用的过程:先拿到参数的地址,设置一个内存屏障,再将拿到的地址复制给调用者。
等待已经存在的RCU读者完成
rcu_read_lcok()
rcu_read_unlock()
synchronize_rcu()
RCU最基本的功能是等待一些事情的完成。RCU最大的好处在于它可以等待所有不同点事件,而无需显式地跟踪它们中的每一个,也不需要担心性能的下降,可伸缩性限制、复杂度死锁场景、以及内存泄漏等所有这些显式跟踪手法固有的问题。
-
为什么RCU可以等待所有不同点事件,而无需显式地跟踪它们?
RCU的等待所有不同点事件的机制是通过使用内存屏障来实现的。内存屏障可以确保在某个事件点之后,所有对共享数据的读操作都使用了新的数据副本。当一个写操作完成后,它会插入一个屏障,然后等待所有已经开始的读操作完成。这样,写操作就可以等待所有不同点事件,而无需显式跟踪每一个读操作。
通过使用屏障和延迟内存资源的释放,RCU实现了高效的并发读写。它适用于读操作频繁,写操作相对较少的场景,可以提供系统的并发性能和吞吐量。
读临界区:一个RCU读临界区始于rcu_read_lock()
,止于rcu_read_unlock()
。还有一种特殊的SRCU
允许在它的读临界区中睡眠或阻塞。
现在我们分析一下 rcu_read_lock()
和 rcu_read_unlock()
:这两个宏函数用来规划出一个读临界区,多个读临界区并不互斥,(这个跟 rwlock 有点像)但是一块内存数据一旦能够在读临界区被获取到的指针引用,这块内存数据的释放必须等待到读临界区结束,通过使用 linux kernel api 的 synchronize_rcu()
。读临界区的检查是全局的,系统中有任何代码处于读临界区,synchronize_rcu()
都会阻塞。什么意思呢?可以这样理解:synchronize_rcu()
底层就是设置了一个屏障,它需要等待在设置这个屏障前的所有读操作都执行完成了,才能执行屏障之后的操作。所以为什么说RCU不需要显式地跟踪每一个不同点事件,(开始我还以为是通过引用计数的方式来实现的,但是,又不符合这一点)。
举个例子吧
/* p 指向一块受 RCU 保护的共享数据 */
/* read */
rcu_read_lock();
p1 = rcu_dereference(p);
if (p1 != NULL) {
printf("%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() 阻塞的这一段时间,有个名字,叫做 Grace period。而 Reader4,5,6,无论与 Grace period 的时间关系如何,由于获取引用的时间在 t1 之后,都无法获得 p 指针的引用,因此不会进入 p1 != NULL 的分支。
现在看看rcu_read_lock()
rcu_read_unlock()
synchronize_rcu()
-
rcu_read_lock()
和rcu_read_unlock()
的实现都非常简单,分别是关闭抢占和打开抢占static inline void __rcu_read_lock(void) { preempt_disable(); } static inline void __rcu_read_unlock(void) { preempt_enable(); }
这时是否度过宽限期的判断就比较简单:每个CPU都经过一次抢占,就说明不在
rcu_read_lock()
和rcu_read_unlock()
之间,必然已经完成访问或者还未开始访问。 -
synchronize_rcu()
void synchronize_rcu(void) { struct rcu_synchronize rcu; // 初始化为等待事件为完成 init_completion(&rcu.completion); // 在当前cpu的私有数据中,注册回调函数 // 其中回调函数的complete(&rcu->completion);用来唤醒等待队列的进程 /* Will wake me after RCU finished */ call_rcu(&rcu.head, wakeme_after_rcu); /* Wait for it */ // completion是linux kernel的另一种同步机制,找机会再深入了解一下 // 简单了解了一下,发现是个无底洞,顶不住了 wait_for_completion(&rcu.completion); } struct rcu_synchronize { struct rcu_head head; struct completion completion; }; struct rcu_head { struct rcu_head *next; // 指向 rcu_head 的指针 void (*func)(struct rcu_head *head); // 回调函数 完成写操作最后的数据释放或修改操作 }
因为RCU由rcu_read_lock()
和 rcu_read_unlock()
定义的临界区是不允许休眠和阻塞的,所以当一个给定的CPU要进行上下文切换的时候,我们可以确定任何已由的RCU读临界区都已经完成了。也就是说只要每一个CPU都至少进行了一次上下文切换,那么所有先前的RCU读临界区也就完成了,
所以从概念上说 synchronize_rcu()
可以被简化成(但是在实际情况下是不可能的,这里只是方便理解)
for_each_online_cpu(cpu);
run_on(cpu);
run_on()
:将当前线程切换到指定CPU,来强制该cpu进行上下文切换
for_each_online_cpu()
:循环强制对每个CPU进行一次上下文切换。
维护多个版本的近期更新的对象
我们直接看看在 kernel 中是怎么操作的
-
删除链表项
// 常见的代码模式: p = seach_the_entry_to_delete(); list_del_rcu(p->list); synchronize_rcu(); kfree(p); //其中 list_del_rcu() 的源码如下,把某一项移出链表: /* list.h */ static inline void __list_del(struct list_head * prev, struct list_head * next) { next->prev = prev; prev->next = next; } /* rculist.h */ static inline void list_del_rcu(struct list_head *entry) { __list_del(entry->prev, entry->next); entry->prev = LIST_POISON2; }
-
更新链表项
// 常见的代码模式 // copy p = search_the_entry_to_update(); q = kmalloc(sizeof(*p), GFP_KERNEL); *q = *p; // write q->field = new_value; // updata list_replace_rcu(&p->list, &q->list); synchronize_rcu(); kfree(p);
解释一下最后三行为什么不是先调用
synchronize_rcu()
,再调用list_replace_rcu(&p->list, &q->list)
和kfree(p)
?其实也很简单,画个图就行了。data_1 -> data_2 -> data_3 -> data_4 这里用 data_new 替换 data_3 data_1 -> data_2 data_3 -> data_4 | | ---> data_new --->|
然后是看看源码,也挺简单的
static inline void list_replace_rcu(struct list_head *old, struct list_head *new) { new->next = old->next; new->prev = old->prev; rcu_assign_pointer(list_next_rcu(new->prev), new); new->next->prev = new; old->prev = LIST_POISON2; }
RCU 和 copy-on write 的区别
最后,就简单聊聊 RCU 和 copy-on write 的区别吧。
先看看 copy-on write。笔者对 copy-on wirte 的理解是基于 mit6.s086 的xv6 kernel 的,和linux kernel 中的copy-on write 难免会有些出入。
其实 copy-on write 属于 lazy copy 的一种。比如说:现在有一个进程 process_1 他调用了 fork()
生成了一个子进程 process_2,我们直到,每个进程都有自己的内存空间,在 kernel 中称为 页 (page),每个进程都有自己的页。那理所当然的,当调用 fork()
后,产生的子进程也应该有自己的 page。如果 kernel 采用的是 copy-on write 机制的话,kernel 并不会马上为子进程申请一个 page,而是让该子进程与父进程公用一个 page,但是 对应的父子进程的 PTE 上的 可写位 会被置为零,也就是说不允许执行写操作。当父进程或子进程执行写操作 之后就会产生一个 page fault(页错误),这时候, cpu就会从用户态陷入到内核态,正真地分配一个页,在 copy ,然后执行写操作。
什么是PTE? 我忘了具体是什么意思,我们知道 page 是一个虚拟的概念,每个进程看到的都是虚拟内存,虚拟内存需要通过映射(mapping)到相应的物理内存上,kernel 代码中有对应的映射函数。但是只有单一映射的话,物理内存就跟虚拟内存大小,没什么意义,所以这时候就需要页表(page table)来建立一个多级映射的关系,大大增加虚拟内存的大小,而 page table 上的每一列 就称为 PTE。
所以,copy-on write 和 RCU 还是有很大的不同的,前者只是一种内存机制(我认为),而RCU是一种同步机制。(为什么会说这个?因为昨天在博客上看到有位兄弟说这两个差不多。但是仔细一看,感觉还真不一样。)
参考文献