引用循环与内存泄漏防范
本教程共 78 篇 · 第 64 篇 · 更新于 2026-08-08 · 约 10 分钟阅读
本节目标:认识 Rc 引用计数的致命陷阱——两个对象互相持有强引用形成循环,计数永远归不了零、内存泄漏;学会用 Weak
弱引用打破循环,并掌握父子关系中”父强子弱”的设计原则。
Rust 靠所有权和 Drop 自动回收内存,通常你不必担心泄漏。但 Rc<T> 这套引用计数有个坑:如果两份数据互相指着对方,计数就永远降不到零,Drop 永不触发,内存悄悄泄漏。这章把这事讲清楚,并给出解法。
1-1 Rc 不是银弹
前面说 Rc 计数归零才释放,听起来万无一失。但”归零”有个前提:所有引用最终都能被切断。如果 A 持有 B、B 又持有 A,谁都离不开谁,计数就卡在 1 以上,两个对象都走不出作用域、Drop 永远不跑。
这就是引用循环(reference cycle)。它不代表 Rust 有 bug——恰恰是你自己用 Rc 把关系连成了环。GC 语言也会遇到循环引用,只是它们有垃圾回收器兜底;Rust 把”是否循环”的判断交给了你。
1-2 构造一个循环看泄漏
设想一个有子节点的树结构。父节点持有子节点(强引用),子节点也回头指向父节点(强引用):
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Option<Rc<Node>>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(None),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(None),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// leaf 回头指向 branch
*leaf.parent.borrow_mut() = Some(Rc::clone(&branch));
println!("leaf 强引用数: {}", Rc::strong_count(&leaf));
println!("branch 强引用数: {}", Rc::strong_count(&branch));
} // 这里 leaf 和 branch 离开作用域
main 结束时,leaf 和 branch 的局部变量确实离开作用域,但循环还在:branch.children 里抱着 leaf,leaf.parent 里抱着 branch。两边的强引用计数都至少是 1,永远归不了零,于是两边的 Node 都不会被 Drop。
Warning这种泄漏很隐蔽:程序照样跑,只是那两块内存再也不会被回收。长时间运行、循环不断被创建的服务,内存会慢慢涨上去直到 OOM。单元测试往往发现不了,得靠长期观察或专门工具(如 valgrind)排查。
1-3 为什么 Drop 救不了它
回顾上章:Drop 只保证”值走出作用域时自动清理”。循环里的对象走不出作用域——因为还有别的 Rc 指着它,它还”被需要”。Drop 管得了”到点清理”,管不了”到点不了”。所以光靠 Drop 解决不了循环泄漏。
1-4 解法:Weak 弱引用
Weak<T> 是 Rc 的弱引用版本,由 Rc::downgrade 产生。关键区别:弱引用不计入强引用计数,不影响对象是否该被释放。
use std::rc::Rc;
let a = Rc::new(5);
let weak_a = Rc::downgrade(&a); // 产生弱引用,strong_count 不变
println!("强引用: {}, 弱引用: {}", Rc::strong_count(&a), Rc::weak_count(&a));
弱引用不保证指向的对象还活着——因为强引用可能早已归零、对象已被释放。所以访问弱引用得先”升级”成 Rc,而升级可能失败,返回 Option<Rc<T>>:
if let Some(strong) = weak_a.upgrade() {
println!("对象还在: {}", strong);
} else {
println!("对象已经被释放了");
}
1-5 用 Weak 打破父子循环
回到树结构:父拥有子(父强子强会成环),正确做法是父用强引用持有子、子用弱引用指回父。子不增加父的强计数,循环被打破。
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Option<Weak<Node>>>, // 子指父:弱引用
children: RefCell<Vec<Rc<Node>>>, // 父指子:强引用
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(None),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(None),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// 子用弱引用指回父
*leaf.parent.borrow_mut() = Some(Rc::downgrade(&branch));
println!("leaf 强引用: {}", Rc::strong_count(&leaf)); // 1
println!("branch 强引用: {}", Rc::strong_count(&branch)); // 2
// 查看父节点(需 upgrade)
if let Some(parent) = leaf.parent.borrow().as_ref().and_then(|w| w.upgrade()) {
println!("leaf 的父节点值是 {}", parent.value);
}
} // main 结束:branch 强计数降到 0,branch 被释放;leaf 也随之释放
现在 main 结束时:局部 branch 离开,branch 的强计数从 2 降到 1(还有 leaf.children 抱着它);局部 leaf 离开,leaf 强计数从 1 降到 0——leaf 被释放,leaf.children 跟着清空,于是 branch 强计数再降到 0,branch 也被释放。循环断开了,两个对象都正常 Drop。
Tip“父强子弱”是树/图结构的经典法则:拥有关系用强引用(父→子),反向查找用弱引用(子→父)。这样所有权方向是单向的,绝不会成环。
1-6 强引用还是弱引用
判断标准其实很朴素:
- 如果”这份数据活着,我必须也得活着 / 我也算它的主人”,用强引用
Rc。 - 如果”我只是偶尔需要找到它,它没了我也照常工作”,用弱引用
Weak。
比如缓存:缓存项被访问时想用 Rc 持有,但如果原数据已被释放,缓存项空着也无妨——这就是 Weak 的典型场景。再比如 GUI 里控件指回窗口、DOM 节点指回文档,反向指针基本都该是 Weak。
Note
Weak也有多线程版:Arc配Weak(来自std::sync),规则一样——Arc::downgrade产生弱引用,upgrade升级成Option<Arc<T>>。单线程用Rc/Weak,跨线程用Arc/Weak。
1-7 小结与全章回顾
智能指针这一组,到这就收尾了:
Box<T>把值放上堆,用于大值、递归类型、特征对象。Deref让智能指针像引用一样用,Drop负责离开作用域时清理。Rc<T>给单线程多所有权,Arc<T>给多线程多所有权;只读,要改配RefCell/Mutex。RefCell<T>提供内部可变性,借用检查推迟到运行期。- 多所有权要警惕引用循环,用
Weak<T>弱引用打破它。
Warning引用循环不是编译器能替你挡的。凡是出现”双向互相持有 Rc”的设计,先问一句”哪边该降为 Weak”。养成这个习惯,能避开 Rust 里为数不多会真泄漏内存的坑。
1-8 如何主动发现循环隐患
引用循环不会编译报错、运行也不崩,是最阴险的一类问题。几个主动防范的习惯:
写涉及双向引用的结构时,先问”谁是拥有者、谁是被引用者”。拥有者用强引用(Rc/Arc),被引用者回头只该用弱引用(Weak)。这条”强拥有、弱回指”的法则,能从设计上杜绝绝大多数循环。
Warning只要一个结构里同时出现两个
Rc(或Arc)互相指向对方,几乎必然成环。看到这种写法先警惕,确认至少一边是Weak。图、树、双向链表、带父指针的节点,都是高发区。
测试阶段也能探一探:在对象该被释放的地方打印一下,或用 Rc::strong_count / Rc::weak_count 观察计数是否如预期归零。如果本该归零却停在 1,八成是循环泄漏。生产环境长期运行的服务,若发现内存只涨不跌,也是循环的红灯信号。
另外,Rc 的循环只是”泄漏内存”,而 Arc 的循环还会让原子计数空转、且同样不释放——跨线程场景危害一样存在,解法同样是 Weak。记住:引用计数的”自动释放”是有前提的,前提就是”没有环”。环一旦形成,计数就永远卡住,Drop 永远不叫。
1-9 智能指针全景回顾
把这一组串起来看:
Box<T>:最朴素,把值放上堆,用于大值、递归类型、特征对象。Deref/Drop:智能指针的两大基石,一个让它像引用,一个负责离开时清理。Rc<T>/Arc<T>:引用计数,给单线程/多线程的多所有权;只读,要改配RefCell/Mutex。RefCell<T>:内部可变性,借用检查推迟到运行期。Weak<T>:弱引用,打破循环、防范泄漏。
它们不是互相替代,而是按需组合。读懂”谁拥有、谁只引用、能不能改、会不会成环”,你就能在真实项目里选对那一个。
至此,从所有权、借用、trait,到测试、Cargo 进阶、智能指针,你已具备阅读绝大多数 Rust 项目源码的能力。后面可以往并发、异步、生态 crate 等方向继续深入。