RCU 的需求之旅

版权所有 IBM Corporation,2015

作者:Paul E. McKenney

本文档的初始版本出现在 LWN 的以下文章中:第一部分第二部分第三部分

简介

读取-复制更新 (RCU) 是一种同步机制,通常用作读写锁的替代品。 RCU 的不寻常之处在于,更新者不会阻塞读取者,这意味着 RCU 的读取端原语可以非常快速且可扩展。此外,更新者可以与读取者并发地进行有用的前进。然而,RCU 读取者和更新者之间的所有这些并发性确实引发了一个问题,即 RCU 读取者究竟在做什么,这反过来又引发了一个问题,即 RCU 的需求到底是什么。

因此,本文档总结了 RCU 的需求,可以被认为是 RCU 的非正式、高级规范。重要的是要理解 RCU 的规范本质上主要是经验性的;事实上,我通过艰苦的努力了解了许多这些需求。这种情况可能会引起一些担忧,但是,不仅这个学习过程非常有趣,而且能够与如此多愿意以有趣的新方式应用技术的人一起工作也是一种极大的荣幸。

抛开这一切,以下是当前已知的 RCU 需求的类别

  1. 基本需求

  2. 基本非需求

  3. 并行性的生活事实

  4. 实现质量要求

  5. Linux 内核复杂性

  6. 软件工程需求

  7. 其他 RCU 风格

  8. 未来可能的变化

接下来是一个 摘要,但是,每个快速测验的答案都紧随测验之后。 用鼠标选择大片空白区域即可查看答案。

基本需求

RCU 的基本需求是 RCU 最接近于严格的数学需求的东西。这些是

  1. 宽限期保证

  2. 发布/订阅保证

  3. 内存屏障保证

  4. 保证无条件执行的 RCU 原语

  5. 保证的读取到写入升级

宽限期保证

RCU 的宽限期保证不同寻常之处在于它是预谋的:在 1990 年代初期,当 Jack Slingwine 和我开始研究 RCU(当时称为“rclock”)时,我们就牢记着这种保证。也就是说,过去二十年 RCU 的使用经验使我们对这种保证有了更详细的理解。

RCU 的宽限期保证允许更新者等待所有先前存在的 RCU 读取端临界区的完成。 RCU 读取端临界区以标记 rcu_read_lock() 开始,以标记 rcu_read_unlock() 结束。 这些标记可以嵌套,RCU 将嵌套集视为一个大型 RCU 读取端临界区。 rcu_read_lock()rcu_read_unlock() 的生产质量实现非常轻量级,实际上在为生产使用而构建的带有 CONFIG_PREEMPTION=n 的 Linux 内核中,它们的开销恰好为零。

这种保证允许以极低的读取器开销来强制执行排序,例如

 1 int x, y;
 2
 3 void thread0(void)
 4 {
 5   rcu_read_lock();
 6   r1 = READ_ONCE(x);
 7   r2 = READ_ONCE(y);
 8   rcu_read_unlock();
 9 }
10
11 void thread1(void)
12 {
13   WRITE_ONCE(x, 1);
14   synchronize_rcu();
15   WRITE_ONCE(y, 1);
16 }

因为第 14 行的 synchronize_rcu() 会等待所有先前存在的读取器,所以 thread0() 的任何实例从 x 加载零值都必须在 thread1() 存储到 y 之前完成,因此该实例也必须从 y 加载零值。 类似地,thread0() 的任何从 y 加载 1 值的实例都必须在 synchronize_rcu() 开始后开始,因此也必须从 x 加载 1 值。 因此,结果

(r1 == 0 && r2 == 1)

不可能发生。

快速测验:

等一下! 你说更新者可以与读取者并发地进行有用的前进,但是先前存在的读取者会阻止 synchronize_rcu()!!! 你到底想欺骗谁???

答案:

首先,如果更新者不希望被读取者阻塞,他们可以使用 call_rcu()kfree_rcu(),稍后将讨论它们。 其次,即使在使用 synchronize_rcu() 时,其他更新端代码也会与读取者(无论是否预先存在)并发运行。

此场景类似于 DYNIX/ptx 中 RCU 的首次使用之一,它管理分布式锁管理器转换为适合处理从节点故障中恢复的状态,大致如下所示

 1 #define STATE_NORMAL        0
 2 #define STATE_WANT_RECOVERY 1
 3 #define STATE_RECOVERING    2
 4 #define STATE_WANT_NORMAL   3
 5
 6 int state = STATE_NORMAL;
 7
 8 void do_something_dlm(void)
 9 {
10   int state_snap;
11
12   rcu_read_lock();
13   state_snap = READ_ONCE(state);
14   if (state_snap == STATE_NORMAL)
15     do_something();
16   else
17     do_something_carefully();
18   rcu_read_unlock();
19 }
20
21 void start_recovery(void)
22 {
23   WRITE_ONCE(state, STATE_WANT_RECOVERY);
24   synchronize_rcu();
25   WRITE_ONCE(state, STATE_RECOVERING);
26   recovery();
27   WRITE_ONCE(state, STATE_WANT_NORMAL);
28   synchronize_rcu();
29   WRITE_ONCE(state, STATE_NORMAL);
30 }

do_something_dlm() 中的 RCU 读取端临界区与 start_recovery() 中的 synchronize_rcu() 一起工作,以保证 do_something() 永远不会与 recovery() 并发运行,但在 do_something_dlm() 中几乎没有或没有同步开销。

快速测验:

为什么需要第 28 行的 synchronize_rcu()

答案:

如果没有额外的宽限期,内存重新排序可能会导致 do_something_dlm() 与 recovery() 的最后几位并发执行 do_something()。

为了避免诸如死锁之类的致命问题,RCU 读取端临界区不得包含对 synchronize_rcu() 的调用。 类似地,RCU 读取端临界区不得包含任何直接或间接地等待 synchronize_rcu() 调用完成的任何内容。

尽管 RCU 的宽限期保证本身很有用,并且有 相当多的用例,但如果能够使用 RCU 来协调对链接数据结构的读取端访问,那将会更好。 为此,宽限期保证是不够的,如下面的函数 add_gp_buggy() 中所见。 我们稍后会查看读取器的代码,但同时,只需将读取器视为无锁地获取 gp 指针,并且如果加载的值为非 NULL,则无锁地访问 ->a->b 字段。

 1 bool add_gp_buggy(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   p->a = a;
12   p->b = a;
13   gp = p; /* ORDERING BUG */
14   spin_unlock(&gp_lock);
15   return true;
16 }

问题在于,编译器和弱排序的 CPU 都有权按如下方式重新排序此代码

 1 bool add_gp_buggy_optimized(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   gp = p; /* ORDERING BUG */
12   p->a = a;
13   p->b = a;
14   spin_unlock(&gp_lock);
15   return true;
16 }

如果 RCU 读取器在 add_gp_buggy_optimized 执行第 11 行后立即获取 gp,则它将看到 ->a->b 字段中的垃圾。 这只是编译器和硬件优化可能导致麻烦的众多方式之一。 因此,我们显然需要某种方法来防止编译器和 CPU 以这种方式重新排序,这使我们进入下一节讨论的发布-订阅保证。

发布/订阅保证

RCU 的发布-订阅保证允许将数据插入到链接的数据结构中,而不会中断 RCU 读取器。 更新程序使用 rcu_assign_pointer() 插入新数据,读取器使用 rcu_dereference() 访问数据,无论是新数据还是旧数据。 以下显示了插入示例

 1 bool add_gp(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   p->a = a;
12   p->b = a;
13   rcu_assign_pointer(gp, p);
14   spin_unlock(&gp_lock);
15   return true;
16 }

第 13 行的 rcu_assign_pointer() 在概念上等效于一个简单的赋值语句,但也保证其赋值将发生在第 11 行和第 12 行的两个赋值之后,类似于 C11 memory_order_release 存储操作。 它还可以防止任何数量的“有趣”编译器优化,例如,在赋值之前立即使用 gp 作为临时位置。

快速测验:

但是 rcu_assign_pointer() 并不能阻止对 p->ap->b 的两次赋值操作被重排序。这难道不会导致问题吗?

答案:

不会。在赋值给 gp 之前,读者无法看到这两个字段,而此时这两个字段都已完全初始化。因此,对 p->ap->b 的赋值操作重排序不可能导致任何问题。

很容易假设读者无需做任何特殊处理来控制对 RCU 保护数据的访问,如下面的 do_something_gp_buggy() 所示

 1 bool do_something_gp_buggy(void)
 2 {
 3   rcu_read_lock();
 4   p = gp;  /* OPTIMIZATIONS GALORE!!! */
 5   if (p) {
 6     do_something(p->a, p->b);
 7     rcu_read_unlock();
 8     return true;
 9   }
10   rcu_read_unlock();
11   return false;
12 }

然而,必须抵制这种诱惑,因为编译器(或像 DEC Alpha 这样的弱排序 CPU)有很多种方法可以使此代码出错。例如,如果编译器缺少寄存器,它可能会选择从 gp 重新获取,而不是像下面这样在 p 中保留一个单独的副本

 1 bool do_something_gp_buggy_optimized(void)
 2 {
 3   rcu_read_lock();
 4   if (gp) { /* OPTIMIZATIONS GALORE!!! */
 5     do_something(gp->a, gp->b);
 6     rcu_read_unlock();
 7     return true;
 8   }
 9   rcu_read_unlock();
10   return false;
11 }

如果此函数与一系列更新同时运行,而这些更新将当前结构替换为一个新结构,那么获取 gp->agp->b 的操作很可能来自两个不同的结构,这可能会导致严重的混乱。为了防止这种情况(以及其他更多问题),do_something_gp() 使用 rcu_dereference()gp 获取数据

 1 bool do_something_gp(void)
 2 {
 3   rcu_read_lock();
 4   p = rcu_dereference(gp);
 5   if (p) {
 6     do_something(p->a, p->b);
 7     rcu_read_unlock();
 8     return true;
 9   }
10   rcu_read_unlock();
11   return false;
12 }

rcu_dereference() 在 Linux 内核中使用 volatile 强制转换和(对于 DEC Alpha)内存屏障。如果出现 C11 memory_order_consume [PDF] 的高质量实现,那么 rcu_dereference() 可以实现为 memory_order_consume 加载。无论具体实现如何,通过 rcu_dereference() 获取的指针不得在包含该 rcu_dereference() 的最外层 RCU 读取侧临界区之外使用,除非相应数据元素的保护已从 RCU 传递到其他同步机制,最常见的是锁定或引用计数(请参阅 用于 RCU 保护的列表/数组元素的引用计数设计)。

简而言之,更新器使用 rcu_assign_pointer(),而读者使用 rcu_dereference(),这两个 RCU API 元素协同工作以确保读者拥有新添加数据元素的一致视图。

当然,还需要从 RCU 保护的数据结构中删除元素,例如,使用以下过程

  1. 从封闭结构中删除数据元素。

  2. 等待所有预先存在的 RCU 读取侧临界区完成(因为只有预先存在的读者才有可能引用新删除的数据元素)。

  3. 此时,只有更新器引用新删除的数据元素,因此它可以安全地回收该数据元素,例如,通过将其传递给 kfree()

此过程由 remove_gp_synchronous() 实现

 1 bool remove_gp_synchronous(void)
 2 {
 3   struct foo *p;
 4
 5   spin_lock(&gp_lock);
 6   p = rcu_access_pointer(gp);
 7   if (!p) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   rcu_assign_pointer(gp, NULL);
12   spin_unlock(&gp_lock);
13   synchronize_rcu();
14   kfree(p);
15   return true;
16 }

此函数很简单,第 13 行等待一个宽限期,然后第 14 行释放旧的数据元素。此等待确保读者在 p 引用的数据元素被释放之前到达 do_something_gp() 的第 7 行。第 6 行的 rcu_access_pointer() 类似于 rcu_dereference(),除了

  1. rcu_access_pointer() 返回的值不能被解引用。如果要访问指向的值以及指针本身,请使用 rcu_dereference() 而不是 rcu_access_pointer()

  2. rcu_access_pointer() 的调用不需要保护。相反,rcu_dereference() 必须在 RCU 读取侧临界区内,或者在指针不能更改的代码段中,例如,在由相应的更新侧锁保护的代码中。

快速测验:

如果没有 rcu_dereference()rcu_access_pointer(),编译器可能会使用哪些破坏性优化?

答案:

让我们从 do_something_gp() 如果未能使用 rcu_dereference() 会发生什么开始。它可能会重用先前从同一个指针获取的值。它还可能以字节为单位的方式从 gp 获取指针,从而导致加载撕裂,进而导致两个不同指针值的字节级混合。它甚至可能使用值推测优化,在其中做出错误的猜测,但在它检查该值时,更新已将指针更改为与错误的猜测匹配。与此同时,任何返回预初始化垃圾的解引用都会很糟糕!对于 remove_gp_synchronous(),只要在持有 gp_lock 的同时执行对 gp 的所有修改,上述优化都是无害的。但是,如果您使用 __rcu 定义 gp,然后在不使用 rcu_access_pointer()rcu_dereference() 的情况下访问它,sparse 将会报错。

简而言之,RCU 的发布-订阅保证由 rcu_assign_pointer()rcu_dereference() 的组合提供。此保证允许将数据元素安全地添加到 RCU 保护的链接数据结构中,而不会中断 RCU 读者。此保证可以与宽限期保证结合使用,以允许从 RCU 保护的链接数据结构中删除数据元素,同样不会中断 RCU 读者。

此保证只是部分预先考虑好的。DYNIX/ptx 使用显式内存屏障进行发布,但对于订阅没有任何类似于 rcu_dereference() 的东西,也没有任何类似于稍后被包含到 rcu_dereference() 中以及后来被包含到 READ_ONCE() 中的依赖项排序屏障。这些操作的需求在 1990 年代后期与 DEC Alpha 架构师的一次会议上突然出现,当时 DEC 仍然是一家独立的的公司。Alpha 架构师花了一个小时才说服我,任何类型的屏障都是必要的,然后我花了两个小时才说服他们,他们的文档没有明确说明这一点。最近在 C 和 C++ 标准委员会的工作中,学习了很多关于编译器的技巧和陷阱。简而言之,编译器在 1990 年代早期并不那么棘手,但在 2015 年,甚至不要考虑省略 rcu_dereference()

内存屏障保证

上一节简单的链式数据结构场景清楚地表明,在具有多个 CPU 的系统上,RCU 需要严格的内存排序保证。

  1. 每个 RCU 读取侧临界区在 synchronize_rcu() 开始之前开始的 CPU,都保证在 RCU 读取侧临界区结束和 synchronize_rcu() 返回之间执行完整的内存屏障。如果没有此保证,则在 remove_gp_synchronous() 的第 14 行 kfree() 之后,预先存在的 RCU 读取侧临界区可能会持有对新删除的 struct foo 的引用。

  2. 每个 RCU 读取侧临界区在 synchronize_rcu() 返回之后结束的 CPU,都保证在 synchronize_rcu() 开始和 RCU 读取侧临界区开始之间执行完整的内存屏障。如果没有此保证,则在 remove_gp_synchronous() 的第 14 行 kfree() 之后运行的后来的 RCU 读取侧临界区可能会稍后运行 do_something_gp() 并找到新删除的 struct foo

  3. 如果调用 synchronize_rcu() 的任务停留在给定的 CPU 上,则保证该 CPU 在 synchronize_rcu() 的执行期间的某个时间执行完整的内存屏障。此保证确保 remove_gp_synchronous() 的第 14 行的 kfree() 确实在第 11 行的删除之后执行。

  4. 如果调用 synchronize_rcu() 的任务在该调用期间在多个 CPU 之间迁移,则保证该组中的每个 CPU 在 synchronize_rcu() 的执行期间的某个时间执行完整的内存屏障。此保证还确保 remove_gp_synchronous() 的第 14 行的 kfree() 确实在第 11 行的删除之后执行,并且在执行 synchronize_rcu() 的线程在此期间迁移的情况下也是如此。

快速测验:

鉴于多个 CPU 可以在没有任何排序的情况下随时启动 RCU 读取侧临界区,RCU 如何才能确定给定的 RCU 读取侧临界区是否在给定的 synchronize_rcu() 实例之前开始?

答案:

如果 RCU 无法确定给定的 RCU 读取侧临界区是否在给定的 synchronize_rcu() 实例之前开始,则它必须假定 RCU 读取侧临界区首先开始。换句话说,只有当可以证明 synchronize_rcu() 首先开始时,给定的 synchronize_rcu() 实例才可以避免等待给定的 RCU 读取侧临界区。一个相关的问题是“当 rcu_read_lock() 不生成任何代码时,它与宽限期有什么关系?” 答案是,重要的不是 rcu_read_lock() 本身的关系,而是封闭的 RCU 读取侧临界区内的代码与宽限期之前和之后的代码的关系。如果我们采用这种观点,则当宽限期之前的某些访问观察到临界区内某些访问的效果时,则给定的 RCU 读取侧临界区在给定的宽限期之前开始,在这种情况下,临界区内的任何访问都不得观察到宽限期之后的任何访问的效果。

截至 2016 年底,RCU 的数学模型采用此观点,例如,请参阅 2016 LinuxCon EU 演示文稿的第 62 和 63 页。

快速测验:

第一个和第二个保证需要令人难以置信的严格排序!真的需要所有这些内存屏障吗?

答案:

是的,它们确实是必需的。要了解为什么需要第一个保证,请考虑以下事件序列

  1. CPU 1: rcu_read_lock()

  2. CPU 1: q = rcu_dereference(gp); /* 很可能返回 p。 */

  3. CPU 0: list_del_rcu(p);

  4. CPU 0: synchronize_rcu() 开始。

  5. CPU 1: do_something_with(q->a); /* 没有 smp_mb(),所以可能在 kfree() 之后发生。 */

  6. CPU 1: rcu_read_unlock()

  7. CPU 0: synchronize_rcu() 返回。

  8. CPU 0: kfree(p);

因此,在 RCU 读取侧临界区的末尾和宽限期的末尾之间绝对必须有一个完整的内存屏障。

演示第二条规则必要性的事件序列大致相似

  1. CPU 0: list_del_rcu(p);

  2. CPU 0: synchronize_rcu() 开始。

  3. CPU 1: rcu_read_lock()

  4. CPU 1: q = rcu_dereference(gp); /* 如果没有内存屏障,则可能返回 p。 */

  5. CPU 0: synchronize_rcu() 返回。

  6. CPU 0: kfree(p);

  7. CPU 1: do_something_with(q->a); /* Boom!!! */

  8. CPU 1: rcu_read_unlock()

同样,在宽限期的开始和 RCU 读取侧临界区的开始之间没有内存屏障,CPU 1 最终可能会访问空闲列表。

当然,适用“好像”规则,因此任何表现得好像已放置适当的内存屏障的实现都是正确的实现。也就是说,让自己相信您已遵守好像规则比实际遵守它要容易得多!

快速测验:

您声称 rcu_read_lock()rcu_read_unlock() 在某些内核版本中绝对不生成任何代码。这意味着编译器可能会任意重新排列连续的 RCU 读取侧临界区。给定这种重新排列,如果给定的 RCU 读取侧临界区完成,您如何确定所有先前的 RCU 读取侧临界区都已完成?编译器的重新排列不会使其无法确定吗?

答案:

rcu_read_lock()rcu_read_unlock() 绝对不生成任何代码的情况下,RCU 仅在特殊位置(例如,调度程序中)推断静止状态。由于对 schedule() 的调用最好防止对共享变量的调用代码访问被重新排列到对 schedule() 的调用中,如果 RCU 检测到给定的 RCU 读取侧临界区的结束,它将必然检测到所有先前 RCU 读取侧临界区的结束,无论编译器多么积极地扰乱代码。同样,这一切都假设编译器无法跨对调度程序的调用、中断处理程序、空闲循环、用户模式代码等扰乱代码。但是,如果您的内核版本允许这种扰乱,那么您破坏的不仅仅是 RCU!

请注意,这些内存屏障要求不会取代 RCU 的基本要求,即宽限期必须等待所有预先存在的读取器。相反,本节中调用的内存屏障必须以确保 *强制执行* 此基本要求的方式运行。当然,不同的实现以不同的方式强制执行此要求,但它们必须强制执行。

保证无条件执行的 RCU 原语

常见 RCU 原语是无条件的。它们被调用,完成它们的工作,然后返回,没有出错的可能性,也不需要重试。这是 RCU 的一个关键设计理念。

但是,这种理念是务实的,而不是固执的。如果有人提出某个特定条件 RCU 原语的充分理由,它很可能会被实现并添加。毕竟,此保证是逆向工程的,而不是预谋的。RCU 原语的无条件性最初是实现上的偶然,后来使用具有条件原语的同步原语的经验使我将此偶然性提升为保证。因此,向 RCU 添加条件原语的理由需要基于详细且引人注目的用例。

保证从读取到写入的升级

就 RCU 而言,始终可以在 RCU 读取侧临界区内执行更新。例如,RCU 读取侧临界区可以搜索给定的数据元素,然后可能会获取更新侧自旋锁以更新该元素,所有这些都发生在 RCU 读取侧临界区内。当然,在调用 synchronize_rcu() 之前,必须退出 RCU 读取侧临界区,但是可以通过使用本文档后面描述的 call_rcu()kfree_rcu() API 成员来避免这种不便。

快速测验:

但是,升级到写入操作如何排除其他读取器?

答案:

它不会排除,就像正常的 RCU 更新一样,也不会排除 RCU 读取器。

这种保证允许在读取侧和更新侧代码之间共享查找代码,这是预先考虑的,最早出现在 DYNIX/ptx RCU 文档中。

基本非要求

RCU 提供了极其轻量级的读取器,其读取侧保证虽然非常有用,但相应地也很轻量级。因此,很容易假设 RCU 保证的内容超出其实际保证的范围。当然,RCU 不保证的事情的列表是无限长的,但是,以下各节列出了一些导致混淆的非保证。除非另有说明,否则这些非保证是预先考虑的。

  1. 读取器施加最小排序

  2. 读取器不排除更新器

  3. 更新器仅等待旧的读取器

  4. 宽限期不分割读取侧临界区

  5. 读取侧临界区不分割宽限期

读取器施加最小排序

诸如 rcu_read_lock()rcu_read_unlock() 之类的读取侧标记除了通过与诸如 synchronize_rcu() 之类的宽限期 API 交互之外,不提供任何排序保证。要了解这一点,请考虑以下一对线程

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(x, 1);
 5   rcu_read_unlock();
 6   rcu_read_lock();
 7   WRITE_ONCE(y, 1);
 8   rcu_read_unlock();
 9 }
10
11 void thread1(void)
12 {
13   rcu_read_lock();
14   r1 = READ_ONCE(y);
15   rcu_read_unlock();
16   rcu_read_lock();
17   r2 = READ_ONCE(x);
18   rcu_read_unlock();
19 }

在 thread0() 和 thread1() 并发执行之后,很可能出现

(r1 == 1 && r2 == 0)

(也就是说,y 看起来是在 x 之前被赋值的),如果 rcu_read_lock()rcu_read_unlock() 具有排序属性,则这是不可能的。但是它们没有,因此 CPU 有权进行大量的重新排序。这是设计的:任何重要的排序约束都会减慢这些快速路径 API 的速度。

快速测验:

编译器也不能重新排序这段代码吗?

答案:

不,READ_ONCE() 和 WRITE_ONCE() 中的 volatile 强制转换阻止了编译器在这种特定情况下进行重新排序。

读取器不排除更新器

rcu_read_lock()rcu_read_unlock() 都不排除更新。它们所做的只是防止宽限期结束。以下示例说明了这一点

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   r1 = READ_ONCE(y);
 5   if (r1) {
 6     do_something_with_nonzero_x();
 7     r2 = READ_ONCE(x);
 8     WARN_ON(!r2); /* BUG!!! */
 9   }
10   rcu_read_unlock();
11 }
12
13 void thread1(void)
14 {
15   spin_lock(&my_lock);
16   WRITE_ONCE(x, 1);
17   WRITE_ONCE(y, 1);
18   spin_unlock(&my_lock);
19 }

如果 thread0() 函数的 rcu_read_lock() 排除了 thread1() 函数的更新,则 WARN_ON() 永远不会触发。但事实是,rcu_read_lock() 除了后续的宽限期之外,并没有排除太多东西,而 thread1() 没有宽限期,因此 WARN_ON() 可以并且会触发。

更新器仅等待旧的读取器

可能会想当然地认为,在 synchronize_rcu() 完成后,没有读取器正在执行。必须避免这种想法,因为新的读取器可以在 synchronize_rcu() 开始后立即启动,并且 synchronize_rcu() 没有义务等待这些新的读取器。

快速测验:

假设 synchronize_rcu() 确实等待直到所有读取器都完成,而不是仅等待预先存在的读取器。更新器可以依赖没有读取器多久?

答案:

根本没有时间。即使 synchronize_rcu() 要等待直到所有读取器都完成,新的读取器也可能在 synchronize_rcu() 完成后立即启动。因此,synchronize_rcu() 之后的代码绝不能依赖于没有读取器。

宽限期不分割读取侧临界区

可能会想当然地认为,如果一个 RCU 读取侧临界区的任何部分先于给定的宽限期,并且如果另一个 RCU 读取侧临界区的任何部分跟随同一个宽限期,那么第一个 RCU 读取侧临界区的所有部分必须先于第二个临界区的所有部分。但是,情况并非如此:单个宽限期不会分割 RCU 读取侧临界区的集合。这种情况的示例可以如下所示,其中 xyz 最初都为零

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   rcu_read_lock();
19   r2 = READ_ONCE(b);
20   r3 = READ_ONCE(c);
21   rcu_read_unlock();
22 }

结果

(r1 == 1 && r2 == 0 && r3 == 1)

完全有可能。下图显示了这种情况的发生方式,每个带圆圈的 QS 都表示 RCU 为每个线程记录静止状态的点,也就是说,RCU 知道该线程不可能处于当前宽限期之前开始的 RCU 读取侧临界区中

../../../_images/GPpartitionReaders1.svg

如果需要以这种方式分割 RCU 读取侧临界区,则需要使用两个宽限期,其中已知第一个宽限期在第二个宽限期开始之前结束

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   r2 = READ_ONCE(c);
19   synchronize_rcu();
20   WRITE_ONCE(d, 1);
21 }
22
23 void thread3(void)
24 {
25   rcu_read_lock();
26   r3 = READ_ONCE(b);
27   r4 = READ_ONCE(d);
28   rcu_read_unlock();
29 }

在此,如果 (r1 == 1),则 thread0() 对 b 的写入必须发生在 thread1() 的宽限期结束之前。如果此外 (r4 == 1),则 thread3() 对 b 的读取必须发生在 thread2() 的宽限期开始之后。如果 (r2 == 1) 也成立,则 thread1() 的宽限期结束必须先于 thread2() 的宽限期开始。这意味着两个 RCU 读取侧临界区不能重叠,从而保证 (r3 == 1)。因此,结果

(r1 == 1 && r2 == 1 && r3 == 0 && r4 == 1)

不可能发生。

此非要求也是非预先考虑的,但是在研究 RCU 与内存排序的交互时变得明显。

读取侧临界区不分割宽限期

可能会想当然地认为,如果 RCU 读取侧临界区发生在两个宽限期之间,那么这些宽限期不能重叠。但是,这种想法不会带来任何好处,如下所示,所有变量最初都为零

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   rcu_read_lock();
19   WRITE_ONCE(d, 1);
20   r2 = READ_ONCE(c);
21   rcu_read_unlock();
22 }
23
24 void thread3(void)
25 {
26   r3 = READ_ONCE(d);
27   synchronize_rcu();
28   WRITE_ONCE(e, 1);
29 }
30
31 void thread4(void)
32 {
33   rcu_read_lock();
34   r4 = READ_ONCE(b);
35   r5 = READ_ONCE(e);
36   rcu_read_unlock();
37 }

在这种情况下,结果

(r1 == 1 && r2 == 1 && r3 == 1 && r4 == 0 && r5 == 1)

完全有可能,如下所示

../../../_images/ReadersPartitionGP1.svg

再次,只要 RCU 读取侧临界区不重叠整个宽限期,它就可以重叠给定的宽限期几乎所有部分。因此,RCU 读取侧临界区不能分割一对 RCU 宽限期。

快速测验:

要分割链开头和结尾的 RCU 读取侧临界区,需要多少个由 RCU 读取侧临界区分隔的宽限期序列?

答案:

理论上,需要无限个。在实践中,需要一个未知的数字,该数字对实现细节和时序因素都很敏感。因此,即使在实践中,RCU 用户也必须遵守理论答案而不是实际答案。

并行性生命事实

这些并行性生命事实绝不是 RCU 所特有的,但是 RCU 实现必须遵守它们。因此,有必要重复说明

  1. 任何 CPU 或任务都可能随时被延迟,并且任何通过禁用抢占、中断或任何其他方式来避免这些延迟的尝试都是完全徒劳的。这在可抢占的用户级环境中以及在虚拟化环境中(其中给定来宾操作系统的 VCPU 可能随时被底层虚拟机监控程序抢占)中最明显,但也可能由于 ECC 错误、NMI 和其他硬件事件而发生在裸机环境中。尽管超过大约 20 秒的延迟可能导致 splats,但 RCU 实现有义务使用可以容忍极长延迟的算法,但是“极长”不足以在递增 64 位计数器时允许环绕。

  2. 编译器和 CPU 都可以重新排序内存访问。在重要的地方,RCU 必须使用编译器指令和内存屏障指令来保留排序。

  3. 对任何给定缓存行中的内存位置的冲突写入将导致昂贵的缓存未命中。更大数量的并发写入和更频繁的并发写入将导致更严重的减速。因此,RCU 有义务使用具有足够局部性的算法,以避免明显的性能和可伸缩性问题。

  4. 根据经验法则,在任何给定的互斥锁保护下,只能执行一个 CPU 的处理量。因此,RCU 必须使用可扩展的锁定设计。

  5. 计数器是有限的,尤其是在 32 位系统上。因此,RCU 对计数器的使用必须容忍计数器回绕,或者必须设计成计数器回绕所需的时间远远超过单个系统可能运行的时间。十年正常运行时间是很可能实现的,而一个世纪的运行时间则不太可能。后者的一个例子是,RCU 的 dyntick-idle 嵌套计数器为中断嵌套级别提供了 54 位(即使在 32 位系统上,此计数器也是 64 位)。要使此计数器溢出,需要在给定的 CPU 上进行 254 次半中断,且该 CPU 从未进入空闲状态。如果每微秒发生一次半中断,则需要运行 570 年才能使此计数器溢出,目前认为这是一个可以接受的长时间。

  6. Linux 系统可以在单个共享内存环境中运行数千个 CPU 的单个 Linux 内核。因此,RCU 必须密切关注高端可扩展性。

最后这个并行性的事实意味着 RCU 必须特别注意前面提到的事实。Linux 可能扩展到具有数千个 CPU 的系统这一想法在 20 世纪 90 年代可能会受到一些怀疑,但这些要求在 20 世纪 90 年代初本来就不足为奇。

实现质量要求

这些部分列出了实现质量要求。虽然可以仍然使用忽略这些要求的 RCU 实现,但它可能会受到一些限制,使其不适合工业强度的生产用途。实现质量要求的类别如下:

  1. 专业化

  2. 性能和可扩展性

  3. 向前进展

  4. 可组合性

  5. 极端情况

以下部分将介绍这些类别。

专业化

RCU 一直以来主要用于读取较多的情况,这意味着 RCU 的读取端原语已得到优化,通常以牺牲其更新端原语为代价。到目前为止的经验已通过以下情况列表捕获:

  1. 读取较多的数据,其中过时和不一致的数据不是问题:RCU 非常有效!

  2. 读取较多的数据,其中数据必须一致:RCU 效果良好。

  3. 读写数据,其中数据必须一致:RCU *可能* 可以正常工作。或者不行。

  4. 写入较多的数据,其中数据必须一致:RCU 很可能不是正确的方法,但以下情况除外,RCU 可以提供

    1. 对更新友好的机制的存在保证。

    2. 用于实时使用的无等待读取端原语。

这种对读取较多情况的关注意味着 RCU 必须与其他同步原语进行互操作。例如,前面讨论的 add_gp() 和 remove_gp_synchronous() 示例使用 RCU 来保护读取器,并使用锁定来协调更新程序。但是,这种需求会扩展得更远,要求在 RCU 读取端临界区内允许使用各种同步原语,包括自旋锁、序列锁、原子操作、引用计数器和内存屏障。

快速测验:

那么睡眠锁呢?

答案:

在 Linux 内核 RCU 读取端临界区内禁止使用这些锁,因为在 RCU 读取端临界区内放置静止状态(在这种情况下,是自愿上下文切换)是不合法的。但是,睡眠锁可以在用户空间 RCU 读取端临界区内使用,也可以在 Linux 内核可睡眠 RCU (SRCU) 读取端临界区内使用。此外,-rt 补丁集将自旋锁转换为睡眠锁,以便可以抢占相应的临界区,这也意味着这些睡眠锁化的自旋锁(而不是其他睡眠锁!)可以在 -rt-Linux 内核 RCU 读取端临界区内获取。请注意,正常的 RCU 读取端临界区*可以*有条件地获取睡眠锁(如 mutex_trylock()),但前提是它不会无限循环尝试有条件地获取该睡眠锁。关键点在于 mutex_trylock() 要么返回并持有互斥锁,要么在互斥锁不可立即用时返回错误指示。无论哪种方式,mutex_trylock() 都会立即返回,而不会睡眠。

许多算法不需要一致的数据视图,但许多算法可以以这种模式运行,而网络路由是其中的典型代表,这常常令人惊讶。互联网路由算法需要相当长的时间来传播更新,因此,当更新到达给定的系统时,该系统已经以错误的方式发送网络流量相当长的时间了。让一些线程继续以错误的方式发送流量几毫秒显然不是问题:在最坏的情况下,TCP 重传最终会将数据发送到需要发送的位置。通常,当跟踪计算机外部的宇宙状态时,必须容忍一定程度的不一致,因为如果其他因素都不考虑,则存在光速延迟。

此外,在许多情况下,外部状态的不确定性是固有的。例如,一对兽医可以使用心跳来确定给定的猫是否还活着。但是,他们应该在最后一次心跳后等待多长时间才能断定猫实际上已经死了?等待时间少于 400 毫秒是没有意义的,因为这将意味着一只放松的猫会被认为每分钟在死亡和生命之间循环 100 多次。此外,就像人类一样,猫的心脏可能会停止一段时间,因此确切的等待时间是一种判断。我们的一对兽医中的一位可能会在宣布猫死亡之前等待 30 秒,而另一位则可能会坚持等待整整一分钟。然后,这两位兽医会在最后一次心跳后一分钟的最后 30 秒内对猫的状态产生分歧。

有趣的是,这种情况同样适用于硬件。当需要时,我们如何判断某个外部服务器是否发生故障?我们会定期向其发送消息,如果在给定的时间内没有收到响应,则声明它已失败。策略决策通常可以容忍短时间的不一致。该策略是在一段时间之前决定的,现在才开始生效,因此几毫秒的延迟通常无关紧要。

但是,有些算法绝对必须看到一致的数据。例如,用户级 SystemV 信号量 ID 与相应的内核数据结构之间的转换受到 RCU 的保护,但绝对禁止更新刚刚删除的信号量。在 Linux 内核中,这种对一致性的需求通过从 RCU 读取端临界区内获取位于内核数据结构中的自旋锁来满足,如上图中的绿色方框所示。可以使用许多其他技术,并且实际上在 Linux 内核中使用。

简而言之,RCU 不需要维护一致性,当需要一致性时,可以将其他机制与 RCU 结合使用。RCU 的专业化使其能够出色地完成其工作,并且其与其他同步机制互操作的能力允许将正确的同步工具组合用于给定的工作。

性能和可扩展性

能效是当今性能的关键组成部分,因此 Linux 内核 RCU 实现必须避免不必要地唤醒空闲 CPU。我不能声称此要求是预谋的。实际上,我是在一次电话交谈中得知此事的,在电话中,我收到了有关电池供电系统中能效的重要性以及 Linux 内核 RCU 实现的特定能效缺点的“坦诚和公开”的反馈。以我的经验来看,电池供电的嵌入式社区会认为任何不必要的唤醒都是非常不友好的行为。以至于仅仅在 Linux 内核邮件列表上发帖不足以发泄他们的愤怒。

在大多数情况下,内存消耗并不特别重要,并且随着内存大小的扩大和内存成本的暴跌,内存消耗已变得越来越不重要。但是,正如我从 Matt Mackall 的 bloatwatch 工作中了解到的那样,内存占用对于具有不可抢占的(CONFIG_PREEMPTION=n)内核的单 CPU 系统至关重要,因此 tiny RCU 应运而生。此后,Josh Triplett 通过他的 Linux 内核精简项目接管了小内存的旗帜,该项目导致 SRCU 对于那些不需要它的内核成为可选的。

其余的性能要求在很大程度上并不令人惊讶。例如,为了与 RCU 的读取端专业化保持一致,rcu_dereference() 应该具有可忽略的开销(例如,抑制一些小的编译器优化)。同样,在不可抢占的环境中,rcu_read_lock()rcu_read_unlock() 应该具有完全为零的开销。

在可抢占环境中,如果 RCU 读取端临界区没有被抢占(对于最高优先级的实时进程来说通常是这种情况),rcu_read_lock()rcu_read_unlock() 的开销应该非常小。特别是,它们不应包含原子读-修改-写操作、内存屏障指令、禁用抢占、禁用中断或向后分支。但是,如果 RCU 读取端临界区被抢占,rcu_read_unlock() 可能会获取自旋锁并禁用中断。这就是为什么在至少在临界区足够短以避免过度降低实时延迟的情况下,最好将 RCU 读取端临界区嵌套在禁用抢占区域内,而不是反之。

synchronize_rcu() 宽限期等待原语针对吞吐量进行了优化。因此,除了最长的 RCU 读取端临界区的持续时间外,它还可能产生几毫秒的延迟。另一方面,需要多次并发调用 synchronize_rcu() 以使用批量优化,以便可以通过单个底层宽限期等待操作来满足它们。例如,在 Linux 内核中,单个宽限期等待操作为超过 1,000 个单独调用 synchronize_rcu() 服务是很常见的,从而将每次调用的开销摊销到接近于零。但是,宽限期优化也必须避免实时调度和中断延迟的明显下降。

在某些情况下,几毫秒的 synchronize_rcu() 延迟是不可接受的。在这些情况下,可以使用 synchronize_rcu_expedited() 来代替,在小型系统上将宽限期延迟降低到几十微秒,至少在 RCU 读取端临界区较短的情况下是这样。目前,大型系统上的 synchronize_rcu_expedited() 没有特殊的延迟要求,但是,与 RCU 规范的经验性质一致,这种情况可能会发生变化。但是,绝对有可扩展性要求:在 4096 个 CPU 上大量调用 synchronize_rcu_expedited() 至少应该取得合理的进展。为了换取更短的延迟,允许 synchronize_rcu_expedited() 对非空闲在线 CPU 的实时延迟造成适度的下降。这里,“适度”意味着大致与调度时钟中断相同的延迟下降。

在许多情况下,即使 synchronize_rcu_expedited() 缩短的宽限期延迟也是不可接受的。在这些情况下,可以使用异步的 call_rcu() 来代替 synchronize_rcu(),如下所示

 1 struct foo {
 2   int a;
 3   int b;
 4   struct rcu_head rh;
 5 };
 6
 7 static void remove_gp_cb(struct rcu_head *rhp)
 8 {
 9   struct foo *p = container_of(rhp, struct foo, rh);
10
11   kfree(p);
12 }
13
14 bool remove_gp_asynchronous(void)
15 {
16   struct foo *p;
17
18   spin_lock(&gp_lock);
19   p = rcu_access_pointer(gp);
20   if (!p) {
21     spin_unlock(&gp_lock);
22     return false;
23   }
24   rcu_assign_pointer(gp, NULL);
25   call_rcu(&p->rh, remove_gp_cb);
26   spin_unlock(&gp_lock);
27   return true;
28 }

最后需要 struct foo 的定义,它出现在第 1-5 行。函数 remove_gp_cb() 在第 25 行传递给 call_rcu(),它将在后续宽限期结束后被调用。这与 remove_gp_synchronous() 具有相同的效果,但不会强制更新程序等待宽限期过去。call_rcu() 函数可用于许多情况下,在这些情况下,synchronize_rcu()synchronize_rcu_expedited() 都是非法的,包括在禁用抢占的代码、local_bh_disable() 代码、禁用中断的代码和中断处理程序中。但是,即使 call_rcu() 在 NMI 处理程序和空闲及离线 CPU 中也是非法的。回调函数(在本例中为 remove_gp_cb())将在 Linux 内核中的软中断(软件中断)环境中执行,或者在真正的软中断处理程序中,或者在 local_bh_disable() 的保护下。在 Linux 内核和用户空间中,编写运行时间过长的 RCU 回调函数是不好的做法。长时间运行的操作应委托给单独的线程或(在 Linux 内核中)工作队列。

快速测验:

为什么第 19 行使用 rcu_access_pointer()?毕竟,第 25 行的 call_rcu() 会存储到结构中,这会与并发插入产生不良的交互。这是否意味着需要 rcu_dereference() 吗?

答案:

大概第 18 行获取的 ->gp_lock 排除任何更改,包括 rcu_dereference() 将保护的任何插入。因此,任何插入都将延迟到第 25 行释放 ->gp_lock 之后,这反过来意味着 rcu_access_pointer() 就足够了。

但是,remove_gp_cb() 所做的只是在数据元素上调用 kfree()。这是一种常见的习惯用法,kfree_rcu() 支持此习惯用法,它允许“即发即忘”的操作,如下所示

 1 struct foo {
 2   int a;
 3   int b;
 4   struct rcu_head rh;
 5 };
 6
 7 bool remove_gp_faf(void)
 8 {
 9   struct foo *p;
10
11   spin_lock(&gp_lock);
12   p = rcu_dereference(gp);
13   if (!p) {
14     spin_unlock(&gp_lock);
15     return false;
16   }
17   rcu_assign_pointer(gp, NULL);
18   kfree_rcu(p, rh);
19   spin_unlock(&gp_lock);
20   return true;
21 }

请注意,remove_gp_faf() 只是调用 kfree_rcu() 并继续,无需再进一步关注后续的宽限期和 kfree()。允许从与 call_rcu() 相同的环境调用 kfree_rcu()。有趣的是,DYNIX/ptx 具有等效于 call_rcu()kfree_rcu() 的功能,但没有 synchronize_rcu()。这是因为 RCU 在 DYNIX/ptx 中没有被大量使用,所以极少数需要类似 synchronize_rcu() 的地方只是将其直接编码。

快速测验:

前面声称 call_rcu()kfree_rcu() 允许更新程序避免被读取程序阻塞。但是,考虑到回调的调用和内存的释放(分别)仍然必须等待宽限期过去,这怎么可能是正确的呢?

答案:

我们可以这样定义,但请记住,这种定义会认为,垃圾回收语言中的更新必须等到下一次垃圾回收器运行时才能完成,这似乎完全不合理。关键在于,在大多数情况下,使用 call_rcu()kfree_rcu() 的更新器,一旦调用了 call_rcu()kfree_rcu(),就可以继续进行下一个更新,而无需等待后续的宽限期。

但是,如果更新器必须等待宽限期结束后执行的代码完成,但同时还有其他任务可以执行呢?如下所示,可以使用轮询式的 get_state_synchronize_rcu()cond_synchronize_rcu() 函数来实现此目的。

 1 bool remove_gp_poll(void)
 2 {
 3   struct foo *p;
 4   unsigned long s;
 5
 6   spin_lock(&gp_lock);
 7   p = rcu_access_pointer(gp);
 8   if (!p) {
 9     spin_unlock(&gp_lock);
10     return false;
11   }
12   rcu_assign_pointer(gp, NULL);
13   spin_unlock(&gp_lock);
14   s = get_state_synchronize_rcu();
15   do_something_while_waiting();
16   cond_synchronize_rcu(s);
17   kfree(p);
18   return true;
19 }

在第 14 行,get_state_synchronize_rcu() 从 RCU 获取一个“cookie”,然后第 15 行执行其他任务,最后,如果在此期间宽限期已过,则第 16 行立即返回,否则按需等待。对 get_state_synchronize_rcucond_synchronize_rcu() 的需求最近才出现,因此现在判断它们是否经得起时间的考验还为时过早。

因此,RCU 提供了一系列工具,允许更新器在延迟、灵活性和 CPU 开销之间取得所需的权衡。

向前进展

理论上,延迟宽限期完成和回调调用是无害的。但在实践中,不仅内存大小有限,而且回调有时会进行唤醒操作,并且延迟足够长时间的唤醒可能很难与系统挂起区分开来。因此,RCU 必须提供一些机制来促进向前进展。

这些机制并非万无一失,也不可能做到万无一失。例如,一个 RCU 读取侧临界区中的无限循环必然会阻止后续的宽限期完成。再举一个更复杂的例子,考虑一个使用 CONFIG_RCU_NOCB_CPU=y 构建并使用 rcu_nocbs=1-63 启动的 64 CPU 系统,其中 CPU 1 到 63 在紧密循环中旋转,并调用 call_rcu()。即使这些紧密循环也包含对 cond_resched() 的调用(从而允许宽限期完成),CPU 0 也无法像其他 63 个 CPU 注册回调那样快地调用回调,至少在系统耗尽内存之前是这样。在这两个示例中,都适用蜘蛛侠原则:能力越大,责任越大。但是,在没有达到这种滥用程度的情况下,RCU 需要确保宽限期及时完成和回调及时调用。

RCU 采取以下步骤来鼓励宽限期及时完成

  1. 如果宽限期在 100 毫秒内未能完成,RCU 将导致在保持 CPU 上对 cond_resched() 的未来调用提供 RCU 静默状态。RCU 还会导致这些 CPU 的 need_resched() 调用返回 true,但仅在相应 CPU 的下一个调度时钟之后。

  2. nohz_full 内核引导参数中提到的 CPU 可以在内核中无限期地运行而无需调度时钟中断,这会使上述 need_resched() 策略失效。因此,RCU 将在 109 毫秒后仍在保持的任何 nohz_full CPU 上调用 resched_cpu()。

  3. 在使用 CONFIG_RCU_BOOST=y 构建的内核中,如果在 RCU 读取侧临界区内被抢占的给定任务保持时间超过 500 毫秒,RCU 将求助于优先级提升。

  4. 如果 CPU 在宽限期开始后的 10 秒内仍然保持,RCU 将在其上调用 resched_cpu(),而无论其 nohz_full 状态如何。

上述值是运行 HZ=1000 的系统的默认值。它们会随着 HZ 的值的变化而变化,也可以使用相关的 Kconfig 选项和内核引导参数进行更改。RCU 目前没有对这些参数进行太多健全性检查,因此在更改它们时请谨慎。请注意,这些向前进展措施仅针对 RCU 提供,不针对 SRCUTasks RCU

当任何给定非 rcu_nocbs CPU 具有 10,000 个回调,或比上次提供鼓励时多 10,000 个回调时,RCU 在 call_rcu() 中采取以下步骤来鼓励及时调用回调

  1. 如果宽限期尚未进行,则启动一个宽限期。

  2. 强制立即检查静默状态,而不是等待自宽限期开始以来经过三毫秒。

  3. 立即使用其宽限期完成编号标记 CPU 的回调,而不是等待 RCU_SOFTIRQ 处理程序来处理它。

  4. 取消回调执行批次限制,这会加速回调调用,但代价是降低实时响应。

同样,这些是在 HZ=1000 下运行时的默认值,可以被覆盖。同样,这些向前进展措施仅针对 RCU 提供,不针对 SRCUTasks RCU。即使对于 RCU,rcu_nocbs CPU 的回调调用向前进展也远不如完善,部分原因是受益于 rcu_nocbs CPU 的工作负载往往相对不频繁地调用 call_rcu()。如果出现既需要 rcu_nocbs CPU 又需要高 call_rcu() 调用率的工作负载,那么就需要进行额外的向前进展工作。

可组合性

近年来,可组合性受到了广泛关注,部分原因可能是多核硬件与在单线程环境中为单线程使用而设计的面向对象技术之间的冲突。理论上,RCU 读取侧临界区可以组合,并且实际上可以任意深度地嵌套。在实践中,与所有可组合构造的实际实现一样,都存在限制。

对于 rcu_read_lock()rcu_read_unlock() 不生成代码的 RCU 实现,例如当 CONFIG_PREEMPTION=n 时 Linux 内核的 RCU,可以任意深度地嵌套。毕竟,没有开销。除非所有这些 rcu_read_lock()rcu_read_unlock() 的实例对编译器可见,否则由于耗尽内存、海量存储或用户耐心,编译最终将失败,以先到者为准。如果嵌套对编译器不可见,就像在各自的翻译单元中的相互递归函数一样,则会导致堆栈溢出。如果嵌套采用循环形式,也许是以尾递归的形式,则控制变量将溢出,或者(在 Linux 内核中)您将收到 RCU CPU 停止警告。尽管如此,此类 RCU 实现是现有最具可组合性的构造之一。

显式跟踪嵌套深度的 RCU 实现受到嵌套深度计数器的限制。例如,Linux 内核的可抢占 RCU 将嵌套限制为 INT_MAX。这应该足以满足几乎所有实际用途。也就是说,在等待宽限期的操作之间的一对连续的 RCU 读取侧临界区不能包含在另一个 RCU 读取侧临界区中。这是因为在 RCU 读取侧临界区内等待宽限期是不合法的:这样做会导致死锁,或者导致 RCU 隐式拆分封闭的 RCU 读取侧临界区,这两种情况都不利于长期运行且繁荣的内核。

值得注意的是,RCU 并非唯一限制可组合性的机制。例如,许多事务性内存实现禁止组合由不可撤销操作(例如,网络接收操作)分隔的一对事务。再例如,基于锁的临界区可以自由地进行组合,但前提是避免了死锁。

简而言之,虽然 RCU 读取侧临界区具有高度可组合性,但在某些情况下需要小心,就像任何其他可组合的同步机制一样。

极端情况

给定的 RCU 工作负载可能具有无休止且密集的 RCU 读取侧临界区流,甚至可能如此密集,以至于在任何时间点都至少有一个 RCU 读取侧临界区在运行。RCU 不能允许这种情况阻止宽限期:只要所有 RCU 读取侧临界区都是有限的,宽限期也必须是有限的。

也就是说,可抢占 RCU 实现可能会导致 RCU 读取侧临界区被抢占很长时间,这会产生长时间的 RCU 读取侧临界区。这种情况只能在负载较重的系统中出现,但使用实时优先级的系统当然更容易受到影响。因此,提供了 RCU 优先级提升来帮助处理这种情况。也就是说,随着经验的积累,对 RCU 优先级提升的确切要求可能会发生变化。

其他工作负载可能具有非常高的更新率。尽管有人可能会认为,这类工作负载应该使用 RCU 之外的其他机制,但事实仍然是 RCU 必须优雅地处理这类工作负载。这一要求是推动宽限期批量处理的另一个因素,同时也是 call_rcu() 代码路径中检查大量排队 RCU 回调的原因。最后,高更新率不应延迟 RCU 读取端临界区,尽管在使用 synchronize_rcu_expedited() 时可能会出现一些小的读取端延迟,这归因于此函数使用了 smp_call_function_single()。

尽管所有这三个极端情况在 20 世纪 90 年代早期就已得到理解,但 21 世纪初的一个简单的用户级测试,即在紧凑循环中 close(open(path)),突然让人对高更新率的极端情况有了更深刻的理解。此测试还促使添加了一些 RCU 代码来应对高更新率,例如,如果给定的 CPU 发现自己有超过 10,000 个 RCU 回调排队,它将导致 RCU 采取规避措施,更积极地启动宽限期并更积极地强制完成宽限期处理。这种规避措施会导致宽限期更快完成,但代价是限制了 RCU 的批量优化,从而增加了该宽限期产生的 CPU 开销。

软件工程要求

由于墨菲定律和“人非圣贤,孰能无过”,有必要防范事故和滥用

  1. 人们很容易忘记在需要的地方都使用 rcu_read_lock(),因此使用 CONFIG_PROVE_RCU=y 构建的内核如果在 RCU 读取端临界区之外使用 rcu_dereference() 将会崩溃。更新端代码可以使用 rcu_dereference_protected(),该函数采用 lockdep 表达式 来指示提供保护的内容。如果未提供指示的保护,则会发出 lockdep 崩溃信息。在读取器和更新器之间共享的代码可以使用 rcu_dereference_check(),该函数也采用 lockdep 表达式,如果既没有 rcu_read_lock() 也没有指示的保护,则会发出 lockdep 崩溃信息。此外,在那些(希望是极少数)无法轻松描述所需保护的情况下,可以使用 rcu_dereference_raw()。最后,提供 rcu_read_lock_held() 以允许函数验证它是否在 RCU 读取端临界区内被调用。在 Thomas Gleixner 审核了许多 RCU 用法后不久,我就意识到了这一系列要求。

  2. 给定函数可能希望在进入时检查 RCU 相关的前提条件,然后再使用任何其他 RCU API。 rcu_lockdep_assert() 完成此项工作,在启用了 lockdep 的内核中断言表达式,否则不执行任何操作。

  3. 也很容易忘记使用 rcu_assign_pointer()rcu_dereference(),或许(错误地)替换为简单的赋值。为了捕获这种类型的错误,可以使用 __rcu 标记给定的受 RCU 保护的指针,之后 sparse 将会抱怨对该指针的简单赋值访问。Arnd Bergmann 让我意识到了这一要求,并且还提供了所需的 补丁系列

  4. 如果数据元素在没有中间宽限期的情况下连续两次传递给 call_rcu(),则使用 CONFIG_DEBUG_OBJECTS_RCU_HEAD=y 构建的内核将会崩溃。(此错误类似于双重释放。)动态分配的相应 rcu_head 结构会自动跟踪,但在堆栈上分配的 rcu_head 结构必须使用 init_rcu_head_on_stack() 进行初始化,并使用 destroy_rcu_head_on_stack() 进行清理。类似地,静态分配的非堆栈 rcu_head 结构必须使用 init_rcu_head() 进行初始化,并使用 destroy_rcu_head() 进行清理。Mathieu Desnoyers 让我意识到了这一要求,并且还提供了所需的 补丁

  5. RCU 读取端临界区中的无限循环最终将触发 RCU CPU 停顿警告崩溃,其中“最终”的持续时间由 RCU_CPU_STALL_TIMEOUT Kconfig 选项或 rcupdate.rcu_cpu_stall_timeout 引导/sysfs 参数控制。但是,除非有宽限期等待该特定 RCU 读取端临界区,否则 RCU 没有义务生成此崩溃。

    一些极端的工作负载可能会故意延迟 RCU 宽限期,运行这些工作负载的系统可以使用 rcupdate.rcu_cpu_stall_suppress 引导,以抑制崩溃信息。此内核参数也可以通过 sysfs 设置。此外,RCU CPU 停顿警告在 sysrq 转储期间和崩溃期间会产生反作用。因此,RCU 提供了 rcu_sysrq_start() 和 rcu_sysrq_end() API 成员,以便在长 sysrq 转储之前和之后调用。RCU 还提供了 rcu_panic() 通知程序,该通知程序会在崩溃开始时自动调用,以抑制进一步的 RCU CPU 停顿警告。

    在 20 世纪 90 年代初,当首次需要调试 CPU 停顿时,就知道了此要求。也就是说,与 Linux 相比,DYNIX/ptx 中的初始实现相当通用。

  6. 尽管检测从 RCU 读取端临界区泄露的指针会非常好,但目前没有很好的方法可以做到这一点。一个复杂因素是需要区分泄露的指针和从 RCU 移交给其他同步机制(例如,引用计数)的指针。

  7. 在使用 CONFIG_RCU_TRACE=y 构建的内核中,RCU 相关信息通过事件跟踪提供。

  8. 使用 rcu_assign_pointer()rcu_dereference() 来创建典型的链接数据结构可能会非常容易出错。因此,受 RCU 保护的链表以及最近受 RCU 保护的哈希表可用。Linux 内核和用户空间 RCU 库中提供了许多其他专用受 RCU 保护的数据结构。

  9. 一些链接结构是在编译时创建的,但仍然需要 __rcu 检查。RCU_POINTER_INITIALIZER() 宏用于此目的。

  10. 在创建要通过单个外部指针发布的链接结构时,无需使用 rcu_assign_pointer()。为此任务提供了 RCU_INIT_POINTER() 宏。

这不是一个硬性列表:RCU 的诊断能力将继续受到在实际 RCU 使用中发现的使用 bug 的数量和类型的指导。

Linux 内核的复杂性

Linux 内核为各种软件(包括 RCU)提供了一个有趣的环境。下面是一些相关的兴趣点:

  1. 配置

  2. 固件接口

  3. 早期引导

  4. 中断和 NMI

  5. 可加载模块

  6. 热插拔 CPU

  7. 调度器和 RCU

  8. 跟踪和 RCU

  9. 对用户内存的访问和 RCU

  10. 能效

  11. 调度时钟中断和 RCU

  12. 内存效率

  13. 性能、可扩展性、响应时间和可靠性

此列表可能不完整,但确实让您感受到了最显著的 Linux 内核复杂性。以下各节涵盖上述主题之一。

配置

RCU 的目标是自动配置,以便几乎没有人需要担心 RCU 的 Kconfig 选项。对于几乎所有用户来说,RCU 实际上都“开箱即用”地工作良好。

然而,有些特殊的用例需要通过内核启动参数和 Kconfig 选项来处理。不幸的是,Kconfig 系统会明确询问用户关于新的 Kconfig 选项,这要求几乎所有这些选项都必须隐藏在一个 CONFIG_RCU_EXPERT Kconfig 选项之后。

这一切应该很明显,但事实是 Linus Torvalds 最近不得不 提醒 我这个要求。

固件接口

在许多情况下,内核从固件获取有关系统的信息,有时在转换过程中会丢失信息。或者转换是准确的,但原始消息是错误的。

例如,某些系统的固件会多报 CPU 的数量,有时甚至会多报很多。如果 RCU 像以前那样天真地相信固件,它会创建过多的每个 CPU 的 kthread。虽然生成的系统仍然可以正确运行,但额外的 kthread 会不必要地消耗内存,并且当它们出现在 ps 列表中时可能会引起混乱。

因此,RCU 必须等待给定的 CPU 实际联机后,才能允许自己相信该 CPU 实际存在。由此产生的“幽灵 CPU”(永远不会联机)会引起许多 有趣的复杂情况

早期启动

Linux 内核的启动过程是一个有趣的过程,RCU 会在早期使用,甚至在调用 rcu_init() 之前。事实上,只要初始任务的 task_struct 可用并且启动 CPU 的每个 CPU 变量设置完毕,就可以使用 RCU 的许多原语。读端原语(rcu_read_lock()rcu_read_unlock()rcu_dereference()rcu_access_pointer())会很早就能正常运行,rcu_assign_pointer() 也是如此。

虽然 call_rcu() 可以在启动期间的任何时间调用,但不能保证在所有 RCU 的 kthread 生成之后才能调用回调,这发生在 early_initcall() 时。回调调用的延迟是由于 RCU 在完全初始化之前不会调用回调,并且只有在调度程序将自己初始化到 RCU 可以生成并运行其 kthread 的程度之后,才能进行此完全初始化。理论上,可以更早地调用回调,但这并不是万能的,因为对这些回调可以调用的操作会有严格的限制。

也许令人惊讶的是,synchronize_rcu()synchronize_rcu_expedited() 将在非常早期的启动期间正常运行,原因是因为只有一个 CPU 并且抢占被禁用。这意味着调用 synchronize_rcu() (或其友元)本身就是一个静止状态,因此是一个宽限期,因此早期启动的实现可以是一个空操作。

但是,一旦调度程序生成了它的第一个 kthread,对于 CONFIG_PREEMPTION=y 内核中的 synchronize_rcu()(以及 synchronize_rcu_expedited()),这个早期启动的技巧就失效了。原因是 RCU 读端临界区可能会被抢占,这意味着后续的 synchronize_rcu() 确实必须等待某些事情,而不是简单地立即返回。不幸的是,synchronize_rcu() 在所有 kthread 生成之前都无法执行此操作,而这要到 early_initcalls() 期间的某个时间才会发生。但这并不是借口:仍然需要 RCU 在此期间正确处理同步宽限期。一旦所有 kthread 都启动并运行,RCU 就会开始正常运行。

快速测验:

在所有 kthread 都生成之前,RCU 如何可能处理宽限期呢?

答案:

非常小心!在调度程序生成第一个任务的时间和所有 RCU kthread 都生成的时间之间的“死区”期间,所有同步宽限期都由加速宽限期机制处理。在运行时,此加速机制依赖于工作队列,但在死区期间,请求任务本身会驱动所需的加速宽限期。由于死区执行发生在任务上下文中,因此一切正常。一旦死区结束,加速宽限期将恢复使用工作队列,这是避免用户任务在驱动加速宽限期时收到 POSIX 信号时可能出现的问题所必需的。

是的,这确实意味着在调度程序生成其第一个 kthread 的时间和 RCU 的所有 kthread 都生成的时间之间向随机任务发送 POSIX 信号是不明智的。如果将来发现有充分的理由在此期间发送 POSIX 信号,将会进行适当的调整。(如果发现在此期间发送 POSIX 信号没有任何充分的理由,将会进行其他调整,无论是适当的还是不适当的。)

我了解到这些启动时需求是一系列系统挂起的结果。

中断和 NMI

Linux 内核具有中断,并且 RCU 读端临界区在中断处理程序中和代码的禁用中断区域内都是合法的,call_rcu() 的调用也是如此。

一些 Linux 内核架构可以从非空闲进程上下文中进入中断处理程序,然后永远不会离开它,而是偷偷地转换回进程上下文。这种技巧有时用于从内核内部调用系统调用。这些“半中断”意味着 RCU 必须非常小心地计算中断嵌套级别。在重写 RCU 的 dyntick 空闲代码期间,我通过惨痛的经历了解了此要求。

Linux 内核具有不可屏蔽中断 (NMI),并且 RCU 读端临界区在 NMI 处理程序内是合法的。值得庆幸的是,RCU 更新端原语,包括 call_rcu(),在 NMI 处理程序中是被禁止的。

尽管名称如此,但某些 Linux 内核架构可以具有嵌套的 NMI,RCU 必须正确处理这些 NMI。Andy Lutomirski 让我惊讶 地提出了这个要求;他还好心地 用一种满足此要求的算法 让我感到惊讶。

此外,NMI 处理程序可能会被 RCU 认为是正常中断的中断所中断。发生这种情况的一种方式是从 NMI 处理程序调用直接调用 ct_irq_enter() 和 ct_irq_exit() 的代码。这个惊人的事实促成了当前的代码结构,该结构具有 ct_irq_enter() 调用 ct_nmi_enter() 和 ct_irq_exit() 调用 ct_nmi_exit()。是的,我也通过惨痛的经历了解了此要求。

可加载模块

Linux 内核具有可加载模块,这些模块也可以被卸载。在给定的模块卸载后,任何尝试调用其函数的行为都会导致段错误。因此,模块卸载函数必须取消对可加载模块函数的任何延迟调用,例如,必须通过 timer_shutdown_sync() 或类似方法处理任何未完成的 mod_timer()

不幸的是,没有办法取消 RCU 回调;一旦您调用 call_rcu(),回调函数最终将被调用,除非系统首先崩溃。由于通常认为在响应模块卸载请求时使系统崩溃是不负责任的,因此我们需要一些其他方法来处理正在进行的 RCU 回调。

因此,RCU 提供了 rcu_barrier(),它会等待直到所有正在进行的 RCU 回调都被调用。如果模块使用 call_rcu(),则其退出函数应阻止将来调用 call_rcu(),然后调用 rcu_barrier()。理论上,底层模块卸载代码可以无条件调用 rcu_barrier(),但实际上这会带来无法接受的延迟。

Nikita Danilov 指出了类似的文件系统卸载情况的这个要求,Dipankar Sarma 将 rcu_barrier() 合并到 RCU 中。后来才发现模块卸载需要 rcu_barrier()

重要

rcu_barrier() 函数没有义务(重复一遍,没有义务)等待宽限期。 它只需要等待已经发布 RCU 回调。因此,如果系统中任何地方都没有发布 RCU 回调,rcu_barrier() 可以立即返回。 即使发布了回调,rcu_barrier() 也未必需要等待宽限期。

快速测验:

等一下!每个 RCU 回调都必须等待宽限期完成,而 rcu_barrier() 必须等待每个预先存在的回调被调用。 那么,如果系统中的任何地方甚至有一个回调被发布,rcu_barrier() 是否需要等待整个宽限期?

答案:

绝对不是!!!是的,每个 RCU 回调都必须等待宽限期完成,但当 rcu_barrier() 被调用时,它很可能已经部分(甚至完全)完成了等待。 在这种情况下,rcu_barrier() 只需要等待宽限期剩余的部分即可。 因此,即使发布了相当多的回调,rcu_barrier() 也可能很快返回。

因此,如果您需要等待宽限期以及所有预先存在的回调,则需要同时调用 synchronize_rcu()rcu_barrier()。如果延迟是一个问题,您可以始终使用工作队列来并发调用它们。

热插拔 CPU

Linux 内核支持 CPU 热插拔,这意味着 CPU 可以来来去去。从离线 CPU 使用任何 RCU API 成员都是非法的,但 SRCU 读取侧临界区除外。 DYNIX/ptx 从一开始就存在此要求,但另一方面,Linux 内核的 CPU 热插拔实现“很有趣”。

Linux 内核 CPU 热插拔实现具有通知器,用于允许各种内核子系统(包括 RCU)对给定的 CPU 热插拔操作做出适当的响应。 大多数 RCU 操作都可以从 CPU 热插拔通知器调用,甚至包括同步宽限期操作,例如 (synchronize_rcu()synchronize_rcu_expedited())。 但是,这些同步操作会阻塞,因此不能从通过 stop_machine() 执行的通知器调用,特别是 CPUHP_AP_OFFLINECPUHP_AP_ONLINE 状态之间的通知器。

此外,诸如 rcu_barrier() 之类的所有回调等待操作不能从任何 CPU 热插拔通知器调用。此限制是由于 CPU 热插拔操作的某些阶段中,传出 CPU 的回调要等到 CPU 热插拔操作结束后才会调用,这也可能导致死锁。此外,rcu_barrier() 在其执行期间会阻止 CPU 热插拔操作,从而导致从 CPU 热插拔通知器调用时出现另一种类型的死锁。

最后,RCU 必须避免由于热插拔、定时器和宽限期处理之间的交互而导致的死锁。它通过维护自己的一组账本(复制集中维护的 cpu_online_mask),以及在 CPU 脱机时显式报告静止状态来实现这一点。这种显式报告静止状态避免了强制静止状态循环 (FQS) 为脱机 CPU 报告静止状态的任何需要。但是,作为调试措施,如果脱机 CPU 阻塞 RCU 宽限期时间过长,FQS 循环会喷出消息。

脱机 CPU 的静止状态将通过以下方式报告:

  1. 当 CPU 使用 RCU 的热插拔通知器 (rcutree_report_cpu_dead()) 脱机时。

  2. 当宽限期初始化 (rcu_gp_init()) 检测到与 CPU 脱机或任务在叶 rcu_node 结构(其 CPU 全部脱机)上解除阻塞的竞争时。

CPU 在线路径 (rcutree_report_cpu_starting()) 永远不需要为脱机 CPU 报告静止状态。但是,作为调试措施,如果尚未为该 CPU 报告静止状态,它会发出警告。

在检查/修改 RCU 的热插拔账本期间,会持有相应 CPU 的叶节点锁。这避免了 RCU 的热插拔通知器钩子、宽限期初始化代码和 FQS 循环之间的竞争条件,所有这些都引用或修改此账本。

调度程序和 RCU

RCU 使用 kthread,必须避免这些 kthread 过度累积 CPU 时间。这个要求并不令人意外,但当使用 CONFIG_NO_HZ_FULL=y 构建时,RCU 在运行上下文切换繁重的工作负载时违反了它,确实令人惊讶 [PDF]。 RCU 在满足此要求方面取得了良好进展,即使对于上下文切换繁重的 CONFIG_NO_HZ_FULL=y 工作负载也是如此,但仍有改进的空间。

rcu_read_unlock() 中,不再禁止持有任何调度程序的运行队列或优先级继承自旋锁,即使中断和抢占在相应的 RCU 读取侧临界区内的某个位置启用也是如此。 因此,现在可以合法地在启用抢占的情况下执行 rcu_read_lock(),获取其中一个调度程序锁,并在匹配的 rcu_read_unlock() 中持有该锁。

类似地,RCU 风格的整合消除了负嵌套的需要。代码的中断禁用区域充当 RCU 读取侧临界区这一事实,隐式避免了早期因中断处理程序使用 RCU 而导致破坏性递归的问题。

跟踪和 RCU

可以在 RCU 代码上使用跟踪,但跟踪本身使用 RCU。因此,为跟踪提供了 rcu_dereference_raw_check(),避免了可能发生的破坏性递归。此 API 也被某些体系结构中的虚拟化使用,在这些体系结构中,RCU 读取器在无法使用跟踪的环境中执行。 跟踪人员找到了需求并提供了所需的修复程序,因此此意外需求相对轻松。

访问用户内存和 RCU

内核需要访问用户空间内存,例如,访问系统调用参数引用的数据。 get_user() 宏完成此工作。

但是,用户空间内存很可能被分页出去,这意味着 get_user() 很可能会出现页面错误,因此会在等待生成的 I/O 完成时阻塞。 对于编译器来说,将 get_user() 调用重新排序到 RCU 读取侧临界区中是一件非常糟糕的事情。

例如,假设源代码如下所示

1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 v = p->value;
4 rcu_read_unlock();
5 get_user(user_v, user_p);
6 do_something_with(v, user_v);

不允许编译器将此源代码转换为以下内容

1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 get_user(user_v, user_p); // BUG: POSSIBLE PAGE FAULT!!!
4 v = p->value;
5 rcu_read_unlock();
6 do_something_with(v, user_v);

如果编译器在 CONFIG_PREEMPTION=n 内核构建中进行了此转换,并且如果 get_user() 确实出现了页面错误,则结果将是 RCU 读取侧临界区中间的静止状态。 这种错位的静止状态可能导致第 4 行是释放后使用的访问,这对您内核的精算统计数据不利。 可以使用 get_user() 调用在 rcu_read_lock() 之前的情况构建类似的示例。

不幸的是,get_user() 没有任何特定的排序属性,并且在某些体系结构中,底层的 asm 甚至没有标记为 volatile。 即使它被标记为 volatile,上述对 p->value 的访问也不是易失的,因此编译器没有任何理由保持这两个访问的顺序。

因此,Linux 内核的 rcu_read_lock()rcu_read_unlock() 的定义必须充当编译器屏障,至少对于嵌套 RCU 读取侧临界区中最外层的 rcu_read_lock()rcu_read_unlock() 实例。

能效

中断空闲 CPU 被认为是不受欢迎的行为,特别是对于使用电池供电的嵌入式系统用户而言。因此,RCU 通过检测哪些 CPU 处于空闲状态来节省能源,包括跟踪从空闲状态被中断的 CPU。这是能效要求的重要组成部分,我通过一个愤怒的电话了解到了这一点。

因为 RCU 避免中断空闲 CPU,所以在空闲 CPU 上执行 RCU 读取端临界区是非法的。(如果尝试在启用了 CONFIG_PROVE_RCU=y 的内核上执行此操作,将会出现 splat。)

同样,中断在用户空间中运行的 nohz_full CPU 也是不可接受的。因此,RCU 必须跟踪 nohz_full 用户空间的执行情况。因此,RCU 必须能够采样两个时间点的状态,并能够确定其他 CPU 是否有任何时间处于空闲状态和/或在用户空间中执行。

事实证明,这些能效要求很难理解和满足,例如,RCU 的能效代码已经进行了五次以上的全新重写,最后一次重写终于能够展示 在真实硬件上运行的实际节能效果 [PDF]。正如之前提到的,我通过愤怒的电话了解到了许多这些要求:在 Linux 内核邮件列表中抨击我显然不足以完全发泄他们对 RCU 能效错误的怒火!

调度时钟中断和 RCU

内核在内核非空闲执行、用户空间执行和空闲循环之间转换。根据内核配置,RCU 对这些状态的处理方式不同

HZ Kconfig

内核中

用户模式

空闲

HZ_PERIODIC

可以依赖调度时钟中断。

可以依赖调度时钟中断及其对从用户模式中断的检测。

可以依赖 RCU 的 dyntick-idle 检测。

NO_HZ_IDLE

可以依赖调度时钟中断。

可以依赖调度时钟中断及其对从用户模式中断的检测。

可以依赖 RCU 的 dyntick-idle 检测。

NO_HZ_FULL

有时只能依赖调度时钟中断。在其他情况下,有必要限制内核执行时间和/或使用 IPI。

可以依赖 RCU 的 dyntick-idle 检测。

可以依赖 RCU 的 dyntick-idle 检测。

快速测验:

为什么 NO_HZ_FULL 内核执行不能像 HZ_PERIODICNO_HZ_IDLE 一样依赖调度时钟中断?

答案:

因为,作为性能优化,NO_HZ_FULL 不一定在每次系统调用时都重新启用调度时钟中断。

但是,RCU 必须可靠地了解任何给定 CPU 当前是否处于空闲循环中,并且对于 NO_HZ_FULL,还需要了解该 CPU 是否在用户模式下执行,如前面所述。它还要求在 RCU 需要时启用调度时钟中断

  1. 如果 CPU 处于空闲状态或在用户模式下执行,而 RCU 认为它处于非空闲状态,那么调度时钟节拍最好正在运行。否则,您将收到 RCU CPU 停顿警告。或者最多,是非常长的(11 秒)宽限期,并且会不时出现无意义的 IPI 唤醒 CPU。

  2. 如果 CPU 处于内核中执行 RCU 读取端临界区的部分,而 RCU 认为该 CPU 处于空闲状态,则会出现随机内存损坏。不要这样做! 这是使用 lockdep 进行测试的原因之一,它会抱怨这种事情。

  3. 如果 CPU 处于内核中绝对保证永远不会执行任何 RCU 读取端临界区的部分,而 RCU 认为该 CPU 处于空闲状态,则没有问题。某些架构使用这种方法来处理轻量级异常处理程序,这样可以避免在异常入口和出口分别产生 ct_irq_enter() 和 ct_irq_exit() 的开销。有些架构更进一步,避免了整个 irq_enter() 和 irq_exit()。只需确保您使用 CONFIG_PROVE_RCU=y 进行一些测试,以防您的某个代码路径实际上是在开玩笑说不执行 RCU 读取端临界区。

  4. 如果 CPU 在禁用调度时钟中断的情况下在内核中执行,并且 RCU 认为该 CPU 处于非空闲状态,并且如果 CPU 每隔几个 jiffies 从(从 RCU 的角度来看)处于空闲状态,则没有问题。通常,空闲期间之间偶尔出现一秒左右的间隙是可以的。如果间隙过长,您将收到 RCU CPU 停顿警告。

  5. 如果 CPU 处于空闲状态或在用户模式下执行,并且 RCU 认为它处于空闲状态,当然没有问题。

  6. 如果 CPU 在内核中执行,内核代码路径以合理的频率(最好每隔几个 jiffies 大概一次,但偶尔延长到一秒左右通常也可以)通过静止状态,并且启用了调度时钟中断,当然没有问题。如果连续一对静止状态之间的间隙过长,您将收到 RCU CPU 停顿警告。

快速测验:

但是,如果我的驱动程序有一个硬件中断处理程序可以运行几秒钟怎么办?毕竟,我不能从硬件中断处理程序调用 schedule()!

答案:

一种方法是每隔一段时间执行 ct_irq_exit();ct_irq_enter();。但是,鉴于长时间运行的中断处理程序可能会导致其他问题,尤其是在响应时间方面,您不应该努力将中断处理程序的运行时保持在合理的范围内吗?

但是,只要 RCU 正确了解内核状态在内核执行、用户模式执行和空闲之间的转换,并且只要在 RCU 需要时启用调度时钟中断,您就可以确信您遇到的错误将出现在 RCU 的其他部分或内核的其他部分中!

内存效率

虽然小内存非实时系统可以简单地使用 Tiny RCU,但代码大小只是内存效率的一个方面。另一方面是 call_rcu()kfree_rcu() 使用的 rcu_head 结构的大小。尽管此结构仅包含一对指针,但它确实出现在许多 RCU 保护的数据结构中,包括一些对大小要求严格的数据结构。page 结构就是一个例子,该结构中多次出现 union 关键字就是证明。

对内存效率的这种需求是 RCU 使用手工制作的单链表来跟踪等待宽限期结束的 rcu_head 结构的原因之一。这也是为什么 rcu_head 结构不包含调试信息的原因,例如跟踪 call_rcu()kfree_rcu() 发布它们的文件和行的字段。尽管此信息可能在某些时候出现在仅用于调试的内核构建中,但在那之前,->func 字段通常会提供所需的调试信息。

但是,在某些情况下,对内存效率的需求导致了更极端的措施。回到 page 结构,rcu_head 字段与许多其他结构共享存储空间,这些结构在相应页面的生命周期的不同阶段使用。为了正确解决某些竞争条件,Linux 内核的内存管理子系统需要在宽限期处理的所有阶段中将特定位保持为零,而该位恰好映射到 rcu_head 结构的 ->next 字段的最低位。只要使用 call_rcu() 发布回调,而不是使用 kfree_rcu() 或将来可能为提高能源效率而创建的 call_rcu() 的某种“惰性”变体,RCU 就可以保证这一点。

也就是说,存在限制。RCU 要求 rcu_head 结构与两个字节边界对齐,并且将未对齐的 rcu_head 结构传递给 call_rcu() 系列函数之一将导致 splat。因此,在打包包含 rcu_head 类型字段的结构时,有必要谨慎行事。为什么不是四个字节甚至八个字节的对齐要求?因为 m68k 架构仅提供两个字节的对齐,因此充当对齐的最小公分母。

保留指向 rcu_head 结构的指针的最低位的原因是为可以安全推迟调用的“惰性”回调敞开大门。推迟调用可能会带来能源效率优势,但前提是对于某些重要的工作负载,非惰性回调的速率显着降低。同时,保留最低位可以使这个选项在有一天变得有用时保持可用。

性能、可伸缩性、响应时间和可靠性

之前的讨论中提到,RCU 被 Linux 内核网络、安全、虚拟化和调度代码路径中对性能要求很高的热代码路径大量使用。因此,RCU 必须使用高效的实现,尤其是在其读端原语中。为此,如果可抢占 RCU 的 rcu_read_lock() 实现可以内联就好了,但是,这样做需要解决 #include 包含 task_struct 结构体的问题。

Linux 内核支持最多 4096 个 CPU 的硬件配置,这意味着 RCU 必须具有极高的可扩展性。在 RCU 实现中,频繁获取全局锁或频繁对全局变量进行原子操作的算法是无法容忍的。因此,RCU 大量使用了基于 rcu_node 结构的组合树。RCU 需要容忍所有 CPU 以最小的每次操作开销持续调用 RCU 的任何运行时原语的任意组合。事实上,在许多情况下,增加负载必须减少每次操作的开销,例如 synchronize_rcu()call_rcu()synchronize_rcu_expedited()rcu_barrier() 的批处理优化。总的来说,RCU 必须乐于接受 Linux 内核其余部分决定抛给它的任何东西。

Linux 内核用于实时工作负载,特别是与 -rt 补丁集结合使用时。实时延迟响应的要求是,在 RCU 读端临界区禁用抢占的传统方法是不合适的。因此,使用 CONFIG_PREEMPTION=y 构建的内核使用允许 RCU 读端临界区被抢占的 RCU 实现。在用户明确表示早期的实时补丁不符合他们的需求,以及一些RCU 问题在 -rt 补丁集的早期版本中遇到之后,这一要求才显现出来。

此外,RCU 必须在 100 微秒以下的实时延迟预算内完成。事实上,在带有 -rt 补丁集的较小系统上,Linux 内核为包括 RCU 在内的整个内核提供了低于 20 微秒的实时延迟。因此,RCU 的可扩展性和延迟必须足以满足这些类型的配置。令我惊讶的是,低于 100 微秒的实时延迟预算 甚至适用于最大的系统 [PDF],包括拥有 4096 个 CPU 的系统。这种实时要求促成了宽限期 kthread 的出现,这也简化了对许多竞争条件的处理。

RCU 必须避免降低 CPU 绑定线程的实时响应,无论是在用户模式下执行(这是 CONFIG_NO_HZ_FULL=y 的一种用例)还是在内核中执行。也就是说,内核中的 CPU 绑定循环必须每隔几十毫秒至少执行一次 cond_resched(),以避免收到来自 RCU 的 IPI。

最后,RCU 作为一种同步原语,意味着任何 RCU 故障都可能导致任意的内存损坏,这可能非常难以调试。这意味着 RCU 必须极其可靠,这在实践中也意味着 RCU 必须有一个积极的压力测试套件。这个压力测试套件被称为 rcutorture

尽管需要 rcutorture 并不令人惊讶,但当前 Linux 内核的巨大普及正在带来有趣且可能是前所未有的验证挑战。要理解这一点,请记住,考虑到 Android 智能手机、Linux 电视和服务器,目前有超过 10 亿个 Linux 内核实例在运行。随着著名的物联网的出现,预计这个数字将急剧增加。

假设 RCU 包含一个竞争条件,平均每百万年运行时才会出现一次。这个错误在整个安装基础上大约每天会发生三次。考虑到没人真的指望他们的智能手机能用一百万年,RCU 可以简单地隐藏在硬件错误率背后。然而,任何对此想法感到过于安慰的人都应该考虑这样一个事实,即在大多数司法管辖区,对给定机制(可能包括 Linux 内核)的成功多年测试足以满足许多类型的安全关键认证。事实上,有传言说 Linux 内核已经用于生产中的安全关键应用。我不知道你怎么样,但如果 RCU 中的一个错误导致某人死亡,我会感到非常难过。这或许可以解释我最近对验证和确认的关注。

其他 RCU 风格

关于 RCU 比较令人惊讶的事情之一是,现在至少有五种风格或 API 系列。此外,到目前为止一直唯一关注的主要风格有两种不同的实现:不可抢占和可抢占。其他四种风格列在下面,每种风格的要求在单独的部分中描述。

  1. Bottom-Half 风格(历史)

  2. Sched 风格(历史)

  3. 可睡眠 RCU

  4. Tasks RCU

  5. Tasks Trace RCU

Bottom-Half 风格(历史)

作为将三种风格整合为一种风格的一部分,RCU 的 RCU-bh 风格已用其他 RCU 风格表示。读端 API 仍然保留,并继续禁用 softirq 并由 lockdep 考虑。因此,本节中的大部分内容本质上是严格的历史性的。

softirq-disable(又名“bottom-half”,因此使用“_bh”缩写)风格的 RCU,或RCU-bh,由 Dipankar Sarma 开发,旨在提供一种可以承受 Robert Olsson 研究的网络拒绝服务攻击的 RCU 风格。这些攻击给系统带来了如此多的网络负载,以至于某些 CPU 从未退出 softirq 执行,这反过来又阻止了这些 CPU 执行上下文切换,而在当时的 RCU 实现中,这阻止了宽限期结束。结果是内存不足的情况和系统挂起。

解决方案是创建 RCU-bh,它在其读端临界区中执行 local_bh_disable(),并且除了上下文切换、空闲、用户模式和离线之外,还使用从一种类型的 softirq 处理到另一种类型的转换作为静止状态。这意味着即使某些 CPU 无限期地在 softirq 中执行,RCU-bh 宽限期也可以完成,从而允许基于 RCU-bh 的算法承受基于网络的拒绝服务攻击。

由于 rcu_read_lock_bh()rcu_read_unlock_bh() 禁用和重新启用 softirq 处理程序,因此任何在 RCU-bh 读端临界区期间尝试启动 softirq 处理程序的行为都会被延迟。在这种情况下,rcu_read_unlock_bh() 将调用 softirq 处理,这可能需要相当长的时间。当然,人们可能会争辩说,这种 softirq 开销应该与 RCU-bh 读端临界区后面的代码相关联,而不是与 rcu_read_unlock_bh() 相关联,但事实是,大多数分析工具都不能期望做出这种细微的区分。例如,假设在网络负载繁重期间执行了 3 毫秒长的 RCU-bh 读端临界区。很可能会尝试在那三毫秒内调用至少一个 softirq 处理程序,但任何此类调用都会延迟到 rcu_read_unlock_bh() 时。这当然会让人们乍一看好像 rcu_read_unlock_bh() 执行速度非常慢。

RCU-bh API 包括 rcu_read_lock_bh()rcu_read_unlock_bh()rcu_dereference_bh()rcu_dereference_bh_check()rcu_read_lock_bh_held()。然而,旧的 RCU-bh 更新端 API 现在已经消失,被 synchronize_rcu()synchronize_rcu_expedited()call_rcu()rcu_barrier() 取代。此外,任何禁用 bottom half 的操作也标志着 RCU-bh 读端临界区,包括 local_bh_disable() 和 local_bh_enable()、local_irq_save() 和 local_irq_restore() 等。

Sched 风格(历史)

作为将三种风格整合为一种风格的一部分,RCU 的 RCU-sched 风格已用其他 RCU 风格表示。读端 API 仍然保留,并继续禁用抢占并由 lockdep 考虑。因此,本节中的大部分内容本质上是严格的历史性的。

在可抢占 RCU 之前,等待 RCU 宽限期也会产生等待所有预先存在的中断和 NMI 处理程序的副作用。然而,存在不具有此属性的合法的可抢占 RCU 实现,因为 RCU 读端临界区之外的任何代码点都可以是静止状态。因此,创建了RCU-sched,它遵循“经典” RCU,即 RCU-sched 宽限期会等待预先存在的中断和 NMI 处理程序。在使用 CONFIG_PREEMPTION=n 构建的内核中,RCU 和 RCU-sched API 具有相同的实现,而使用 CONFIG_PREEMPTION=y 构建的内核则为每个 API 提供单独的实现。

请注意,在 CONFIG_PREEMPTION=y 内核中,rcu_read_lock_sched()rcu_read_unlock_sched() 分别禁用和重新启用抢占。这意味着如果在 RCU-sched 读取侧临界区期间有抢占尝试,rcu_read_unlock_sched() 将进入调度器,并带来所有相关的延迟和开销。就像 rcu_read_unlock_bh() 一样,这可能会使 rcu_read_unlock_sched() 看起来执行速度非常慢。但是,最高优先级的任务不会被抢占,因此该任务将享受低开销的 rcu_read_unlock_sched() 调用。

RCU-sched API 包括 rcu_read_lock_sched(), rcu_read_unlock_sched(), rcu_read_lock_sched_notrace(), rcu_read_unlock_sched_notrace(), rcu_dereference_sched(), rcu_dereference_sched_check() 和 rcu_read_lock_sched_held()。然而,旧的 RCU-sched 更新侧 API 现在已经消失,被 synchronize_rcu(), synchronize_rcu_expedited(), call_rcu()rcu_barrier() 取代。此外,任何禁用抢占的操作也会标记一个 RCU-sched 读取侧临界区,包括 preempt_disable() 和 preempt_enable()、local_irq_save() 和 local_irq_restore() 等。

可睡眠 RCU

在过去的十多年里,如果有人说“我需要在 RCU 读取侧临界区内阻塞”,这通常可靠地表明此人不理解 RCU。毕竟,如果您总是在 RCU 读取侧临界区中阻塞,那么您可能可以使用开销更高的同步机制。但是,随着 Linux 内核通知器的出现,情况发生了变化。通知器的 RCU 读取侧临界区几乎从不睡眠,但有时需要睡眠。这导致了可睡眠 RCU或 *SRCU* 的引入。

SRCU 允许定义不同的域,每个域都由 srcu_struct 结构的实例定义。必须将指向此结构的指针传递给每个 SRCU 函数,例如 synchronize_srcu(&ss),其中 sssrcu_struct 结构。这些域的主要好处是,一个域中的慢速 SRCU 读取器不会延迟其他域中的 SRCU 宽限期。也就是说,这些域的一个后果是,读取侧代码必须将“cookie”从 srcu_read_lock() 传递给 srcu_read_unlock(),例如,如下所示

1 int idx;
2
3 idx = srcu_read_lock(&ss);
4 do_something();
5 srcu_read_unlock(&ss, idx);

如上所述,在 SRCU 读取侧临界区内阻塞是合法的,但是,能力越大,责任越大。如果您在给定域的 SRCU 读取侧临界区中永远阻塞,那么该域的宽限期也将永远阻塞。当然,永远阻塞的一个好方法是死锁,如果给定域的 SRCU 读取侧临界区中的任何操作可以直接或间接地等待该域的宽限期结束,则可能会发生死锁。例如,这会导致自死锁

1 int idx;
2
3 idx = srcu_read_lock(&ss);
4 do_something();
5 synchronize_srcu(&ss);
6 srcu_read_unlock(&ss, idx);

但是,如果第 5 行获取了一个在域 sssynchronize_srcu() 中持有的互斥锁,则仍然可能发生死锁。此外,如果第 5 行获取了一个在某个其他域 ss1synchronize_srcu() 中持有的互斥锁,并且如果 ss1 域的 SRCU 读取侧临界区获取了另一个在 ss 域的 synchronize_srcu() 中持有的互斥锁,则再次可能发生死锁。这样的死锁循环可以扩展到任意多个不同的 SRCU 域。再次强调,能力越大,责任越大。

与其他 RCU 变体不同,SRCU 读取侧临界区可以在空闲甚至离线的 CPU 上运行。此能力要求 srcu_read_lock()srcu_read_unlock() 包含内存屏障,这意味着 SRCU 读取器会比 RCU 读取器运行得慢一些。这也促使了 smp_mb__after_srcu_read_unlock() API 的出现,该 API 与 srcu_read_unlock() 结合使用,可以保证完整的内存屏障。

同样与其他 RCU 变体不同,由于 SRCU 宽限期使用定时器以及定时器可能临时“滞留”在即将离开的 CPU 上,synchronize_srcu() 可能**不能**从 CPU 热插拔通知器中调用。定时器的这种滞留意味着发布到即将离开的 CPU 的定时器将不会触发,直到 CPU 热插拔过程的后期。问题在于,如果通知器正在等待 SRCU 宽限期,而该宽限期正在等待定时器,并且该定时器滞留在即将离开的 CPU 上,那么通知器将永远不会被唤醒,换句话说,死锁已经发生。当然,这种情况也禁止从 CPU 热插拔通知器中调用 srcu_barrier()

SRCU 与其他 RCU 变体的另一个不同之处在于,SRCU 的加速和非加速宽限期是通过相同的机制实现的。这意味着,在当前的 SRCU 实现中,加速未来的宽限期会产生加速所有尚未完成的先前宽限期的副作用。(但请注意,这是当前实现的属性,不一定是未来实现的属性。)此外,如果 SRCU 空闲的时间超过 srcutree.exp_holdoff 内核启动参数(默认为 25 微秒)指定的时间间隔,并且如果 synchronize_srcu() 调用结束了此空闲期,则该调用将自动加速。

从 v4.12 开始,SRCU 的回调维护在每个 CPU 上,消除了先前内核版本中存在的锁定瓶颈。虽然这将允许用户对 call_srcu() 施加更大的压力,但需要注意的是,SRCU 尚未采取任何特殊步骤来处理回调泛滥。因此,如果您每秒每个 CPU 发布(例如)10,000 个 SRCU 回调,那么您可能完全没问题,但是如果您打算每秒每个 CPU 发布(例如)1,000,000 个 SRCU 回调,请先运行一些测试。SRCU 可能需要进行一些调整来处理这种负载。当然,您的实际情况可能会因 CPU 的速度和内存大小而异。

SRCU API 包括 srcu_read_lock(), srcu_read_unlock(), srcu_dereference(), srcu_dereference_check(), synchronize_srcu(), synchronize_srcu_expedited(), call_srcu(), srcu_barrier(), 和 srcu_read_lock_held()。它还包括 DEFINE_SRCU(), DEFINE_STATIC_SRCU() 和 init_srcu_struct() 等 API,用于定义和初始化 srcu_struct 结构体。

最近,SRCU API 添加了轮询接口。

  1. start_poll_synchronize_srcu() 返回一个 cookie,用于标识未来 SRCU 宽限期的完成,并确保启动此宽限期。

  2. poll_state_synchronize_srcu() 返回 true,当且仅当指定的 cookie 对应于已完成的 SRCU 宽限期。

  3. get_state_synchronize_srcu() 返回一个 cookie,就像 start_poll_synchronize_srcu() 一样,但不同之处在于它不做任何事情来确保启动任何未来的 SRCU 宽限期。

这些函数用于避免在某些具有多阶段老化机制的缓冲缓存算法中出现不必要的 SRCU 宽限期。其思想是,当块从缓存中完全老化时,很可能已经过去了一个 SRCU 宽限期。

任务 RCU

某些形式的跟踪使用“跳板”来处理安装不同类型的探针所需的二进制重写。最好能够释放旧的跳板,这听起来像是某种 RCU 的工作。但是,由于必须能够在代码中的任何位置安装跟踪,因此不可能使用诸如 rcu_read_lock()rcu_read_unlock() 之类的读取端标记。此外,在跳板本身中放置这些标记是行不通的,因为在 rcu_read_unlock() 之后需要有指令。尽管 synchronize_rcu() 可以保证执行到达 rcu_read_unlock(),但它无法保证执行已完全离开跳板。更糟糕的是,在某些情况下,跳板的保护必须在执行到达跳板之前延伸一些指令 * prior * 。例如,这几个指令可能会计算跳板的地址,以便进入跳板会在执行实际到达跳板本身之前出奇地长的时间内被预先确定。

解决方案(以 任务 RCU 的形式)是具有由自愿上下文切换(即,调用 schedule(),cond_resched() 和 synchronize_rcu_tasks() )划分的隐式读取端临界区。此外,向用户空间执行的转换以及从用户空间执行的转换也分隔了任务 RCU 读取端临界区。空闲任务被任务 RCU 忽略,任务粗暴 RCU 可用于与它们交互。

请注意,非自愿上下文切换 * not * 是任务 RCU 静止状态。毕竟,在可抢占内核中,在跳板中执行代码的任务可能会被抢占。在这种情况下,任务 RCU 宽限期显然不能结束,直到该任务恢复并且其执行离开该跳板。这意味着,cond_resched() 不提供任务 RCU 静止状态。(相反,请使用来自 softirq 的 rcu_softirq_qs() 或其他方式使用 rcu_tasks_classic_qs()。)

任务 RCU API 非常简洁,仅包含 call_rcu_tasks()synchronize_rcu_tasks()rcu_barrier_tasks()。在 CONFIG_PREEMPTION=n 内核中,跳板无法被抢占,因此这些 API 会映射到 call_rcu()synchronize_rcu()rcu_barrier()。在 CONFIG_PREEMPTION=y 内核中,跳板可以被抢占,因此这三个 API 由单独的函数实现,这些函数检查自愿上下文切换。

任务粗暴 RCU

某些形式的跟踪需要等待任何在线 CPU 上运行的所有禁用抢占的代码区域,包括 RCU 未监视时执行的代码区域。这意味着 synchronize_rcu() 是不够的,必须改用任务粗暴 RCU。此 RCU 风味通过强制在每个在线 CPU 上调度一个工作队列来完成其工作,因此得名“粗暴”。并且,这种操作被不希望 nohz_full CPU 接收 IPI 的实时工作负载以及不希望唤醒空闲 CPU 的电池供电系统认为是非常粗暴的。

一旦内核入口/出口和深度空闲函数被正确标记为 noinstr,任务 RCU 就可以开始关注空闲任务(除了那些从 RCU 的角度来看是空闲的任务),然后可以将任务粗暴 RCU 从内核中删除。

任务粗暴 RCU API 也是无读取器标记的,因此非常简洁,仅包含 synchronize_rcu_tasks_rude()

任务跟踪 RCU

某些形式的跟踪需要在读取器中休眠,但不能容忍 SRCU 的读取端开销,其中包括 srcu_read_lock()srcu_read_unlock() 中的完整内存屏障。此需求由任务跟踪 RCU 处理,该 RCU 使用调度程序锁定和 IPI 与读取器同步。无法容忍 IPI 的实时系统可以使用 CONFIG_TASKS_TRACE_RCU_READ_MB=y 构建其内核,这样可以避免 IPI,但代价是在读取端原语中添加完整的内存屏障。

任务跟踪 RCU API 也相当简洁,包括 rcu_read_lock_trace()rcu_read_unlock_trace(), rcu_read_lock_trace_held(), call_rcu_tasks_trace()synchronize_rcu_tasks_trace()rcu_barrier_tasks_trace()

可能的未来变化

RCU 用来获得更新端可伸缩性的技巧之一是随着 CPU 数量的增加而增加宽限期延迟。如果这成为一个严重的问题,则有必要重新设计宽限期状态机,以避免额外的延迟。

RCU 在一些地方禁用了 CPU 热插拔,也许最值得注意的是在 rcu_barrier() 操作中。如果有一个强有力的理由在 CPU 热插拔通知器中使用 rcu_barrier(),则有必要避免禁用 CPU 热插拔。这会引入一些复杂性,所以最好有一个 *非常* 好的理由。

另一方面,宽限期延迟与另一方面中断其他 CPU 之间的权衡可能需要重新检查。当然,希望在快速宽限期操作期间实现零宽限期延迟以及零处理器间中断。虽然不太可能实现这种理想状态,但很可能可以做进一步的改进。

RCU的多处理器实现使用组合树来对CPU进行分组,以减少锁竞争并提高缓存局部性。然而,这种组合树不会将其内存分散到NUMA节点上,也不会将CPU组与诸如插槽或内核之类的硬件特性对齐。目前认为这种分散和对齐是不必要的,因为热路径读取端原语不访问组合树,并且在通常情况下 call_rcu() 也不会访问。如果您认为您的架构需要这种分散和对齐,那么您的架构也应该从 rcutree.rcu_fanout_leaf 启动参数中受益,该参数可以设置为插槽、NUMA节点或任何其他设备中的CPU数量。如果CPU数量过多,请使用CPU数量的一部分。如果CPU数量是一个较大的素数,那么这肯定是一个“有趣”的架构选择!可以考虑更灵活的安排,但前提是 rcutree.rcu_fanout_leaf 已被证明不足,并且只有在经过精心运行和真实的系统级工作负载的证明下才能认为不足。

请注意,需要RCU重新映射CPU编号的安排将需要极其充分的必要性论证和对替代方案的全面探索。

RCU的各种内核线程是最近添加的。很可能需要进行调整,以便更优雅地处理极端负载。可能还需要能够将RCU内核线程和软中断处理程序的CPU利用率与引起此CPU利用率的代码联系起来。例如,RCU回调开销可能会被计回原始的 call_rcu() 实例,尽管可能不会在生产内核中这样做。

可能需要进行额外的工作,以便在繁重负载下为宽限期和回调调用提供合理的向前推进保证。

总结

本文档介绍了超过二十年的RCU需求。鉴于这些需求不断变化,这不会是关于此主题的最后结论,但至少它有助于提出需求的重要子集。

致谢

我非常感谢 Steven Rostedt、赖江山、Ingo Molnar、Oleg Nesterov、Borislav Petkov、Peter Zijlstra、冯博群和 Andy Lutomirski 在使本文易于阅读方面的帮助,以及感谢 Michelle Rankin 对这项工作的支持。其他贡献已在Linux内核的git存档中确认。