首页
/ Linux 内核 RCU 无锁列表的引用计数设计:从自旋锁模式到 RCU 模式的演进(rcuref)

Linux 内核 RCU 无锁列表的引用计数设计:从自旋锁模式到 RCU 模式的演进(rcuref)

2026-09-04 15:20:29作者:丁柯新Fawn

本篇技术指南围绕 Linux 内核文档 Documentation/RCU/rcuref.rst 展开,讲解"由 RCU 保护的列表/数组元素如何做引用计数"这一经典难题。文中完整覆盖了从传统读写锁到 RCU 无锁读路径的三套代码模式(Listing A/B/C)、各模式的取舍以及 synchronize_rcu() 变体,并结合内核中 struct pidstruct posix_acl 的真实实现和 include/linux/percpu-refcount.h 的配套机制做源码级佐证。读完本文,你将掌握在内核模块中安全地为 RCU 保护数据结构做引用、避免释放竞争(use-after-free)的完整方法论。

一、背景:为什么引用计数与 RCU 结合是难点

在内核中,很多数据结构(进程表、权限对象、路由表等)以列表/数组形式组织,元素需要同时满足两个需求:

  1. 读者可以并发访问:读路径性能要求极高,希望无锁或只读加锁;
  2. 元素可能被删除:删除方必须保证"没有任何读者仍在使用"之后才能真正释放内存。

引用计数(reference counting)是满足第 2 点最常用的手段,但当写侧从自旋锁换成 RCU 后,atomic_inc() 这类无条件自增操作会引出一个微妙的竞争:读者可能在元素已从表中摘除但尚未释放的时间窗口内,对一个"正在消亡"的元素成功加引用。Documentation/RCU/rcuref.rst 正是系统性地解决这一问题的设计文档。

文档同时给出一个重要提示:如果你的场景是"引用计数 + RCU",第一选择应当是 percpu-refcount,见 include/linux/percpu-refcount.h。只有在 percpu-ref 的内存开销不可接受(例如每元素都要嵌一个 percpu 计数)这类少数场景下,才需要本文后半部分讨论的手动引用计数模式。

二、Listing A:传统读写锁 + 引用计数(基线模式)

在以读写自旋锁(read_lock/write_lock)或信号量保护的列表中,引用计数的写法非常直接,文档给出的基线模式(Listing A)如下:

1.                            2.
add()                         search_and_reference()
{                             {
    alloc_object                    read_lock(&list_lock);
    ...                             search_for_element
    atomic_set(&el->rc, 1);         atomic_inc(&el->rc);
    write_lock(&list_lock);         ...
    add_element                   read_unlock(&list_lock);
    ...                             ...
    write_unlock(&list_lock);     }
}

3.                            4.
release_referenced()          delete()
{                             {
    ...                         write_lock(&list_lock);
    if (atomic_dec_and_test(&el->rc))   ...
        kfree(el);                  remove_element
    ...                         write_unlock(&list_lock);
}                               ...
                                if (atomic_dec_and_test(&el->rc))
                                    kfree(el);
                                ...
                            }

要点:

  • add() 分配对象后先设置初始引用计数为 1(代表"表中驻留"这一引用),再写锁插入;
  • search_and_reference() 读锁内查找元素并 atomic_inc() 加引用,随后即可在读锁外安全使用;
  • delete() 写锁内摘除元素,锁外再 atomic_dec_and_test() 递减那个初始引用,归零即 kfree()
  • release_referenced() 是读者放引用的入口,归零同样 kfree()

这个模式之所以正确,是因为所有对 el->rc 的修改都被同一把锁串行化:读者要么加锁成功拿到引用、要么根本找不到元素,不存在中间态。

三、Listing B:RCU 读路径下的正确写法——atomic_inc_not_zero + call_rcu

现在把写侧改为 RCU:add()/delete() 中的 write_lock() 换成 spin_lock()search_and_reference() 中的 read_lock() 换成 rcu_read_lock()。此时 Listing A 里的 atomic_inc()持有一个引用到一个可能已被摘除的元素上——更糟的是,如果 delete() 恰好在 dec_and_test 观察到计数为 0 时读者的 inc 又插进来,元素就会被释放后继续被使用。

文档给出的解决方案(Listing B)是把无条件自增换成带零检查的条件自增 atomic_inc_not_zero(),并把释放推迟到 RCU 宽限期:

1.                            2.
add()                         search_and_reference()
{                             {
    alloc_object                    rcu_read_lock();
    ...                             search_for_element
    atomic_set(&el->rc, 1);         if (!atomic_inc_not_zero(&el->rc)) {
    spin_lock(&list_lock);                rcu_read_unlock();
    add_element                           return FAIL;
    ...                             }
    spin_unlock(&list_lock);          ...
}                             rcu_read_unlock();
}
3.                            4.
release_referenced()          delete()
{                             {
    ...                         spin_lock(&list_lock);
    if (atomic_dec_and_test(&el->rc))   ...
        call_rcu(&el->head, el_free); remove_element
    ...                         spin_unlock(&list_lock);
}                             ...
                                if (atomic_dec_and_test(&el->rc))
                                    call_rcu(&el->head, el_free);
                                ...
                            }

两处关键改动:

  1. atomic_inc_not_zero() 取代 atomic_inc():只有当引用计数非零时才自增成功;若 delete() 已把初始引用减掉(计数为 0),读者会得到 FAIL,从而避免给"已判死"的元素续命。
  2. kfree() 换成 call_rcu(&el->head, el_free):即便引用归零,也不立即释放,而是挂一个 RCU 回调,等一个**宽限期(grace period)**过去之后再执行 el_free() 释放内存。这保证了在释放前,所有仍可能持有旧指针视图的 RCU 读侧临界区都已退出。

文档还补充了两个重要细节:

  • 写侧流中获取引用:如果需要在校验(write)侧拿引用,由于此时已持有更新侧自旋锁,atomic_inc_not_zero() 属于"杀鸡用牛刀",直接用 atomic_inc() 即可。
  • delete() 不会被读者拖延:无论有多少个并发 search_and_reference() 在查同一个对象,delete() 的摘除动作都不受阻;被延迟的只是最终的 kfree(),而"推迟释放"在现代计算机系统(包括小型设备)上通常不是问题。

四、Listing C:把"归零判断"移入 RCU 回调——读者永不失败

Listing B 的代价是读路径可能返回 FAIL,而很多时候调用方并不方便处理失败。文档给出的第三种模式(Listing C)是:search_and_reference() 恢复使用无条件的 atomic_inc(),把 atomic_dec_and_test()delete() 挪到 el_free()

1.                            2.
add()                         search_and_reference()
{                             {
    alloc_object                    rcu_read_lock();
    ...                             search_for_element
    atomic_set(&el->rc, 1);         atomic_inc(&el->rc);
    spin_lock(&list_lock);          ...
    add_element                   rcu_read_unlock();
    ...                             }
    spin_unlock(&list_lock);
}

3.                            4.
release_referenced()          delete()
{                             {
    ...                         spin_lock(&list_lock);
    if (atomic_dec_and_test(&el->rc)   ...
        kfree(el);                  remove_element
    ...                         spin_unlock(&list_lock);
}                             ...
                                call_rcu(&el->head, el_free);
                                ...
                            }
5.
void el_free(struct rcu_head *rhp)
{
    release_referenced();
}

正确性关键(文档原文核心论证):add() 加入的那个初始引用,只有在摘除之后经过一个宽限期的 el_free() 中才被移除。这意味着:

  • el_free() 执行之前,search_and_reference() 在 RCU 读侧根本找不到该元素(它已不在表中且宽限期未过),即 el->rc 不可能再增长;
  • 因此一旦 release_referenced() 观察到计数归零,就确定不存在、也永远不会再出现能引用该元素的读者,可以安全 kfree()
  • 反过来,这也保证了"任何在 RCU 读侧找到该元素的读者,都可以不加检查地安全加引用"——因为只要还能找到它,el->rc 必然非零。

Listing C 对 Listing B 的明确优势search_and_reference() 只要找到了对象,就一定能成功获得引用,即使 delete() 正在并发地删除同一对象。而 B 与 C 相对 A 的共同优势是:delete() 不因读者数量而被延迟,被延迟的只有 kfree()

五、delete() 可睡眠时的变体:synchronize_rcu()

delete() 运行在可睡眠的上下文中时,可以直接在 delete() 内同步等待宽限期,把 el_free() 回调合并进 delete() 本身:

4.
delete()
{
    spin_lock(&list_lock);
    ...
    remove_element
    spin_unlock(&list_lock);
    ...
    synchronize_rcu();
    if (atomic_dec_and_test(&el->rc))
        kfree(el);
    ...
}

synchronize_rcu() 阻塞直到摘除时刻之后开始的所有 RCU 读侧临界区结束,因此其后即可安全地 kfree(),无需再排队 RCU 回调。注意这要求 delete() 所在的上下文允许睡眠。

六、内核中的真实使用:struct pidstruct posix_acl

文档末尾点名了两个内核内的实例,可在当前仓库中直接对照阅读:

  • struct pid 采用 Listing C 模式:参考 kernel/pid.c。其查找路径(如 find_pid_ns() 系列)在 rcu_read_lock() 下遍历 RCU 保护的 pid 哈希表,读侧直接对 pid->count 无条件 atomic_inc();而释放走 RCU 回调,在回调中再执行"减初始引用 + 归零判断",与 Listing C 的 el_free() 结构一致。
  • struct posix_acl 采用 Listing B 模式:参考 fs/posix_acl.cinclude/linux/posix_acl.h,其引用获取使用"非零才自增"的条件自增语义,失败时返回错误,正对应 Listing B 中的 FAIL 分支。

七、配套机制:percpu-refcount 才是首选

再次强调文档开篇的指引:需要"引用计数 + RCU"组合时,优先看 include/linux/percpu-refcount.h。从源码注释可以读出它的设计语义:

  • 提供与 atomic_inc()/atomic_dec_and_test() 相似语义的 percpu 化引用计数,热点 CPU 上的自增/自减只改本地 percpu 计数,避免跨核缓存行弹跳;
  • 采用两阶段关闭:先 percpu_ref_kill()(把计数从 percpu 模式收回单一原子计数、并标记 shutting down),之后 percpu_ref_put() 才会检测归零——这恰好对应本文"先摘除、后释放初始引用"的不变量;
  • 头文件注释明确指出:percpu_ref 本身不隐含任何 RCU 宽限期,需要与 RCU 保护的查找路径配合时(例如其中的 aio 示例 free_ioctx() 要同步 lookup_ioctx() 的 RCU 查找),必须显式走 call_rcu()——这与本文 Listing B/C 中"释放必须等宽限期"的原则完全同构。

当每个元素都要嵌入引用计数、且元素数量巨大(percpu 计数会放大内存占用)时,本文的 atomic_t 手动模式才是更省内存的选择。

八、小结:选型决策清单

场景 推荐模式 关键 API
读多写少、引用+RCU、内存宽松 percpu-refcount percpu_ref_kill() / percpu_ref_put() + 显式 call_rcu()
读路径可接受 FAIL 返回 Listing B atomic_inc_not_zero() + call_rcu()
读路径要求"找到必成功" Listing C atomic_inc() + 归零判断移入 RCU 回调
delete() 上下文可睡眠 Listing C 变体 synchronize_rcu() + 直接 kfree()

三条不变量贯穿所有模式:① 初始引用(表中驻留)只在摘除且宽限期过后移除;② 只要元素仍可被 RCU 读侧找到,其引用计数必非零;③ 真正 kfree() 前必须确保不存在任何可能持有旧指针的读者——前两条由设计保证,第三条由 call_rcu()/synchronize_rcu() 兜底。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384