RCU补丁审查清单 【ChatGPT】

发布时间 2023-12-09 21:06:00作者: 摩斯电码

RCU补丁审查清单

本文档包含了一个用于生成和审查使用RCU的补丁的清单。违反以下列出的任何规则都会导致与省略锁原语相同类型的问题。这份清单基于审查这类补丁的经验,经历了相当长的一段时间,但总是欢迎改进!

  • RCU是否应用于读多写少的情况?如果数据结构更新的频率超过大约10%,那么除非详细的性能测量表明RCU仍然是适合该任务的正确工具,否则您应该强烈考虑其他方法。是的,RCU通过增加写端开销来减少读端开销,这正是为什么通常使用RCU会进行更多的读取而不是更新。

    另一个例外是性能不是问题,而且RCU提供了更简单的实现。这种情况的一个例子是Linux 2.6内核中的动态NMI代码,至少在NMIs很少的架构上。

    还有一个例外是RCU读端原语的低实时延迟至关重要。

    最后一个例外是RCU读者用于防止ABA问题(https://en.wikipedia.org/wiki/ABA_problem) 的出现。这确实导致了一个略微反直觉的情况,即rcu_read_lock()和rcu_read_unlock()被用于保护更新,然而,这种方法可以为某些类型的无锁算法提供与垃圾收集器相同的简化。

  • 更新代码是否具有适当的互斥?

    RCU确实允许读者(几乎)裸奔,但写者仍然必须使用某种形式的互斥,比如:

    • 锁定,
    • 原子操作,或
    • 限制更新到单个任务。

    如果您选择#b,请准备好描述您如何在弱排序的机器上处理了内存屏障(几乎所有机器都是弱排序的——即使x86也允许后续加载被重新排序到先前存储),并准备解释为什么这种额外的复杂性是值得的。如果您选择#c,请准备解释这个单个任务如何不会成为大型系统上的主要瓶颈(例如,如果该任务正在更新与其他任务可以读取的信息相关的信息,那么根据定义,就不会有瓶颈)。请注意,“大型”的定义已经发生了显著的变化:在2000年,八个CPU是“大型”,但在2017年,一百个CPU是司空见惯的。

  • RCU读端临界区是否正确使用了rcu_read_lock()和相关函数?这些原语需要防止优雅期过早结束,这可能导致数据在您的内核读端代码下被不庄重地释放,这可能会极大地增加您内核的风险。

  • 作为一个粗略的经验法则,任何对受RCU保护的指针的解引用都必须由rcu_read_lock()、rcu_read_lock_bh()、rcu_read_lock_sched()或适当的更新端锁来覆盖。显式禁用抢占(例如preempt_disable())可以作为rcu_read_lock_sched(),但可读性较差,并且会阻止lockdep检测到锁定问题。

    请注意,您不能依赖于已知仅在不可抢占的内核中构建的代码。这样的代码可能会出现问题,尤其是在使用CONFIG_PREEMPT_COUNT=y构建的内核中。

    让RCU受保护的指针“泄漏”出RCU读端临界区与让它们在锁的下泄漏一样糟糕。当然,除非您已经安排了其他保护手段,比如在RCU读端临界区之外让它们泄漏之前的锁或引用计数。

  • 更新代码是否能容忍并发访问?

    RCU的整个目的是允许读者在没有任何锁或原子操作的情况下运行。这意味着读者将在更新正在进行时运行。根据情况,有许多处理这种并发性的方法:

    • 使用RCU变体的列表和hlist更新原语来在受RCU保护的列表上添加、删除和替换元素。或者,使用已添加到Linux内核中的其他受RCU保护的数据结构。

      这几乎总是最佳的方法。

    • 进行与(a)相同的操作,但同时维护每个元素的锁(读者和写者都会获取的锁),以保护每个元素的状态。读者不访问的字段可以由其他锁保护,如果需要的话,只有更新者才能获取。

      这也非常有效。

    • 使更新对读者看起来是原子的。例如,对于适当对齐的字段的指针更新将看起来是原子的,就像单个原子原语一样。在锁下执行的操作序列对于RCU读者来说不会看起来是原子的,多个原子原语的序列也不会。一个替代方案是将多个单独的字段移动到一个单独的结构中,从而通过施加额外的间接层来解决多字段问题。

      这可能有效,但开始变得有些棘手。

    • 仔细地安排更新和读取,以便读者在更新的所有阶段都看到有效的数据。这通常比听起来更困难,特别是考虑到现代CPU倾向于重新排序内存引用。您通常必须在代码中大量使用内存排序操作,这使得代码难以理解和测试。在这种情况下,最好使用像smp_store_release()和smp_load_acquire()这样的东西,但在某些情况下,可能需要smp_mb()全内存屏障。

      正如前面所述,通常最好将正在更改的数据分组到一个单独的结构中,以便通过更新指向包含更新值的新结构的指针来使更改看起来是原子的。

  • 弱排序的CPU提出了特殊的挑战。几乎所有的CPU都是弱排序的——即使x86 CPU也允许后续加载被重新排序到先前存储。RCU代码必须采取以下所有措施来防止内存损坏问题:

    • 读者必须维护其内存访问的正确顺序。rcu_dereference()原语确保CPU在获取指针之前获取指针指向的数据。这在Alpha CPU上确实是必要的。

    • rcu_dereference()原语也是一个很好的文档辅助工具,让阅读代码的人准确地知道哪些指针受RCU保护。请注意,编译器也可以重新排序代码,而且它们越来越积极地这样做。因此,rcu_dereference()原语也可以防止破坏性的编译器优化。然而,通过一些狡猾的创造力,可能会错误处理rcu_dereference()的返回值。有关更多信息,请参阅《正确对待和处理rcu_dereference()的返回值》。

    • rcu_dereference()原语被各种“_rcu()”列表遍历原语使用,比如list_for_each_entry_rcu()。请注意,如果更新端代码使用rcu_dereference()和“_rcu()”列表遍历原语是完全合法的(尽管多余)。这在对读者和更新者都通用的代码中特别有用。但是,如果您在RCU读端临界区之外访问rcu_dereference(),lockdep将会抱怨。请参阅RCU和lockdep检查以了解如何处理这个问题。

    • 当然,rcu_dereference()和“_rcu()”列表遍历原语都不能替代一个良好的并发设计,协调多个更新者之间的操作。

    • 如果正在使用列表宏,必须使用list_add_tail_rcu()和list_add_rcu()原语,以防止弱排序的机器对结构初始化和指针插入进行错误排序。类似地,如果正在使用hlist宏,需要使用hlist_add_head_rcu()原语。

    • 如果正在使用列表宏,必须使用list_del_rcu()原语,以防止list_del()的指针毒害对并发读者造成有害影响。类似地,如果正在使用hlist宏,需要使用hlist_del_rcu()原语。

    • list_replace_rcu()和hlist_replace_rcu()原语可以用于在它们各自类型的RCU受保护列表中用新结构替换旧结构。

    • 类似于(4b)和(4c)的规则适用于“hlist_nulls”类型的RCU受保护链接列表。

    • 更新必须确保给定结构的初始化发生在公开指向该结构的指针之前。在公开指向可以由RCU读端临界区遍历的结构的指针时,请使用rcu_assign_pointer()原语。

  • 如果使用了call_rcu()、call_srcu()、call_rcu_tasks()、call_rcu_tasks_rude()或call_rcu_tasks_trace()中的任何一个,回调函数可能会从softirq上下文中调用,并且在任何情况下都会禁用底半部。特别是,这个回调函数不能阻塞。如果需要回调阻塞,请在从回调调度的工作队列处理程序中运行该代码。queue_rcu_work()函数在call_rcu()的情况下会为您执行此操作。

  • 由于synchronize_rcu()可能会阻塞,因此不能从任何类型的irq上下文中调用。相同的规则适用于synchronize_srcu()、synchronize_rcu_expedited()、synchronize_srcu_expedited()、synchronize_rcu_tasks()、synchronize_rcu_tasks_rude()和synchronize_rcu_tasks_trace()。

    这些原语的加速形式具有与非加速形式相同的语义,但加速更加CPU密集。使用加速原语应该限制在不会在实时工作负载运行时进行的罕见的配置更改操作。请注意,对IPI敏感的实时工作负载可以使用rcupdate.rcu_normal内核引导参数来完全禁用加速的优雅期,尽管这可能会影响性能。

    特别是,如果发现自己在循环中重复调用加速原语之一,请为大家做个好人:重新构造您的代码,使其批处理更新,从而允许单个非加速原语覆盖整个批处理。这很可能比包含加速原语的循环更快,并且对系统的其余部分,特别是运行在系统其余部分的实时工作负载,要容易得多。或者,使用异步原语,比如call_rcu()。

  • 根据v4.20版本,给定的内核只实现了一种RCU(Read-Copy-Update)类型,即对于PREEMPTION=n的情况使用RCU-sched,对于PREEMPTION=y的情况使用RCU-preempt。如果更新程序使用call_rcu()或synchronize_rcu(),那么相应的读取程序可以使用:(1)rcu_read_lock()和rcu_read_unlock(),(2)任何一对禁用和重新启用softirq的原语,例如rcu_read_lock_bh()和rcu_read_unlock_bh(),或(3)任何一对禁用和重新启用抢占的原语,例如rcu_read_lock_sched()和rcu_read_unlock_sched()。如果更新程序使用synchronize_srcu()或call_srcu(),那么相应的读取程序必须使用srcu_read_lock()和srcu_read_unlock(),并且使用相同的srcu_struct。对于快速RCU宽限期等待原语的规则与非快速的情况相同。

    如果更新程序使用call_rcu_tasks()或synchronize_rcu_tasks(),那么读取程序必须避免执行自愿的上下文切换,即避免阻塞。如果更新程序使用call_rcu_tasks_trace()或synchronize_rcu_tasks_trace(),那么相应的读取程序必须使用rcu_read_lock_trace()和rcu_read_unlock_trace()。如果更新程序使用call_rcu_tasks_rude()或synchronize_rcu_tasks_rude(),那么相应的读取程序必须使用任何禁用抢占的方法,例如preempt_disable()和preempt_enable()。

    混合使用这些原语将导致混乱和内核崩溃,甚至可能导致可利用的安全问题。因此,在使用不明显的原语时,注释当然是必须的。一个不明显的配对示例是网络中的XDP功能,它从网络驱动程序NAPI(softirq)上调用BPF程序。BPF对其数据结构严重依赖RCU保护,但由于BPF程序调用完全发生在NAPI轮询周期中的单个local_bh_disable()部分内,这种用法是安全的。这种用法之所以安全是因为当更新程序使用call_rcu()或synchronize_rcu()时,读取程序可以使用任何禁用BH的方法。

  • 尽管synchronize_rcu()比call_rcu()慢,但通常会导致更简单的代码。因此,除非更新性能非常重要,更新程序不能阻塞,或者synchronize_rcu()的延迟对用户空间可见,应优先使用synchronize_rcu()而不是call_rcu()。此外,kfree_rcu()和kvfree_rcu()通常比synchronize_rcu()产生更简单的代码,而且没有synchronize_rcu()的多毫秒延迟。因此,请在适用的情况下利用kfree_rcu()和kvfree_rcu()的“fire and forget”内存释放功能。

    synchronize_rcu()的一个特别重要的属性是它会自动限制自身:如果宽限期因某种原因延迟,那么synchronize_rcu()原语将相应地延迟更新。相比之下,使用call_rcu()的代码应该在宽限期延迟的情况下明确限制更新速率,否则可能导致过多的实时延迟甚至OOM条件。

    在使用call_rcu()、kfree_rcu()或kvfree_rcu()时获得这种自我限制属性的方法包括:

    • 维护RCU保护数据结构使用的数据结构元素数量的计数,包括等待宽限期过去的元素。强制对这个数量设置限制,必要时暂停更新以允许之前延迟的释放完成。或者,仅限制等待延迟释放的数量,而不是总元素数量。

      一种暂停更新的方法是获取更新端的互斥锁。(不要尝试在自旋锁上这样做--其他CPU在锁上自旋可能会阻止宽限期的结束。)另一种暂停更新的方法是让更新程序使用内存分配器周围的包装函数,以便在等待RCU宽限期的内存过多时模拟OOM。当然,还有许多其他变体。

    • 限制更新速率。例如,如果更新每小时只发生一次,那么不需要显式限制速率,除非您的系统已经严重损坏。旧版本的dcache子系统采用这种方法,使用全局锁保护更新,限制更新速率。

    • 受信任的更新--如果更新只能由超级用户或其他受信任的用户手动执行,那么可能不需要自动限制更新。这里的理论是超级用户已经有很多方法来使机器崩溃。

    • 定期调用rcu_barrier(),允许每个宽限期有限数量的更新。

    对call_srcu()、call_rcu_tasks()、call_rcu_tasks_rude()和call_rcu_tasks_trace()也适用相同的注意事项。这就是为什么有srcu_barrier()、rcu_barrier_tasks()、rcu_barrier_tasks_rude()和rcu_barrier_tasks_rude()。

    请注意,尽管这些原语在任何给定CPU有太多回调时采取措施避免内存耗尽,但坚决的用户或管理员仍然可以耗尽内存。特别是在一个具有大量CPU的系统已经配置为将所有RCU回调都卸载到单个CPU上,或者系统的空闲内存相对较少的情况下,这种情况尤为突出。

  • 所有RCU列表遍历原语,包括rcu_dereference()、list_for_each_entry_rcu()和list_for_each_safe_rcu(),必须要么在RCU读取端临界区内,要么由适当的更新端锁保护。RCU读取端临界区由rcu_read_lock()和rcu_read_unlock()或类似的原语(例如rcu_read_lock_bh()和rcu_read_unlock_bh())界定,此时必须使用匹配的rcu_dereference()原语以使lockdep正常工作,在这种情况下,使用rcu_dereference_bh()。

    可以在持有更新端锁的情况下使用RCU列表遍历原语的原因是,当共享代码在读取程序和更新程序之间共享时,这样做可以帮助减少代码膨胀。针对这种情况提供了额外的原语,如RCU和lockdep检查中所讨论的那样。

    这个规则的一个例外是当数据只能被添加到链接的数据结构中,而在读取程序可能访问该结构时永远不会被移除。在这种情况下,可以使用READ_ONCE()代替rcu_dereference(),并且可以省略读取端标记(例如rcu_read_lock()和rcu_read_unlock())。

  • 相反,如果您在RCU读取端临界区内,而又没有持有适当的更新端锁,您必须使用列表宏的“_rcu()”变体。如果不这样做,将会破坏Alpha,导致侵略性编译器生成错误的代码,并使试图理解您的代码的人感到困惑。

  • 由RCU回调获取的任何锁必须在其他地方以禁用softirq的方式获取,例如通过spin_lock_bh()。在获取该锁的情况下未禁用softirq将导致死锁,因为一旦RCU softirq处理程序在中断该获取的临界区时运行您的RCU回调,就会立即发生死锁。

  • RCU回调可以并且通常是并行执行的。在许多情况下,回调代码只是包装了kfree(),因此这不是一个问题(或者更准确地说,只要是一个问题,内存分配器锁定就可以处理)。但是,如果回调确实操作共享数据结构,那么它们必须使用任何必要的锁定或其他同步来安全地访问和/或修改该数据结构。

    不要假设RCU回调将在执行相应的call_rcu()或call_srcu()的CPU上执行。例如,如果某个CPU在有待的RCU回调时下线,那么该RCU回调将在某个存活的CPU上执行。(如果不是这种情况,一个自我生成的RCU回调将阻止受害CPU永远下线。)此外,由rcu_nocbs=指定的CPU可能总是在其他CPU上执行其RCU回调,实际上,对于某些实时工作负载来说,这正是使用rcu_nocbs=内核引导参数的目的。

    此外,不要假设按照给定顺序排队的回调将按照该顺序被调用,即使它们都在同一个CPU上排队。此外,不要假设相同CPU的回调将被串行调用。例如,在最近的内核中,CPU可以在已卸载和未卸载的回调调用之间切换,而在给定CPU正在进行这种切换时,它的回调可能会被该CPU的softirq处理程序和该CPU的rcuo kthread同时调用。在这种情况下,该CPU的回调可能同时且无序地执行。

  • 与大多数RCU类型不同,允许在SRCU(sleepable RCU)读取端临界区内阻塞,因此“SRCU”:可睡眠RCU。请注意,如果您不需要在读取端临界区内睡眠,您应该使用RCU而不是SRCU,因为RCU几乎总是比SRCU更快且更容易使用。

    与其他形式的RCU不同,需要显式初始化和清理,可以在构建时通过DEFINE_SRCU()或DEFINE_STATIC_SRCU()或在运行时通过init_srcu_struct()和cleanup_srcu_struct()进行。这两个函数都接受定义给定SRCU域范围的“struct srcu_struct”。一旦初始化,srcu_struct将被传递给srcu_read_lock()、srcu_read_unlock()、synchronize_srcu()、synchronize_srcu_expedited()和call_srcu()。给定的synchronize_srcu()仅等待由传递相同srcu_struct的srcu_read_lock()和srcu_read_unlock()调用管理的SRCU读取端临界区。这个属性使得延迟睡眠读取端临界区只延迟了自己的更新,而不是延迟了使用SRCU的其他子系统的更新。因此,与如果允许RCU的读取端临界区睡眠时RCU可能导致系统OOM相比,SRCU不太容易导致系统OOM。

    在读取端临界区内睡眠的能力并非免费。首先,相应的srcu_read_lock()和srcu_read_unlock()调用必须传递相同的srcu_struct。其次,宽限期检测开销只在共享给定srcu_struct的更新上摊销,而不像其他形式的RCU那样在全局上摊销。因此,SRCU应该优先于rw_semaphore,只有在极端的读取密集情况下或需要SRCU的读取端死锁免疫性或低读取端实时延迟的情况下才应该使用。当需要轻量级读取程序时,还应考虑使用percpu_rw_semaphore。

    SRCU的快速原语(synchronize_srcu_expedited())从不向其他CPU发送IPI,因此对于实时工作负载而言,它比synchronize_rcu_expedited()更容易处理。

    在RCU任务跟踪读取端临界区内睡眠也是允许的,它由rcu_read_lock_trace()和rcu_read_unlock_trace()界定。但是,这是一种专门的RCU类型,如果没有先与当前用户进行检查,您不应该使用它。在大多数情况下,您应该使用SRCU。

    请注意,rcu_assign_pointer()与SRCU的关系与其他形式的RCU一样,但是您应该使用srcu_dereference()而不是rcu_dereference(),以避免lockdep错误。

  • call_rcu()、synchronize_rcu()和相关函数的整个目的是在执行某些可能具有破坏性的操作之前等待所有现有的读者完成。因此,首先必须删除任何读者可能遵循的路径,这些路径可能会受到破坏性操作的影响,然后才能调用call_rcu()、synchronize_rcu()或相关函数。

    由于这些原语只等待现有的读者,所以调用者有责任确保任何后续的读者能够安全执行。

  • 各种RCU读取端原语不一定包含内存屏障。因此,您应该计划CPU和编译器自由地将代码重新排序到RCU读取端临界区之内和之外。这是RCU更新端原语的责任来处理这个问题。

    对于SRCU读者,您可以在srcu_read_unlock()之后立即使用smp_mb__after_srcu_read_unlock()来获取一个完整的屏障。

  • 使用CONFIG_PROVE_LOCKING、CONFIG_DEBUG_OBJECTS_RCU_HEAD和__rcu稀疏检查来验证您的RCU代码。这些可以帮助找到以下问题:

    CONFIG_PROVE_LOCKING:
    检查对受RCU保护的数据结构的访问是否在适当的RCU读取端临界区下进行,同时持有正确的锁组合或其他适当的条件。

    CONFIG_DEBUG_OBJECTS_RCU_HEAD:
    检查在上次将相同对象传递给call_rcu()(或相关函数)之后的RCU宽限期已过去之前,您是否再次将相同对象传递给call_rcu()(或相关函数)。

    __rcu稀疏检查:
    使用__rcu标记RCU保护的数据结构的指针,如果您在没有使用rcu_dereference()的任何变体的情况下访问该指针,稀疏检查将发出警告。

    这些调试辅助工具可以帮助您找到其他非常难以发现的问题。

  • 如果您将在模块内定义的回调函数传递给call_rcu()、call_srcu()、call_rcu_tasks()、call_rcu_tasks_rude()或call_rcu_tasks_trace()之一,则需要在卸载该模块之前等待所有挂起的回调被调用。请注意,仅等待一个宽限期绝对不足够!例如,synchronize_rcu()的实现不能保证等待通过call_rcu()在其他CPU上注册的回调。甚至不能保证等待在当前CPU上注册的回调,如果该CPU最近离线并重新上线。

    相反,您需要使用以下其中一个屏障函数:

    • call_rcu() -> rcu_barrier()

    • call_srcu() -> srcu_barrier()

    • call_rcu_tasks() -> rcu_barrier_tasks()

    • call_rcu_tasks_rude() -> rcu_barrier_tasks_rude()

    • call_rcu_tasks_trace() -> rcu_barrier_tasks_trace()

    然而,这些屏障函数绝对不能保证等待一个宽限期。例如,如果系统中没有任何call_rcu()回调排队,rcu_barrier()可以立即返回。

    因此,如果您需要等待宽限期和所有现有回调,您需要同时调用这两个函数,具体取决于RCU的类型:

    • synchronize_rcu()或synchronize_rcu_expedited(),以及rcu_barrier()

    • synchronize_srcu()或synchronize_srcu_expedited(),以及srcu_barrier()

    • synchronize_rcu_tasks()和rcu_barrier_tasks()

    • synchronize_tasks_rude()和rcu_barrier_tasks_rude()

    • synchronize_tasks_trace()和rcu_barrier_tasks_trace()

    如果需要,您可以使用类似工作队列的东西同时执行所需的一对函数。

    有关更多信息,请参阅RCU和可卸载模块。