找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5035

积分

0

好友

685

主题
发表于 3 小时前 | 查看: 4| 回复: 0

大家在啃 Linux 内核源码的时候,有没有注意到一个细节:很多链表、指针后面都带着一个 __rcu 后缀?比如 struct list_head __rcu *next; 或者 rcu_dereference() 这类 API。

今天,我们就来把 RCU(Read-Copy Update)这个机制彻底讲明白。

RCU 到底是什么?

RCU 全称 Read-Copy Update,直译过来就是 “读-拷贝-更新”

它的核心思想非常简单:读者可以随意读取数据,开销几乎为零;写者想要修改数据时,先拷贝一份副本,在副本上修改完成,再一次性将指针指向新数据。

为什么要这样设计?因为在 Linux 内核中,存在大量 读多写少 的场景。例如:

  • 文件系统里频繁的目录查找(读);
  • 路由表查询(读);
  • 系统调用审计、SELinux AVC、dcache 等等。

传统的读写锁(rwlock)在读操作频繁时,会让所有读者排队等待,性能很差。RCU 则完全消除了读端的锁竞争,允许多个读者同时读取,甚至允许读者和写者并发执行(写者只需自己处理好同步即可)。

读到这里,你可能会有很多疑问:RCU 怎么做到的这么高效?写数据的时候读数据真的不用等吗?有了读写锁为什么还需要 RCU?别急,下面这张图能帮你直观理解它的核心流程。

RCU读写并发与宽限期示意图

内核的官方文档在 Documentation/RCU/ 目录下非常齐全,其主要实现者 Paul E. McKenney 也撰写了大量文章。想要深入研究的同学可以直接去查阅内核源码。

RCU 解决了哪些难题?

RCU 在设计时重点解决了三个关键问题:

  1. 读者正在读的时候,写者删除了节点
    写者可以将节点从链表中移除,但不能立刻释放其内存。必须等到所有读者都读完(即度过宽限期 Grace Period)后才能安全销毁。这就是 RCU 的“延迟释放”机制。

  2. 读者正在读的时候,写者插入了新节点
    需要保证读者看到的节点是完整初始化的。这里利用了发布-订阅机制(Publish-Subscribe),依靠内存屏障来保证写入的可见性。

  3. 链表遍历不能因为增删而断链
    RCU 保证遍历过程不会从中间断开,但不保证一定能读到最新的节点(这也是它与普通锁的一个重要区别)。

用一句话总结就是:RCU 让读者几乎无感知,而将所有的复杂性交给了写者来处理。

RCU 原理剖析

你可以将 RCU 看作读写锁的升级版,但更为激进:

  • 读者:开销极低。无需加锁、无需原子指令(除了 Alpha 架构外甚至不需要内存屏障),也不会导致死锁。
  • 写者:需要拷贝副本 -> 修改副本 -> 注册回调 -> 等待宽限期 -> 最终完成替换或释放。

Grace Period(宽限期) 是 RCU 的灵魂。它指的是 “所有 CPU 都至少经历了一次上下文切换(即进入 Quiescent State,安静状态)” 的一段时间。
为什么用上下文切换来判断?因为 RCU 的读端要求 读者在临界区内不能被调度rcu_read_lock 期间会关闭抢占)。一旦发生了上下文切换,就说明该 CPU 上的读者已经安全退出了临界区。

内核会维护每个 CPU 的变量来标记它们是否经历过一次安静状态。写者挂起回调后:

  1. 重置所有 CPU 的标记为 0;
  2. 每个 CPU 经历一次上下文切换后,将自己的标记设为 1;
  3. 当所有 CPU 的标记都变为 1 后,唤醒写者,执行其回调(如释放内存)。

一个简单的例子看穿本质

我们先来看内核文档 whatisRCU.txt 中的一个经典例子(保护一个全局指针):

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

DEFINE_SPINLOCK(foo_mutex);
struct foo *gbl_foo;

void foo_read(void)
{
    struct foo *fp = gbl_foo;  // 普通读(后续会改成 rcu 版)
    if (fp != NULL)
        dosomething(fp->a, fp->b, fp->c);
}

void foo_update(struct foo *new_fp)
{
    spin_lock(&foo_mutex);
    struct foo *old_fp = gbl_foo;
    gbl_foo = new_fp;          // 指针切换
    spin_unlock(&foo_mutex);
    kfree(old_fp);             // 危险!读者可能还在使用 old_fp
}

如果去掉自旋锁,直接并发更新,就会出现 释放后使用(use-after-free) 的严重 Bug。用 RCU 改造后如下(注意关键变化):

void foo_read(void)
{
    rcu_read_lock();                   // 声明进入读临界区
    struct foo *fp = gbl_foo;
    if (fp != NULL)
        dosomething(fp->a, fp->b, fp->c);
    rcu_read_unlock();                 // 声明退出读临界区
}

void foo_update(struct foo *new_fp)
{
    spin_lock(&foo_mutex);
    struct foo *old_fp = gbl_foo;
    gbl_foo = new_fp;
    spin_unlock(&foo_mutex);

    synchronize_rcu();                 // 等待所有读者退出!
    kfree(old_fp);
}

RCU 允许多个读者并发,也允许读者和写者并发,但多个写者之间仍然需要锁来同步(这里使用了 spin_lock)。这正是在复杂后端 & 架构中常见的设计权衡。

RCU 核心 API

rcu_read_lock();          // 进入读临界区(关闭抢占)
rcu_read_unlock();        // 退出读临界区

synchronize_rcu();        // 写者等待宽限期(核心阻塞点)

rcu_assign_pointer();     // 写者安全发布新指针(带内存屏障)
rcu_dereference();        // 读者安全解引用(带内存屏障)

接下来我们看看它们在链表操作中的实际应用。

链表上的实战示例

6.1 增加链表项

内核中增加 RCU 链表项的典型代码如下:

#define list_next_rcu(list)     (*((struct list_head __rcu **)(&(list)->next)))

static inline void __list_add_rcu(struct list_head *new,
                struct list_head *prev, struct list_head *next)
{
    new->next = next;
    new->prev = prev;
    rcu_assign_pointer(list_next_rcu(prev), new); // 关键发布操作
    next->prev = new;
}

这里的 __rcu 后缀是给 Sparse 静态分析工具使用的标注,它强制开发者必须使用 rcu_dereference() 来访问这些被标记的指针

重点看 rcu_assign_pointer()

#define __rcu_assign_pointer(p, v, space) \
    ({ \
        smp_wmb();                     // 写内存屏障
        (p) = (typeof(*v) __force space *)(v); \
    })

为什么需要内存屏障?
CPU 的乱序执行可能导致:新节点的 nextprev 指针还没初始化完,这个新节点就被读者看到了!内存屏障确保了 new->nextnew->prev 的写入先完成,然后再将新节点的指针“发布”出去。

注意:如果多个线程同时执行添加操作,仍然需要额外的自旋锁(如 spin_lock)进行保护。

6.2 访问链表项

标准的读取模式:

rcu_read_lock();
list_for_each_entry_rcu(pos, head, member) {
    // 对 pos 进行操作
}
rcu_read_unlock();

list_for_each_entry_rcu 内部最终会调用 rcu_dereference()

#define __rcu_dereference_check(p, c, space) \
    ({ \
        typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \
        rcu_lockdep_assert(c, "suspicious rcu_dereference_check() usage"); \
        rcu_dereference_sparse(p, space); \
        smp_read_barrier_depends();     // 读依赖屏障
        ((typeof(*p) __force __kernel *)(_________p1)); \
    })

在 Alpha 这类弱内存序架构上,这条屏障防止编译器或 CPU 的推测优化导致乱序;在 x86/ARM 等强内存序架构上,它通常是空实现(无性能损耗)。

读临界区的全局可见性:只要系统中还有一个读者处于 rcu_read_lock() 保护区内,synchronize_rcu() 调用就会阻塞,直到所有读者退出。这就是宽限期最直观的体现。

6.3 删除链表项

p = search_the_entry_to_delete();
list_del_rcu(p->list);      // 仅从链表逻辑上移除,不释放内存
synchronize_rcu();          // 等待宽限期结束
kfree(p);

list_del_rcu() 的源码很简单:

static inline void list_del_rcu(struct list_head *entry)
{
    __list_del(entry->prev, entry->next);
    entry->prev = LIST_POISON2;  // “毒化”指针,防止后续误用
}

6.4 更新链表项

p = search_the_entry_to_update();
q = kmalloc(sizeof(*p), GFP_KERNEL);
*q = *p;                    // 完整拷贝原数据
q->field = new_value;       // 在副本上修改
list_replace_rcu(&p->list, &q->list); // 安全替换
synchronize_rcu();
kfree(p);                   // 延迟释放旧版本

list_replace_rcu() 内部同样使用 rcu_assign_pointer() 进行安全的指针替换。

实战:RCU 在系统调用审计中的应用

场景一:只有增加/删除(最常见、最容易转换)

原来使用读写锁的读端:

read_lock(&auditsc_lock);
list_for_each_entry(e, &audit_tsklist, list) { ... }
read_unlock(&auditsc_lock);

改成 RCU 后:

rcu_read_lock();
list_for_each_entry_rcu(e, &audit_tsklist, list) { ... }
rcu_read_unlock();

写端原来用 write_lock,现在只需把 list_add/list_del 换成 _rcu 版本(如 list_add_rcu),并使用 call_rcu() 进行异步内存释放(这比 synchronize_rcu() 更高效,不会阻塞写者)。

场景二:需要修改链表条目内容
必须遵循“拷贝-修改-替换”流程:先拷贝 -> 修改副本 -> list_replace_rcu() -> 最后用 call_rcu() 释放旧条目。

场景三:不能容忍旧数据(要求立即可见)
可以在每个链表条目中增加一个 deleted 标志位,并配合条目自己的自旋锁:

  • 读端在遍历时检查 if (e->deleted),如果为真则立即跳过。
  • 写端删除时,先标记 deleted = 1,然后再执行 list_del_rcu() + call_rcu()

总结

RCU 是 Linux 2.6 内核引入的重量级同步机制,用好它,内核性能能上一个大台阶。

优点

  • 读端开销极低,完美契合“读多写少”的经典场景。
  • 在路由表、dcache、SELinux AVC、IPC 等关键路径上已大规模替换传统的读写锁,带来了显著的性能提升。

缺点

  • 写端的延迟释放机制会导致内存被短暂地多占用一段时间(在嵌入式等内存敏感系统中需谨慎评估)。
  • 当写者非常多,或者应用场景完全无法容忍读到旧数据时,仍需引入额外的锁机制,其带来的收益会打折扣。

理解 RCU 机制,不仅有助于我们深入理解Linux内核的并发设计哲学,也是进行高性能、低延迟多线程编程的重要知识储备。希望这篇解析能对你有所帮助。如果你想与其他开发者交流更多内核或底层技术,欢迎来 云栈社区 一起探讨。




上一篇:Intel Nova Lake处理器曝光:BLLC大缓存与LGA 1954接口将支持四代CPU
下一篇:使用 FastAPI-Amis-Admin 快速搭建高效 Python 后台管理系统
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-20 10:47 , Processed in 0.805643 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表