Linux RCU 原理与应用

原作者:Linux教程
原文地址:Linux 内核:RCU机制全解(读多写少场景的性能神器)

各位啃 Linux 源码的时候,有没有发现一个细节:很多链表、指针后面有个 __rcu 后缀?比如 struct list_head __rcu *next; 或者 rcu_dereference() 这些 API。

今天这篇文章,主要目的是把 RCU(Read-Copy Update)讲透。

走起!

Part1 RCU 到底是什么?

RCU 全称 Read-Copy Update ,直白翻译就是**"读-拷贝-更新"**。

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

为什么这么设计?因为 Linux 内核里大量场景是读多写少。比如:

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

传统锁(rwlock)在读多的时候会让所有读者排队,性能差。RCU 直接干掉读端的锁竞争,让多个读者同时读 ,甚至读者和写者也能并发(写者自己加锁就行)。

读到这,我打赌你现在脑子里肯定冒出了好多问号:RCU怎么这么厉害?写数据的时候读数据居然不用等?有了读写锁为啥还要搞个RCU?别担心,下面这张图能让你恍然大悟!"

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

Part2 RCU 解决了哪些难题?

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

  1. 读者正在读的时候,写者删除了节点 写者可以把节点从链表移除,但不能立刻释放内存。必须等到所有读者都读完(宽限期 Grace Period)才能销毁。这就是 RCU 的"延迟释放"机制。
  2. 读者正在读的时候,写者插入了新节点 需要保证读者读到的节点是完整初始化 的。这里用到了发布-订阅机制(Publish-Subscribe),靠内存屏障保证可见性。
  3. 链表遍历不能因为增删而断链 RCU 保证遍历不会从中间断开,但不保证一定能读到最新节点(这也是它和普通锁的区别)。

一句话总结就是RCU 让读者几乎无感,写者承担所有复杂性

Part3 RCU 原理

RCU 可以看作 rwlock 的升级版,但更激进:

  • 读者:几乎零开销。不加锁、不用原子指令(除 Alpha 外无需内存屏障),也不会导致死锁。
  • 写者:先拷贝副本 → 修改副本 → 注册回调 → 等待 Grace Period → 真正替换/释放。

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

内核里会维护 per-CPU 变量来标记每个 CPU 是否经历过一次安静状态。写者挂起后:

  1. 重置所有 per-CPU 变量为 0;
  2. 每个 CPU 切换一次就把自己的变量设为 1;
  3. 全部变为 1 后,唤醒写者,执行回调。

Part4 一个简单的例子看穿本质

我们先看内核文档 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);              // 危险!可能被读者还在用
}

如果去掉 spinlock,直接并发更新,会出现**释放后使用(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 允许多个读者并发,也允许读者和写者并发,但多个写者之间仍需锁同步(这里用了 spinlock)。

往期文章推荐

不懂 内存管理,别再说你懂 Linux 内核了

为什么很多人劝退学 C++,但大厂核心岗位还是要 C++?

【大厂标准】Linux C/C++ 后端进阶学习路线

音视频流媒体高级开发-学习路线

C++ Qt学习路线一条龙!(桌面开发&嵌入式开发)

Linux内核学习指南,硬核修炼手册

C/C++ 高频八股文面试题1000题(三)

手撕线程池:C++程序员的能力试金石

Part5 RCU 核心 API

复制代码
rcu_read_lock();          // 进入读临界区(关抢占)
rcu_read_unlock();        // 退出读临界区


synchronize_rcu();        // 写者等待 Grace Period(核心阻塞点)


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

后面会逐个讲解它们在链表里的真实用法。

Part6 链表上的实例

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 乱序执行可能导致:新节点还没初始化完,就被读者看到!内存屏障保证 new->next、new->prev 先写完,再把指针发布出去。

注意:如果多个线程同时 add,仍需额外 spinlock 保护。

6.2 访问链表项

标准读模式:

复制代码
rcu_read_lock();
list_for_each_entry_rcu(pos, head, member) {
    // do something with 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 上则是空实现(性能无损)。

读临界区全局可见:只要有任何一个读者还在临界区,synchronize_rcu() 就会阻塞,直到所有读者退出。这就是 Grace Period 的直观体现。

6.3 删除链表项

复制代码
p = search_the_entry_to_delete();
list_del_rcu(p->list);      // 只移除,不释放
synchronize_rcu();          // 等待 Grace Period
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() 安全替换。

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

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

原来用 rwlock 的读端:

复制代码
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 版本,并用 call_rcu() 异步释放(代替 synchronize_rcu())。

场景二:需要修改链表条目

必须先拷贝 → 修改副本 → list_replace_rcu() → call_rcu() 释放旧条目。

场景三:不能容忍旧数据(立即可见)

在每个条目里加 deleted 标志 + 每个条目自己的 spinlock:

读端检查 if (e->deleted) 立即跳过;写端删除时先标记 deleted = 1,再 list_del_rcu() + call_rcu()。

总结

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

优点

  • 读端几乎零开销,完美适配"读多写少"场景;
  • 在路由表、dcache、SELinux AVC、IPC 等地方已大规模替换 rwlock,性能提升明显。

缺点

  • 写端延迟释放会短暂占用内存(嵌入式系统需谨慎);
  • 写者较多或不能容忍旧数据时,仍需额外锁,收益会打折扣。
相关推荐
The Sheep 20231 小时前
Vue复习
linux·服务器·数据库
qeen871 小时前
【C++】类与对象之类的默认成员函数(二)
android·c语言·开发语言·c++·笔记·学习
兄台の请冷静1 小时前
Linux 安装es
linux·elasticsearch·jenkins
fengyehongWorld2 小时前
Linux rg命令
linux
pride.li2 小时前
海思视觉Hi3516CV610--开机自动设置ip
linux·网络·网络协议·tcp/ip
我叫张小白。2 小时前
CentOS 7 安装 Docker并配置镜像加速(完整指南)
linux·docker·centos
王老师青少年编程2 小时前
信奥赛C++提高组csp-s之搜索进阶(记忆化搜索案例实践3)
c++·记忆化搜索·方格取数·csp·信奥赛·csp-s·提高组
Titan20243 小时前
Linux动静态库
linux·服务器·c++
赵民勇4 小时前
Linux file命令详解
linux·运维