Linux 内核RCU机制的使用

一、Linux内核RCU机制介绍

1. 基本思想

  RCU背后的基本思想是将更新操作分为“移除”“回收”两个阶段。

  移除阶段会删除数据结构中对数据项的引用(可能通过用这些数据项新版本的引用来替换它们),并且可以与读者(读操作)同时运行。之所以能够安全地与读者同时运行移除阶段,是因为现代CPU的语义保证读者将看到数据结构的旧版本或新版本,而不会看到部分更新的引用。

  回收阶段则执行在移除阶段从数据结构中删除的数据项的回收工作(例如,释放它们)。由于回收数据项可能会干扰同时引用这些数据项的任何读者,因此在读者不再持有对这些数据项的引用之前,回收阶段不能开始。

  将更新分为移除和回收阶段允许更新者立即执行移除阶段,并推迟回收阶段,直到在移除阶段期间活跃的所有读者都已完成,无论是通过阻塞直到它们完成,还是通过注册一个在它们完成后调用的回调函数。只有那些在移除阶段期间活跃的读者才需要被考虑,因为任何在移除阶段之后开始的读者都将无法获得对已移除数据项的引用,因此不会受到回收阶段的干扰。

  因此,典型的RCU更新序列如下:

a. 移除指向数据结构的指针,以便后续的读者无法获得对它的引用。
b. 等待所有先前的读者完成他们的RCU读侧临界区。
c. 此时,不可能有任何读者持有对数据结构的引用,所以现在可以安全地回收它(例如,使用kfree()释放)。

  上述步骤(b)是RCU延迟销毁的关键思想。能够等待直到所有读者完成,允许RCU读者使用更轻量级的同步机制,在某些情况下,甚至可以完全不使用同步。相比之下,在更传统的基于锁的方案中,读者必须使用重量级的同步机制,以防止更新者在他们下面删除数据结构。这是因为基于锁的更新者通常就地更新数据项,因此必须排除读者。相比之下,基于RCU的更新者通常利用现代CPU上单个对齐指针写操作的原子性这一事实,允许在不干扰读者的情况下,原子性地插入、移除和替换链式结构中的数据项。并发的RCU读者可以继续访问旧版本,并可以省略在现代SMP计算机系统中非常昂贵的原子操作、内存屏障和通信缓存未命中,即使在没有锁竞争的情况下也是如此。

  在上述三个步骤的程序中,更新者同时执行移除和回收步骤,但通常由一个完全不同的线程来执行回收可能会更有帮助,就像实际上Linux内核的目录项缓存(dcache)中那样。即使同一个线程执行了更新步骤(上述步骤(a))和回收步骤(步骤(c)),分开考虑它们通常也是有帮助的。例如,RCU的读者和更新者不需要进行任何通信,但RCU提供了读者和回收者之间的隐式低开销通信,即在上述步骤(b)中。

  那么,回收者如何知道读者何时完成,尤其是考虑到读者没有进行任何同步操作呢???继续阅读以了解RCU的API如何使这变得容易。

2. RCU的核心API是什么?

  RCU的核心API相当小,包括以下几个:

a. rcu_read_lock()
b. rcu_read_unlock()
c. synchronize_rcu() / call_rcu()
d. rcu_assign_pointer()
e. rcu_dereference()

  除了这五个基本API外,还有许多其他的RCU API成员,但其余的都可以用这五个来表达,尽管大多数实现反而用call_rcu()回调API来表达synchronize_rcu()。

  下面将对这五个核心RCU API进行描述,其他18个将在稍后列举。更多信息请参阅内核的doc book文档,或者直接查看函数的头部注释。

rcu_read_lock

  void rcu_read_lock(void):由读者用来通知回收器读者正在进入RCU读侧临界区。在RCU读侧临界区内阻塞是不允许的,尽管使用CONFIG_PREEMPT_RCU配置构建的内核可以抢占RCU读侧临界区。在RCU读侧临界区内访问的任何RCU保护的数据结构都保证在该临界区的整个持续期间不会被回收。引用计数可以与RCU结合使用,以维持对数据结构的长期引用。

rcu_read_unlock

  void rcu_read_unlock(void):由读者用来通知回收器读者正在退出RCU读侧临界区。请注意,RCU读侧临界区可能是嵌套和/或重叠的。

synchronize_rcu / call_rcu

  void synchronize_rcu(void):标记更新器代码的结束和回收器代码的开始。它通过阻塞,直到所有CPU上所有预先存在的RCU读侧临界区都完成后才继续。请注意,synchronize_rcu()不一定会等待任何随后开始的RCU读侧临界区完成。例如,考虑以下事件序列:
在这里插入图片描述
  再次强调,synchronize_rcu() 仅等待正在进行的RCU读侧临界区完成,而不一定等待在synchronize_rcu()被调用之后开始的临界区完成。

  当然,synchronize_rcu() 在最后一个预先存在的RCU读侧临界区完成后,并不一定会立即返回。一方面,可能会有调度延迟。另一方面,许多RCU实现为了提高效率,采用批量处理请求的方式,这可能会进一步延迟synchronize_rcu()

  由于synchronize_rcu() 是必须确定读者何时完成的API,其实现对RCU至关重要。为了让RCU在几乎所有读密集型情况下都有用,synchronize_rcu() 的开销也必须非常小。

  call_rcu() API 是synchronize_rcu()的回调形式,并在后文中更详细地描述。它不是阻塞,而是注册一个函数和参数,这些在所有正在进行的RCU读侧临界区完成后被调用。在阻塞是不允许的或更新侧性能至关重要的情况下,这种回调变体特别有用。

  然而,不应轻易使用call_rcu() API,因为使用synchronize_rcu() API通常会得到更简单的代码。此外,synchronize_rcu() API具有在宽限期被延迟时自动限制更新速率的优良特性。这种特性使系统在面对拒绝服务攻击时具有弹性。使用call_rcu()的代码应该限制更新速率以获得这种弹性。有关限制更新速率的一些方法,请参见内核源码/Documentation/RCU/checklist.txt。

rcu_assign_pointer

  void rcu_assign_pointer(p, typeof(p) v):是的,rcu_assign_pointer() 是作为一个宏实现的,尽管能够以这种方式声明一个函数会很酷。(编译器专家无疑会不同意。)更新者使用这个函数为RCU保护的指针赋新值,以便安全地将值的更改从更新者传递给读者。这个宏不会计算为一个右值,但它确实执行了给定CPU架构所需的任何内存屏障指令。

  也同样重要的是,它用来记录哪些指针由RCU保护,以及一个给定的结构何时对其他CPU变得可访问。也就是说,rcu_assign_pointer() 最常通过_rcu列表操作原语间接使用,如list_add_rcu()

rcu_dereference

  rcu_dereference() 函数:typeof(p) rcu_dereference(p);类似于 rcu_assign_pointer()rcu_dereference() 必须实现为一个宏。读者使用 rcu_dereference() 来获取一个 RCU 保护的指针,它会返回一个值,这个值可以安全地被解引用。注意,rcu_dereference() 实际上并不解引用指针,相反,它保护指针以供稍后解引用。它还执行特定 CPU 架构所需的所有内存屏障指令。

  目前,只有 Alpha 需要 rcu_dereference() 内部的内存屏障——在其他 CPU 上,它不编译任何内容,甚至不是一个编译器指令。

  常见的编码实践使用 rcu_dereference() 将 RCU 保护的指针复制到局部变量,然后解引用这个局部变量,例如如下所示:

p = rcu_dereference(head.next);
return p->data;

  然而,在这种情况下,同样可以轻松地将这些合并成一个语句:return rcu_dereference(head.next)->data;

  如果你将要从 RCU 保护的结构中获取多个字段,使用局部变量当然是首选。重复调用 rcu_dereference() 看起来很丑陋,不保证如果在临界区内发生更新时返回相同的指针,并且在 Alpha CPU 上会产生不必要的开销。

  请注意,rcu_dereference()返回的值仅在封闭的RCU读侧临界区域内有效。例如,以下不是有效的:

rcu_read_lock();
p = rcu_dereference(head.next);
rcu_read_unlock();
x = p->address; /* BUG!!! */
rcu_read_lock();
y = p->data; /* BUG!!! */
rcu_read_unlock();

  从一个RCU读侧临界区域到另一个保持同一个引用与从一个基于锁的临界区域到另一个临界区域是一样非法的!同样,在获取引用的地方之外使用它就像使用正常的锁定一样非法。

  与rcu_assign_pointer()一样,rcu_dereference()的一个重要作用是记录哪些指针受到RCU的保护,特别是标记一个指针,它可能会在任何时候发生变化,包括rcu_dereference()之后立即发生变化。并且像rcu_assign_pointer()一样,rcu_dereference()通常是通过_rcu列表操作原语间接使用,例如list_for_each_entry_rcu()。下图显示了API如何在读者、更新者和再利用者之间通信:
在这里插入图片描述
  RCU(Read-Copy-Update)基础设施会观察rcu_read_lock()rcu_read_unlock()synchronize_rcu()call_rcu()的调用时间顺序,以确定synchronize_rcu()调用何时可以返回给调用者,以及call_rcu()回调何时可以被调用。高效的RCU实现会大量使用批处理技术,以将开销分摊到多个对应API的使用中。

  在Linux内核中,RCU有至少三种使用方式。上图展示了最常见的一种。在更新者方面,rcu_assign_pointer()synchronize_rcu()call_rcu()这几个基本操作在这三种方式中是相同的。然而,在保护机制(即读取者方面),使用的基本操作根据不同的方式有所不同:

  a. rcu_read_lock() / rcu_read_unlock()rcu_dereference()

  b. rcu_read_lock_bh() / rcu_read_unlock_bh()local_bh_disable() / local_bh_enable()rcu_dereference_bh()

  c. rcu_read_lock_sched() / rcu_read_unlock_sched()preempt_disable() / preempt_enable()local_irq_save() / local_irq_restore()hardirq enter / hardirq exitNMI enter / NMI exitrcu_dereference_sched()

  这三种方式的使用场景如下:
  a. RCU应用于普通数据结构。
  b. RCU应用于可能受到远程拒绝服务攻击的网络数据结构。
  c. RCU应用于调度器和中断/NMI处理任务。

  大多数情况下,使用的是(a)。(b)和(c)情况对于特殊用途非常重要,但相对不那么常见。

二、Linux内核RCU机制的使用

  以下是核心RCU API的一些示例用法,展示了如何使用这些API来保护一个全局指针,指向一个动态分配的结构体。更多典型的RCU用法可以在以下文档中找到:listRCU.rstarrayRCU.rstNMI-RCU.rst

示例1:

struct foo {
	int a;
	char b;
	long c;
};

DEFINE_SPINLOCK(foo_mutex);
struct foo __rcu *gbl_foo;

/*
* 创建一个新的 `struct foo`,该结构体与当前由 `gbl_foo` 指向的结构体相同,只是
* 将字段 "a" 替换为 "new_a"。将 `gbl_foo` 指向新结构体,并在经过一个延迟期后释
* 放旧结构体。
* 
* 使用 `rcu_assign_pointer()` 确保并发读者能够看到初始化后的新结构体版本。
* 
* 使用 `synchronize_rcu()` 确保所有可能引用旧结构体的读者在释放旧结构体之
* 前完成对旧结构体的访问。
*/
void foo_update_a(int new_a)
{
	struct foo *new_fp;
	struct foo *old_fp;
	
	new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
	
	spin_lock(&foo_mutex);
	old_fp = rcu_dereference_protected(gbl_foo, lockdep_is_held(&foo_mutex));
	*new_fp = *old_fp;
	new_fp->a = new_a;
	rcu_assign_pointer(gbl_foo, new_fp);
	spin_unlock(&foo_mutex);
	
	synchronize_rcu();
	
	kfree(old_fp);
}

/*
* 返回当前 `gbl_foo` 结构体中字段 "a" 的值。使用 `rcu_read_lock()` 和
* `rcu_read_unlock()` 来确保在我们读取结构体时,该结构体不会被删除,并
* 使用 `rcu_dereference()` 来确保我们看到结构体的初始化版本(这对于 DEC
* Alpha 处理器和阅读代码的人都很重要)。
*/
int foo_get_a(void)
{
	int retval;
	rcu_read_lock();
	retval = rcu_dereference(gbl_foo)->a;
	rcu_read_unlock();
	return retval;
}

  本节展示了核心 RCU API 的一个简单用法,保护一个全局指针,该指针指向一个动态分配的结构体。更典型的 RCU 用法可以在以下文档中找到:listRCU.rstarrayRCU.rstNMI-RCU.rst

总结:

  • 使用 rcu_read_lock()rcu_read_unlock() 来保护 RCU 读侧临界区。
  • 在 RCU 读侧临界区内,使用 rcu_dereference() 来解引用 RCU 保护的指针。
  • 使用某种可靠的机制(如锁或信号量)来防止并发更新相互干扰。
  • 使用 rcu_assign_pointer() 来更新 RCU 保护的指针。这个原语保护的是读者不会看到更新者带来的不一致性,而不是保护并发更新之间的干扰。因此,你仍然需要使用锁(或类似的机制)来防止并发的 rcu_assign_pointer() 操作相互干扰。
  • 在从 RCU 保护的数据结构中移除数据项之后,但在回收/释放数据项之前,使用 synchronize_rcu() 以等待所有可能引用该数据项的 RCU 读侧临界区完成。

  参见 checklist.txt 以获取使用 RCU 时需遵循的附加规则。更多典型的 RCU 用法可以在 listRCU.rstarrayRCU.rstNMI-RCU.rst 中找到。

示例2:

  在上面的例子中,foo_update_a() 会阻塞,直到一个延迟期结束。这虽然很简单,但在某些情况下,可能无法承受如此长时间的等待——可能还有其他更高优先级的工作要做。

  在这种情况下,可以使用 call_rcu() 而不是 synchronize_rcu()call_rcu() API 如下所示:

void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head));

  这个函数会在一个宽限期过后调用 func(head)。这个调用可能发生在软中断或进程上下文中,因此函数不能阻塞。

  foo 结构体需要添加一个 rcu_head 结构体,可能如下所示:

struct foo {
    int a;
    char b;
    long c;
    struct rcu_head rcu;
};

  然后 foo_update_a() 函数可以写成如下形式:

/*
* 创建一个新的 foo 结构体,该结构体与 gbl_foo 当前指向的结构体相同,
* 除了将字段 "a" 替换为 "new_a"。将 gbl_foo 指向新结构体,
* 并在一个延迟期后释放旧结构体。
*
* 使用 rcu_assign_pointer() 确保并发读者能够看到新结构体的初始化版本。
*
* 使用 call_rcu() 确保在释放旧结构体之前,任何可能引用旧结构体的读者
* 都已完成对旧结构体的访问。
*/
void foo_update_a(int new_a)
{
	struct foo *new_fp;
	struct foo *old_fp;
	
	new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
	
	spin_lock(&foo_mutex);
	old_fp = rcu_dereference_protected(gbl_foo, lockdep_is_held(&foo_mutex));
	*new_fp = *old_fp;
	new_fp->a = new_a;
	rcu_assign_pointer(gbl_foo, new_fp);
	spin_unlock(&foo_mutex);
	
	call_rcu(&old_fp->rcu, foo_reclaim);
}

  foo_reclaim() 函数可能如下所示:

void foo_reclaim(struct rcu_head *rp)
{
	struct foo *fp = container_of(rp, struct foo, rcu);
	
	foo_cleanup(fp->a);
	kfree(fp);
}

  ``container_of()` 原语是一个宏,它给定一个指向结构体的指针、结构体的类型和结构体中被指向的字段,返回指向结构体开头的指针。

  使用 call_rcu() 允许 foo_update_a() 的调用者立即重新获得控制,而无需进一步担心更新元素的旧版本。这也清楚地展示了 RCU 在更新者(即 foo_update_a())和回收者(即 foo_reclaim())之间的区别。

  在从 RCU 保护的数据结构中移除数据元素后,使用call_rcu()` 来注册一个回调函数,该函数将在所有可能引用该数据项的 RCU 读侧临界区完成后被调用。

  如果 call_rcu() 的回调函数只做了一件事情,那就是对结构体调用 kfree(),你可以使用 kfree_rcu() 代替 call_rcu(),以避免编写自己的回调函数:

kfree_rcu(old_fp, rcu);

  同样,查看内核文档可checklist.txt 以获取有关 RCU 使用的附加规则。

三、 扩展阅读

  请参阅 kernel/rcu/update.c 文件,并查看:http://www.rdrop.com/users/paulmck/RCU,那里有描述 Linux 内核 RCU 实现的论文。OLS’01 和 OLS’02 论文是一个很好的介绍,而论文集提供了关于 2004 年初当前实现的更多细节。

  优秀文章分享:深入理解RCU|核心原理

  本篇文章由内核《What is RCU?》文档翻译总结而来,欢迎各位在评论区交流指正!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小嵌同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值