Linux 内核 RCU 无锁列表的引用计数设计:从自旋锁模式到 RCU 模式的演进(rcuref)
本篇技术指南围绕 Linux 内核文档 Documentation/RCU/rcuref.rst 展开,讲解"由 RCU 保护的列表/数组元素如何做引用计数"这一经典难题。文中完整覆盖了从传统读写锁到 RCU 无锁读路径的三套代码模式(Listing A/B/C)、各模式的取舍以及 synchronize_rcu() 变体,并结合内核中 struct pid、struct posix_acl 的真实实现和 include/linux/percpu-refcount.h 的配套机制做源码级佐证。读完本文,你将掌握在内核模块中安全地为 RCU 保护数据结构做引用、避免释放竞争(use-after-free)的完整方法论。
一、背景:为什么引用计数与 RCU 结合是难点
在内核中,很多数据结构(进程表、权限对象、路由表等)以列表/数组形式组织,元素需要同时满足两个需求:
- 读者可以并发访问:读路径性能要求极高,希望无锁或只读加锁;
- 元素可能被删除:删除方必须保证"没有任何读者仍在使用"之后才能真正释放内存。
引用计数(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);
...
}
两处关键改动:
atomic_inc_not_zero()取代atomic_inc():只有当引用计数非零时才自增成功;若delete()已把初始引用减掉(计数为 0),读者会得到FAIL,从而避免给"已判死"的元素续命。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 pid 与 struct 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.c 与 include/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() 兜底。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00