在上一篇文章《编程界也内卷?浅析“斜杠青年”RCU 》中,鼎道智联带着大家一起认识了并行编程,了解了什么是 RCU ,相信大家已经对 RCU 的特点和如何实现 Reader 无锁有了一定的了解。
今天就带着大家继续从 RCU 的实现入手,一起看看在实际操作中,并行编程是如何实现的!
RCU 的实现原理可以概括为“读取-复制-更新”(Read-Copy-Update)。在 RCU 中,共享数据结构被复制多次,每个副本都有自己的读者,并且在写者更新数据结构之前,所有读者都只读取数据结构的一个副本。当写者更新数据结构时,它会创建一个新的副本,而不是直接更新原始数据结构。然后,写者会在新的副本上进行更新操作,并向所有正在使用旧副本的读者发出通知,让它们开始使用新的副本。由于所有的读者都是在旧副本上读取数据的,因此写者的更新不会影响正在读取旧副本的任何读者。一旦没有任何读者使用旧副本,它就可以被销毁,释放内存资源。
为了能让大家更好地理解 RCU 的实现原理,我们通过几种 RCU 的实现来说明这点。
首先,我们需要认识一些常用名词:
- 静止状态:
Quiescent State,简写 qs ,在任意时刻,一个特定的 CPU 只要看起来处于阻塞状态、 IDLE 循环、或者离开内核的状态,我们就知道所有 RCU 读端临界区已经完成。这些状态被称为“静止状态”。
- 宽限期:
grace period,简写 gp ,这是一个等待期,以确保所有与执行删除数据相关的 reader 访问完毕。
RCU 宽限期的开始和结束都取决于是否有读端在临界区内,因此,也可以将 RCU 的重要过程分为三个:
- 读端进入临界区
- 读端退出临界区
- 等待宽限期结束(同步方式)
下面通过几个简单的例子来也重点看看上述三个方面是如何实现的:
DEFINE_SPINLOCK(rcu_gp_lock);
static void rcu_read_lock(void)
{
spin_lock(&rcu_gp_lcok);
}
static void rcu_read_unlock(void)
{
spin_unlock(&rcu_gp_lock);
}
static void synchronize_rcu(void)
{
spin_lock(&rcu_gp_lock);
spin_unlock(&rcu_gp_lock);
}
上述的例子是通过 spinlock 的方式来实现:即 rcu_read_lock() 函数和 rcu_read_unlock() 函数通过对 spinlock 上锁和解锁,可以很好地判断出读端当前是否处于临界区内;在 synchronize_rcu() 函数中通过检查 spinlock 是否上锁来判断是否有读端在临界区内,即宽限期是否结束。
所以本例子很好地实现了 rcu_read_lock() 函数、rcu_read_unlock() 函数和 synchronize_rcu() 函数的语义。但是 spinlock 具有强烈的排他性,所以每次只能有一个读端进入临界区。
为了更好地提高读端的并发行,我们首先想到了计数器,即通过计数器的值来判断是否所有读端都退出了临界区。
这时,我们采用了原子变量作为读端的计数器,那为什么使用原子变量作为计数器呢?
首先原子操作的性能开销要远远小于锁的性能开销,其次原子操作也只对计数器变量进行保护,所以在读端可以并发执行。
atomic_t rcu_refcnt;static void rcu_read_lock(void){
atomic_inc(&rcu_refcnt);smp_mb();
}
static void rcu_read_unlock(void){
smp_mb();atomic_dec(&rcu_refcnt);} static void synchronize_rcu(void){
smp_mb();
while(atomic_read(&rcu_refcnt) != 0) {
poll(NULL, 0, 10);
}
smp_mb();
}
从上述这个例子我们可以看到,使用原子变量作为计数器可以大幅提高读端的并发性能,但是该原子计数器仍然是一个全局变量,是被所有的读端共享的一个变量,因此,随着并发数量的增加,对原子计数器的竞争机率也会增加,这就导致了扩展性不足问题的出现。
但我们通过空间换性能的方式,将读端进入临界区的标记变成一个线程,这样读端之间就不存在共享进入临界区标记的情况,也提高了读端的并发性和扩展性。
static void rcu_read_lock(void)
{
spin_lock(&__get_thread_var(rcu_gp_lock));
}
static void rcu_read_unlock(void)
{
spin_unlock(&__get_thread_var(rcu_gp_lock));
}
static void synchronize_rcu(void)
{
for_each_running_thread(t){
spin_lock(&per_thread(rcu_gp_lock, t));
spin_unlock(&per_thread(ruc_gp_lock, t));
}
}
在 Linux 内核中 RCU 有多种实现方式,根据跟踪宽限期的方式不同可以简单分为:基于单核的 tiny 实现、基于静止状态的实现、基于任务的实现、和可睡眠的 SRCU 等。我们简单以基于静止状态的 RCU 的宽限期跟踪实现为例子,为大家介绍。
经典 RCU 读端临界区限制其中的内核代码,不允许其阻塞。这意味着在任意时刻,一个特定的 CPU 只要看起来处于阻塞、 IDLE 循环、或离开内核的状态,就可以确定所有 RCU 读端临界区都已完成——我们将这些状态称为“静止状态”;当每一个 CPU 都经历过至少一次静止状态时,则代表 RCU 宽限期结束。
在 Linux 中经典的 RCU 实现就巧妙地利用了上述的原则:通过对静止状态的检测来减少读端的竞争。为了减少 CPU 上报时对数据锁的竞争, RCU 采用了分级控制,如下图:
在一个 rcu_node 中,第一个上报静止状态的 CPU 会将数据保存在该层的节点中;只有另外一个 CPU 也上报了静止状态的时候,才会将该节点的两个 CPU 的静止状态同时上报到上一层的 rcu_node 中。这样做的好处是:在底层同一时间至多有两个 CPU 会竞争锁,这也减少了顶层锁的竞争,从而大大减少 CPU 相互竞争的机会。
其实,除了静止状态下的非阻塞算法之外, RCU 还有多种实现来减少 CPU 之间的相互竞争:
- RCU 采用延迟回收技术:
RCU 采用延迟回收技术来销毁旧的副本,这种技术可以将内存释放延迟到一定时机,从而减少了 CPU 之间的相互竞争。
- RCU 允许并发读取:
RCU 允许多个读者同时访问共享数据结构的一个副本,从而减少了 CPU 之间的相互竞争。尤其在多核 CPU 上,这可以提高程序的并发性能。
我们可以看到无论哪种方式, RCU 都在努力减少 CPU 之间的相互竞争,通过这种方式不仅可以提升程序的并发性能,还可以提高响应速度、程序的可扩展性;除此之外, RCU 还可以减少锁的使用,一部分降低了锁竞争的开销,另一部分还可以降低程序的复杂度。由于 RCU 不需要使用锁,因此程序的代码会更加简单,也减少了锁的处理逻辑和异常情况的处理。这有助于提高代码的可维护性和可读性,从而减少出错的可能性。
在互联网行业越来越卷的今天,更快的响应速度、更低的代码成本就代表着有越多的可能性,来开发出专属于自己产品的壁垒优势。
对于鼎道智联来说,开发一个全新的、以人为本的、为用户提供更便捷、智能、安全的操作系统,就代表着我们需要走更多创新的路,需要花更多时间去钻研市场动态、新兴技术,鼎道智联一直保持着对行业的探索和对自己生态的打造,如果你也认可我们的想法,有相同的目标,欢迎加入或关注鼎道生态。