Skip to content

RCU (Read Copy Update)

一、RCU 核心思想与适用场景

  1. 定义: RCU (Read-Copy Update) 是 Linux 内核中一种重要的同步机制。其核心思想正如其名:“读取(随意进行),拷贝更新(修改时先复制)”。
  2. 工作模式:
    • 读取 (Read): 读者可以无需加锁、无阻塞地并发访问受 RCU 保护的数据。读取操作非常快,几乎没有性能损耗。
    • 更新 (Update/Write): 写者(更新者)不能直接在原始数据上修改。它必须:
      1. 拷贝 (Copy): 创建一个数据的副本。
      2. 修改 (Modify): 在副本上进行所有必要的修改。
      3. 替换 (Replace): 修改完成后,通过一次原子性的指针操作(如 rcu_assign_pointer)将指向旧数据的指针替换为指向新副本的指针。
      4. 等待 (Wait): 等待一个所谓的“宽限期” (Grace Period) 结束。
      5. 回收 (Reclaim): 宽限期结束后,安全地释放旧数据的内存。
  3. 目标场景: RCU 主要针对读多写少”的共享数据场景。在这种场景下,读操作的性能至关重要,而写操作的频率相对较低。
  4. 关键优势: 极大地提升了读密集型负载下的并发性能和可伸缩性,因为读者之间、读者与写者之间(在读取阶段)几乎没有同步开销。

二、RCU 的关键组件与机制

  1. 读端临界区 (Read-Side Critical Sections):

    • 界定: 通过 rcu_read_lock()rcu_read_unlock() 宏来界定。
    • 特性:
      • 非阻塞: 进入和退出读端临界区非常轻量级,通常只涉及禁用/启用抢占或类似操作,不会睡眠或自旋等待
      • 允许多个读者并发: 多个线程可以同时处于 RCU 读端临界区内。
      • 保护读者: RCU 的核心保证是:任何在读端临界区内开始访问的数据,在该临界区结束之前,其内存不会被释放
  2. 安全指针访问 (rcu_dereference()):

    • 目的: 用于安全地获取一个受 RCU 保护的指针。
    • 机制:
      • 内部通常包含 ACCESS_ONCE() 来防止编译器进行不当的访问优化(如重复读取或消除读取)。
      • 包含 smp_read_barrier_depends()(依赖性内存屏障)。虽然在 x86/ARM 等常见架构上可能是空操作,但在像 Alpha 这样的弱内存序架构上,它可以防止编译器或 CPU 进行“值推测”优化,确保指针的读取发生在解引用(访问指针指向的内容)之前,保证了代码的可移植性和正确性。
      • 配合 __rcu 标记(供 Sparse 等静态分析工具使用),强制开发者使用 rcu_dereference() 访问受 RCU 保护的指针。
    • 模式: c rcu_read_lock(); struct data_struct *ptr = rcu_dereference(protected_pointer); if (ptr) { // 安全地使用 ptr 指向的数据 // 例如:int val = ptr->some_field; } rcu_read_unlock();
  3. 原子指针赋值 (rcu_assign_pointer()):

    • 目的: 原子地更新一个受 RCU 保护的指针,使其指向新的数据副本。这是 RCU 更新机制中的关键一步。
    • 机制:
      • 核心是执行 (p) = (v) 这样的指针赋值。
      • 包含写内存屏障 (smp_wmb()): 这是至关重要的。它确保在执行指针赋值之前,对新副本的所有初始化修改(如 new->next = next; new->prev = prev;)都已经完成并且对其他 CPU 可见。这防止了读者通过新指针访问到一个尚未完全初始化的数据结构。
    • 保证: 确保新数据结构在被“发布”(指针被更新)之前是完全初始化好的。
  4. 宽限期 (Grace Period) 与同步 (synchronize_rcu()):

    • 定义: 宽限期是指从写者发起一次更新(逻辑上移除旧数据或准备替换)开始,到系统中所有在此之前已经进入 RCU 读端临界区的读者全部退出临界区为止的这段时间。
    • synchronize_rcu() 的作用: 这是一个阻塞函数。调用它的写者线程会在此等待,直到一个完整的宽限期结束。
    • 为何需要等待: 为了确保没有任何读者仍然持有指向即将被释放的旧数据副本的指针。一旦 synchronize_rcu() 返回,就意味着所有可能看到旧数据的读者都已经完成了它们的读端临界区,此时释放旧数据的内存才是安全的。
    • 理解: 想象一条时间线,写者在 t1 时刻逻辑上删除了旧数据(或替换了指针),在 t2 时刻调用 synchronize_rcu()。该函数会一直阻塞,直到 t3 时刻,所有在 t1 之前进入读端临界区的读者(如图中的 Reader 1, 2, 3)都已经退出。在 t1 之后才进入临界区的读者(Reader 4, 5, 6)已经无法看到旧数据了。因此,在 t3 之后 kfree() 旧数据是安全的。
  5. 内存回收 (kfree()):synchronize_rcu() 返回后,写者可以安全地调用 kfree() 或类似函数来释放旧数据副本占用的内存。

三、RCU 在链表操作中的应用 (rculist.h)

RCU 非常适合用于链表这类动态数据结构。Linux 内核提供了 rculist.h 头文件来简化 RCU 保护下的链表操作。

  1. 增加链表项 (list_add_rcu, __list_add_rcu):

    • 先完全初始化新节点 (new->next, new->prev)。
    • 使用 rcu_assign_pointer(list_next_rcu(prev), new) 将新节点链接到链表中。smp_wmb() 内存屏障确保初始化先于链接完成。
    • 更新 next->prev = new
    • 注意: 如果多个写者可能同时添加节点,添加操作本身需要外部锁(如 spinlock)保护,RCU 不解决写者间的并发冲突。
  2. 访问链表项 (list_for_each_entry_rcu):

    • 必须在 rcu_read_lock()rcu_read_unlock() 界定的临界区内进行。
    • 内部使用 rcu_dereference() 安全地获取链表节点的指针。
    • 读者可以安全地遍历链表,即使在遍历过程中有写者正在修改链表(添加、删除、替换节点)。读者要么看到修改前的数据,要么看到修改后的数据,但不会访问到正在被释放的内存或未初始化完全的节点。
  3. 删除链表项 (list_del_rcu):

    • 逻辑删除: 调用 __list_del() 将节点从链表中移除(修改前后节点的指针)。这是快速的。
    • 标记(可选): entry->prev = LIST_POISON2; 将删除节点的指针设为特殊值,有助于调试。
    • 延迟回收: 不立即释放内存。调用者必须在 list_del_rcu() 之后调用 synchronize_rcu() 等待宽限期结束,然后才能 kfree(p) 释放被删除节点的内存。
    • 模式: c p = search_entry_to_delete(); list_del_rcu(&p->list); // 逻辑删除 synchronize_rcu(); // 等待所有潜在读者完成 kfree(p); // 安全回收内存
  4. 更新链表项 (list_replace_rcu):

    • 遵循 "Copy Update" 原则:
      1. 找到要更新的旧节点 p
      2. 分配新节点 q (kmalloc)。
      3. 复制旧节点内容到新节点 (*q = *p;)。
      4. 副本 q 上进行修改 (q->field = new_value;)。
      5. 调用 list_replace_rcu(&p->list, &q->list) 用新节点 q 原子地替换旧节点 p 在链表中的位置。内部使用 rcu_assign_pointer 保证顺序和可见性。
      6. 调用 synchronize_rcu() 等待宽限期结束。
      7. kfree(p) 安全地释放旧节点 p 的内存
    • 模式: c p = search_entry_to_update(); q = kmalloc(...); *q = *p; // 复制 q->field = new_value; // 在副本上修改 list_replace_rcu(&p->list, &q->list); // 原子替换 synchronize_rcu(); // 等待 kfree(p); // 回收旧节点

四、RCU 的优缺点与考量

优点:

  1. 极高的读性能: 读操作几乎没有同步开销,非常适合读密集型场景。
  2. 良好的伸缩性: 读者数量增加时,性能几乎不受影响。
  3. 无死锁(读写间): 读者不持有锁,不会与写者发生死锁。
  4. 实时性友好: 读操作是 wait-free 的,不会导致不可预测的延迟。

缺点与考量:

  1. 写者开销增大: 需要复制数据、等待宽限期,写操作延迟较高。
  2. 内存开销: 更新期间,新旧数据副本并存,需要额外内存。
  3. 宽限期延迟: synchronize_rcu() 可能阻塞较长时间,尤其在读者临界区很长或系统负载高时。这可能不适用于对写延迟要求极高的场景。
  4. 写者间同步: RCU 不解决写者之间的并发问题,如果存在多个写者,仍需使用锁(如 spinlock, mutex)来保护写操作本身。
  5. 复杂性: 理解和正确使用 RCU 比传统锁机制更复杂,需要仔细管理内存生命周期。
  6. 适用性: 不适合写密集型或读写均衡的场景。

五、总结

  • RCU 是一种基于延迟回收 (Deferred Reclamation) 思想的高性能同步机制。
  • 它通过允许读者无锁访问,并将数据修改的同步开销和内存回收的负担转移到写者身上,极大地优化了读密集型场景下的并发性能。
  • 理解读端临界区、宽限期、原子指针操作 (rcu_assign_pointer)、安全指针解引用 (rcu_dereference) 以及相关的内存屏障是掌握 RCU 的关键。
  • RCU 并非万能药,需要根据具体应用场景的读写比例、性能要求、延迟容忍度等因素来权衡是否使用。
  • 除了 synchronize_rcu()(阻塞等待),RCU 还提供了异步接口 call_rcu(),它注册一个回调函数,在宽限期结束后异步执行(通常用于内存回收),避免了写者线程的阻塞,适用于不能睡眠的上下文(如中断处理)。这是实际内核代码中更常用的方式。