Rust 智能指针实战:Rc、RefCell 与循环引用

Rust 的所有权规则在编译期解决大部分问题:一个值只有一个所有者,借用要么多个不可变,要么一个可变。但只要写稍微复杂一点的数据结构——树、图、双向链表、观察者列表——就会撞上同一个问题:我需要多个地方共享同一份数据,而且还要修改它。

标准库给出的答案是 Rc<T> 加 RefCell<T>。这个组合确实能用,但它把编译期的检查搬到了运行时,代价是 panic、引用计数开销,以及一种 Rust 里罕见的 bug:内存泄漏。这篇文章从一个真实场景出发,把这几个坑逐个拆开。

一、先搞清楚 Rc 解决的是什么

Box<T> 是独占所有权,赋值即移动。如果两个父节点要共享同一个子节点,Box 就无能为力了。Rc<T>(Reference Counting)通过引用计数实现多所有者:每次 clone 只是把计数加一,数据本身不动。

1use std::rc::Rc;
2
3#[derive(Debug)]
4struct Node {
5    name: &'static str,
6    children: Vec<Rc<Node>>,
7}
8
9fn main() {
10    let leaf = Rc::new(Node { name: "leaf", children: vec![] });
11
12    let a = Node { name: "a", children: vec![Rc::clone(&leaf)] };
13    let b = Node { name: "b", children: vec![Rc::clone(&leaf)] };
14
15    println!("strong = {}", Rc::strong_count(&leaf)); // 3
16    println!("{:?} {:?}", a, b);
17}

几个容易忽略的点:

  • Rc::clone(&leaf) 而不是 leaf.clone()。后者也能编译,但会让人误以为发生了深拷贝。社区惯例是显式写 Rc::clone。
  • 计数是普通整数,不是原子的,所以 Rc<T> 不是 Send/Sync。跨线程要用 Arc<T>,代价是每次 clone 一次原子操作。
  • Rc 只提供不可变访问。想改内容,要么 Rc::get_mut(要求计数为 1),要么 Rc::make_mut(写时复制),要么就上 RefCell。

还有一个小陷阱:递归 drop 会爆栈。如果用一个很长的 Rc 链表,最后一个引用释放时会递归调用析构,几万个节点就足以让栈溢出。解决办法是实现 Drop 手动迭代拆链,或者干脆改用 Vec + 索引。

二、RefCell:把借用检查推迟到运行时

RefCell<T> 内部维护一个借用标志:可以同时存在多个 borrow(),或者一个 borrow_mut(),二者不能共存。违反规则不会编译报错,而是直接 panic。

1use std::cell::RefCell;
2
3fn main() {
4    let v = RefCell::new(vec![1, 2, 3]);
5
6    let first = v.borrow()[0];
7    v.borrow_mut().push(4);       // 这里没问题,first 是 Copy 出来的
8    println!("{} {:?}", first, v.borrow());
9}

上面这段是安全的。下面这段就会炸:

1let v = RefCell::new(vec![1, 2, 3]);
2
3for x in v.borrow().iter() {
4    v.borrow_mut().push(*x);      // panic: already borrowed: BorrowMutError
5}

原因是 v.borrow() 返回的 Ref 守卫活在整个 for 循环里,循环体里再申请可变借用就冲突了。修复方式有三种,按推荐程度排序:

1// 1. 先收集,再修改(最清晰)
2let snapshot: Vec<i32> = v.borrow().clone();
3for x in snapshot {
4    v.borrow_mut().push(x);
5}
6
7// 2. 用完立刻释放守卫
8let len = v.borrow().len();
9for i in 0..len {
10    let x = v.borrow()[i];
11    v.borrow_mut().push(x);
12}
13
14// 3. 只是简单计数,用 Cell 就够了
15use std::cell::Cell;
16let hits = Cell::new(0);
17hits.set(hits.get() + 1);

如果不想 panic,可以用 try_borrow() / try_borrow_mut(),它们返回 Result。但在业务代码里,借用冲突通常意味着设计有问题,捕获错误往往只是把问题往后推。

另一个高频场景是方法内部重复借用:

1struct Counter {
2    hits: RefCell<u32>,
3}
4
5impl Counter {
6    fn hit(&self) {
7        *self.hits.borrow_mut() += 1;
8    }
9
10    fn hits(&self) -> u32 {
11        *self.hits.borrow()
12    }
13
14    // 错误:borrow_mut 的守卫没释放就调用了 hits()
15    fn hit_twice_bad(&self) {
16        let mut n = self.hits.borrow_mut();
17        *n += 1;
18        println!("{}", self.hits()); // panic
19    }
20
21    // 正确:显式结束作用域
22    fn hit_twice(&self) {
23        {
24            let mut n = self.hits.borrow_mut();
25            *n += 1;
26        }
27        println!("{}", self.hits());
28    }
29}

排查这类 panic 时,RUST_BACKTRACE=1 很有用,但更重要的是看 panic 信息里的 already borrowed / already mutably borrowed,它直接告诉你是哪一侧的借用没释放。

三、循环引用:Rust 也会内存泄漏

这是 Rc 最隐蔽的问题。Rust 保证内存安全,但不保证不泄漏——泄漏是安全的,只是资源不回收。

典型结构是父子互相持有:父节点用 Rc 存子节点,子节点再用 Rc 指回父节点。这样父的计数永远至少为 1(被子持有),子的计数也永远至少为 1(被父持有),两个都 drop 不掉。

1use std::cell::RefCell;
2use std::rc::{Rc, Weak};
3
4struct Node {
5    name: &'static str,
6    parent: RefCell<Weak<Node>>,
7    children: RefCell<Vec<Rc<Node>>>,
8}
9
10impl Drop for Node {
11    fn drop(&mut self) {
12        println!("drop {}", self.name);
13    }
14}
15
16fn main() {
17    let parent = Rc::new(Node {
18        name: "parent",
19        parent: RefCell::new(Weak::new()),
20        children: RefCell::new(vec![]),
21    });
22
23    let child = Rc::new(Node {
24        name: "child",
25        parent: RefCell::new(Rc::downgrade(&parent)),
26        children: RefCell::new(vec![]),
27    });
28
29    parent.children.borrow_mut().push(Rc::clone(&child));
30
31    println!("parent strong = {}", Rc::strong_count(&parent)); // 1
32    println!("parent weak   = {}", Rc::weak_count(&parent));   // 1
33    println!("child  strong = {}", Rc::strong_count(&child));  // 2
34
35    if let Some(p) = child.parent.borrow().upgrade() {
36        println!("child's parent = {}", p.name);
37    }
38}

关键在于 Weak<T> 不增加强引用计数。Rc::downgrade 得到弱引用,upgrade() 返回 Option<Rc<T>>:对象还活着就拿到 Some,已经被释放就是 None。上例中 parent 离开作用域后计数归零,Drop 正常执行,随后 children 里的 child 也被释放,两个 drop 都会打印出来。

如果把 parent: RefCell<Weak<Node>> 换成 RefCell<Rc<Node>>,程序不会崩,也不报错,但两个 drop 一个都不会打印——这就是泄漏。

实践中的判断标准很简单:方向性关系用 Rc,反向回指用 Weak。树结构里父持有子是 Rc,子回指父是 Weak;观察者模式里被观察者持有观察者是 Rc,观察者回指被观察者用 Weak,顺手还能避免悬垂回调。

四、选型速查

类型共享所有权可变性线程安全检查时机
Box<T>否需要 &mut取决于 T编译期
Rc<T>是否否编译期
Arc<T>是否是编译期
Cell<T>否是(Copy 类型)否无
RefCell<T>否是否运行时
Rc<RefCell<T>>是是否运行时
Arc<Mutex<T>>是是是运行时(锁)

五、几条能省下调试时间的建议

  1. 先问能不能不用共享。很多 Rc<RefCell<T>> 的需求,其实用 Vec<Node> 加 usize 索引(arena 模式)更简单:没有计数、没有借用 panic、没有循环引用,序列化也方便。
  2. 缩小借用作用域。RefCell 的 panic 九成来自守卫活得比预期久,尤其是 if let、match、for 和链式调用里。
  3. 不要长期持有 Ref / RefMut。它们是守卫,不是引用;跨函数传递守卫几乎一定会出问题。
  4. 注意 Rc 不是 Send。想把 Rc<RefCell<T>> 丢进 thread::spawn,编译器会直接拒绝,这不是 bug 而是保护。
  5. 怀疑泄漏时数计数。在关键位置打印 Rc::strong_count 和 Rc::weak_count,比读代码快得多;也可以用 Rc::try_unwrap 验证是否还存在其他持有者。

Rc 和 RefCell 不是「高级用法」,而是所有权模型在共享场景下的必要补充。理解它们的代价——运行时检查、非原子计数、可能的循环引用——比记住 API 更重要。真正写起来,大多数时候更值得做的是回头看看数据结构能不能改成独占加索引,那才是 Rust 最舒服的姿势。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_