首页 / Rust 入门教程 / 引用循环与内存泄漏防范

Rust 入门教程

引用循环与内存泄漏防范

本教程共 78 篇 · 第 64 篇 · 更新于 2026-08-08 · 约 10 分钟阅读

RustRust 入门教程引用循环内存泄漏WeakRc强引用Drop

本节目标:认识 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 结束时,leafbranch 的局部变量确实离开作用域,但循环还在:branch.children 里抱着 leafleaf.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 也有多线程版:ArcWeak(来自 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 等方向继续深入。