[toc]

kernel/rcu/srcu.c Sleepable Read-Copy Update (SRCU) 允许睡眠的RCU变体

历史与背景

这项技术是为了解决什么特定问题而诞生的?

Sleepable Read-Copy Update (SRCU) 的诞生是为了解决标准RCU(包括Classic RCU和Preemptible RCU)的一个核心限制:其读侧临界区(read-side critical section)内不允许睡眠

标准的RCU通过监控CPU的静止状态(如上下文切换、进入idle)来确定宽限期(Grace Period),而这些监控手段都假设了读者在持有“锁”(即在rcu_read_lock()rcu_read_unlock()之间)时不会主动放弃CPU(即睡眠)。

然而,在内核中存在很多场景,开发者需要在访问一个被RCU保护的、读多写少的数据结构的同时,执行一些可能会导致睡眠的操作。例如:

  1. 与用户空间交互:调用 copy_from_user()copy_to_user(),这些函数在访问的用户内存被换出到磁盘时,会发生页错误并睡眠。
  2. 获取互斥锁:调用 mutex_lock(),如果锁已被其他任务持有,当前任务会睡眠等待。
  3. 内存分配:使用 kmalloc() 并传入 GFP_KERNEL 标志,在内存不足时可能会睡眠等待。

在这些场景下,标准的RCU无法使用。SRCU就是为了将RCU的“读者几乎零开销”这一核心优势,扩展到这些读侧临界区必须允许睡眠的场景中而设计的。

它的发展经历了哪些重要的里程碑或版本迭代?

SRCU是RCU家族的一个重要分支,其发展本身就是一个里程碑。

  • 概念的提出与实现:由Paul E. McKenney等人设计并实现,作为对RCU适用范围的一次重大扩展。其核心创新在于放弃了依赖调度器状态来检测宽限期,转而采用一种更显式的、基于计数器的追踪机制。
  • API的独立与成熟:SRCU拥有一套完全独立的API(srcu_read_lock(), synchronize_srcu()等)和一个独立的struct srcu_struct来定义作用域。这使得它可以与系统中的其他RCU变体共存而互不干扰。
  • 性能与扩展性优化:与Tree RCU类似,SRCU的内部实现也在不断优化,以更好地适应大规模多核系统,尽管其固有的开销使其性能上限低于非睡眠的RCU变体。

目前该技术的社区活跃度和主流应用情况如何?

SRCU是内核中一个稳定且关键的同步原语。虽然它的使用范围不如通用的Preemptible RCU广泛,但在其适用的特定领域是不可或缺的。

  • 主流应用
    • 追踪子系统 (Tracepoints, ftrace):追踪回调函数在执行时可能会进行复杂的操作,甚至需要获取锁,因此它们通常在SRCU的读侧临界区内运行。
    • 文件系统:某些文件系统的内部数据结构,其访问路径上可能涉及需要睡眠的I/O操作或锁。
    • 网络:一些需要与用户空间进行复杂交互的网络管理代码。

核心原理与设计

它的核心工作原理是什么?

SRCU的核心原理与RCU相同:读-拷贝-更新,并通过等待宽限期来延迟内存回收。但它实现宽限期检测的方式完全不同。

SRCU不依赖CPU的调度状态,而是为每一个被保护的数据结构实例维护一个struct srcu_struct。这个结构体内部有一个小数组(通常是两个元素)的计数器和一个当前“活动”计数器的索引。

  1. 读者 (Readers)

    • srcu_read_lock(ss):读者调用此函数时,会原子地增加当前活动计数器的值。ss是指向struct srcu_struct的指针。这个操作是一个真正的原子操作,比rcu_read_lock()的开销要大。
    • srcu_read_unlock(ss):读者完成读取后,调用此函数原子地减少同一个计数器的值。
    • 由于只是增减计数器,读者在临界区内完全可以睡眠,不会影响机制的正确性。
  2. 更新者 (Updaters)

    • 更新者执行标准的“读-拷贝-更新”流程。
    • 当需要等待宽限期时,它调用 synchronize_srcu(ss)
  3. synchronize_srcu 的工作流程 (SRCU宽限期的精髓)

    • 切换索引 (Flip)synchronize_srcu首先切换srcu_struct中的活动计数器索引(例如,从0切换到1)。从此以后,所有srcu_read_lock()调用都会去操作索引为1的计数器。
    • 等待旧读者:然后,synchronize_srcu会等待索引为0的那个旧的计数器值变为零。它会以一种可睡眠的方式反复检查这个计数器。
    • 宽限期结束:一旦旧计数器的值变为零,就意味着所有在切换索引之前进入读侧临界区的读者,现在都已经调用了srcu_read_unlock()并退出了。至此,一个宽限期就结束了。此时释放旧数据是安全的。

它的主要优势体现在哪些方面?

  • 允许读者睡眠:这是其最核心、最不可替代的优势。
  • 读写并发:继承了RCU的优点,读者不会阻塞写者,反之亦然。
  • 作用域独立:每个srcu_struct管理自己的宽限期,不同SRCU实例之间互不影响。

它存在哪些已知的劣势、局-限性或在特定场景下的不适用性?

  • 读者开销更高:与几乎零开销的rcu_read_lock()相比,srcu_read_lock()srcu_read_unlock()需要执行原子增减操作,并访问共享的srcu_struct内存,这带来了更高的性能开销和缓存争用。
  • 宽限期可能更长:SRCU的宽限期依赖于所有旧读者完成其(可能很长的)睡眠操作,因此通常比非睡眠RCU的宽限期要长得多。
  • 需要显式定义结构:开发者必须为每个需要SRCU保护的场景定义并初始化一个struct srcu_struct实例。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

当一个读多写少的数据结构,其读者访问路径上不可避免地存在可能导致睡眠的操作时,SRCU是首选乃至唯一的解决方案。

  • 追踪回调:内核的tracepoint允许其他子系统挂接回调函数。这些回调函数的行为是未知的,可能需要睡眠。为了保护被追踪代码的上下文,整个回调的执行都由SRCU来保护。
  • 需要与用户空间交互的内核接口:例如一个ioctl的实现,它需要读取一个被RCU保护的全局配置,同时又要用copy_from_user从用户空间拷贝参数。
  • 需要获取mutex的读者:当读者在访问共享数据后,需要获取一个mutex来操作其他资源时。

是否有不推荐使用该技术的场景?为什么?

  • 读者不睡眠的场景:如果可以保证读侧临界区内绝不会发生睡眠,绝对应该使用标准的Preemptible RCU。因为SRCU的读者开销要大得多,在这种场景下使用SRCU是一种不必要的性能浪费。
  • 写密集型负载:与所有RCU变体一样,它不适合写操作频繁的场景。

对比分析

请将其 与 其他相似技术 进行详细对比。

特性 SRCU (Sleepable RCU) Preemptible RCU (标准RCU) 互斥锁 (mutex)
读者是否可睡眠
读者开销 中等 (原子操作 + 缓存行争用)。 极低 (通常只是禁用/启用抢占)。 (可能涉及睡眠和上下文切换)。
读-写并发 允许。读者和写者互不阻塞。 允许。读者和写者互不阻塞。 不允许。完全互斥。
宽限期机制 基于每个实例的显式计数器 基于全局的调度器状态 (静止状态)。 N/A (使用锁,没有宽限期概念)。
性能 读性能优于mutex,但远逊于标准RCU。 读性能极高,是所有方案中最好的。 在高争用下性能和扩展性差。
适用场景 读多写少,且读者必须睡眠的场景。 读多写少,且读者不睡眠的通用场景。 保护需要睡眠的、任意读写比例的临界区。

kernel/rcu/srcutree.c

srcu_init 初始化 SRCU 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void __init srcu_init(void)
{
struct srcu_usage *sup;

/* Decide on srcu_struct-size strategy. */
if (SRCU_SIZING_IS(SRCU_SIZING_AUTO)) {
if (nr_cpu_ids >= big_cpu_lim) {
convert_to_big = SRCU_SIZING_INIT; // Don't bother waiting for contention.
pr_info("%s: Setting srcu_struct sizes to big.\n", __func__);
} else {
convert_to_big = SRCU_SIZING_NONE | SRCU_SIZING_CONTEND;
pr_info("%s: Setting srcu_struct sizes based on contention.\n", __func__);
}
}

/*
* 设置完成后,call_srcu() 可以遵循正常路径并对 delay work 进行排队。这必须在 RCU 工作队列创建和计时器初始化之后进行。
*/
srcu_init_done = true;
/* 全局列表用于早期引导:

在早期引导阶段,当调用 call_srcu() 时,将 SRCU 结构(srcu_struct)添加到全局列表(srcu_boot_list)。
这确保了必要的 SRCU 结构被跟踪,以便稍后初始化。
推迟工作执行:

在早期引导阶段,call_srcu() 不会立即尝试队列工作。相反,它执行所有其他必要的操作,并将 SRCU 结构添加到全局列表。
一旦系统准备就绪(在 rcu_init() 之后),srcu_init() 函数会处理全局列表并为每个 SRCU 结构队列工作。
在 srcu_init() 中的早期初始化:

在 rcu_init() 期间调用 srcu_init() 来处理全局列表。
它遍历全局列表并为每个 SRCU 结构队列工作,使用 queue_work()。
避免计时器初始化问题:

在早期引导阶段,计时器尚未初始化。为了避免问题,使用 queue_work() 而不是 queue_delayed_work()。
这确保不会访问未初始化的自旋锁,并将工作推迟到调度器和工作队列子系统运行后 */
while (!list_empty(&srcu_boot_list)) {
sup = list_first_entry(&srcu_boot_list, struct srcu_usage,
work.work.entry);
list_del_init(&sup->work.work.entry);
if (SRCU_SIZING_IS(SRCU_SIZING_INIT) &&
sup->srcu_size_state == SRCU_SIZE_SMALL)
sup->srcu_size_state = SRCU_SIZE_ALLOC;
queue_work(rcu_gp_wq, &sup->work.work);
}
}