
该来的总会来的,所有的智能指针都会面临循环依赖问题,这是一个内在的逻辑性问题。我们在《C++ 智能指针:示例、原理、适用场景全方位解读》一文中系统地介绍过循环引用,有 C/C++ 背景的读者可以参考一下。本文,我们用 Rust 视角来认识一下什么是循环依赖以及它的应对。此外,真诚推荐本博客近期的两篇精华好文《你真得懂 Rust 借用检查吗?一文聊透 Borrow Checker 底层逻辑:权限模型》和《深入理解 Rust 生命周期(可能是全网最透彻的解读了)》
1. 循环引用是如何发生的?
当我们使用共享指针时,是很容易发生循环依赖的:只要两种数据结构相互持有对方的共享指针就很容易形成循环依赖,导致两种数据结构在释放时相互依赖对方先释放,致使强引用计数始终无法归零。我们还是通过示例来看一下吧。继续使用我们在《Rust 语言特性:智能指针 Box<T>》和《Rust 语言特性:共享指针 Rc<T>》中使用过的 cons list 结构,看下面这样一个示例:
use std::rc::Rc;
use std::cell::RefCell;
use crate::List::{Cons, Nil};
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
// 注意:tail 的参数是 List 的一个引用,因此在下面对它进行模式匹配时,
// 编译器会自动进行“引用匹配”,也就是以引用身份代入到模式匹配中,返回的匹配值也自动是
// 引用类型,所以提取出的 item 是 &RefCell<Rc<List>> 类型,而不是 RefCell<Rc<List>> 类型!
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
在代码的第 7 行,定义 List 结构体的地方,我们第一次见到了 RefCell + Rc 的组合,基于前面对它们的单独介绍,把它们组合在一起也不难理解:Rc 赋予了 List 可以拥有多个所有者的能力,这让它可以“拼接”到多个其它的 List 中;RefCell 有赋予了 Rc “内部可变性”,这样可以通过 Rc 修改其包裹的 List 数据。tail() 方法是读取 List 中第二个成员的方法,这在函数式编程常用的“尾递归”中极为常见。
接下来,我们在 main 函数中先构造一个 List:a,它的头元素是 5,尾元素是 Nil,然后再构造一个 List:b,它的头元素是 5,尾元素是 a(这是使用 Rc 包裹后才具有的能力):
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
}
完成上述操作后,a 和 b 两个 List 的结构关系如下:

如果现在,我们把 a 的尾元素从 Nil 改为 b(这是使用 RefCell 包裹后才具有的能力):
if let Some(link) = a.tail() {
// 注意:使用模式匹配提取出的 link 类型是:&RefCell<Rc<List>>,使用它时要先解引用
*link.borrow_mut() = Rc::clone(&b);
}
这时候,原本 a 和 b 都是以 Nil 结尾的,现在,它们的“尾”又都指回了 b 的头,循环引用由此产生:

形成循环依赖以后,在编译阶段是检查不出任何问题的,但是到了运行期,如果我们试图打印一下 a 的尾元素,则程序将陷入无限循环,直至栈溢出。下面是完整的代码:
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack
println!("a next item = {:?}", a.tail());
}
上面的代码还打印了 a 和 b 强引用计数,输出内容如下:
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
a next item = Some(RefCell { value: Cons(10, RefCell { …
我们简单分析一下 a 和 b 强引用计数的变化过程:最初,在第 2 行创建 a 时,只有 a 自己这一个 位于栈上的 Rc 指针,此时它的强引用计数是 1,至第 7 行时,在创建 b 的过程中需要将其尾元素设为 a,故使用了一次 Rc::clone(&a),a 的强引用计数变为 2,b 刚创建,此时它的强引用计数还是 1,进行至第 14 行时,我们把 a 的尾元素替换成了 b,这时需要调用一次 Rc::clone(&b),b 的强引用计数变也为了 2,且 a 和 b 形成了循环依赖。
如果注释掉最后一行的打印语句,不让程序报错,则实际上,a 和 b 将永远无法释放,这已经是一种内存泄漏了。为什么这样的说呢?按照局部变量按逆序销毁的规则,当 a 和 b 离开作用域时,会先尝试销毁 b,b 的 drop 函数会被自动调用,且正常情况下,还会级联调用它所有成员的 drop 函数,也就是 drop glue,但是,对于 Rc 类型来说,它的 drop 函数逻辑是:先让强引用计数减 1,然后判断是不是已经为 0,只有变成了 0 才会继续释放它指向的共享数据,以及级联调用它的成员字段的 drop 函数,对于 b 来说,这些都没有发生,也就是:b 指向的位于堆上的 List 数据没有被清理,a 的 drop 函数也没有被级联调用,整个过程只有位于栈上的 b 指针被销毁了。
同样的情形紧接着发生在了 a 身上,在执行 a 的 drop 函数时,a 的强引用计数从 2 减到 1,由于也不是 0,所以,a 指向的位于堆上的 List 数据没有被清理,b 的 drop 函数也没有被级联调用,整个过程只有位于栈上的 a 指针被销毁了。这样,最后,位于堆上的 a 和 b 两个指针指向的共享数据都错过了最后的清理机会,它们内部的强引用计数都是 1,且在栈上也没有任何再指向它们的 Rc 指针,这就意味着,它们再也没有了“名字”(失去了地址),成为了内存里的“黑户”,它们所占用的内存就实质上被“泄漏”了。
2. 引入 Weak 打破循环依赖
针对上述循环依赖,一种破解的方法就是使用 Weak<T> 去替换 Rc<T>,然后在定义 a 和 b 时依然使用 Rc,只是当把 a 设置给 b 作尾元素时,为了类型匹配,需要通过 Rc::downgrade(&a) 将 a 降级为弱指针 Weak。以下是具体实现:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Weak<List>>),
Nil,
}
use List::{Cons, Nil};
impl List {
fn tail(&self) -> Option<&RefCell<Weak<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Weak::new())));
println!( "a strong count= {}, weak count= {}", Rc::strong_count(&a), Rc::weak_count(&a));
let b = Rc::new(Cons(10, RefCell::new(Rc::downgrade(&a))));
println!( "after b creation, a strong count= {}, weak count= {}", Rc::strong_count(&a), Rc::weak_count(&a));
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::downgrade(&b);
}
println!( "a strong count= {}, weak count= {}", Rc::strong_count(&a), Rc::weak_count(&a));
println!( "b strong count= {}, weak count= {}", Rc::strong_count(&b), Rc::weak_count(&b));
}
程序输出:
a strong count= 1, weak count= 0
after b creation, a strong count= 1, weak count= 1
a strong count= 1, weak count= 1
b strong count= 1, weak count= 1
由于作用了 Weak<T> 替换 Rc<T>, a 和 b 的强引用计数一直是 1,没有升到 2,在离开作用域进行自动析构时,它们的强引用计数都能归 0,进而可以被安全释放。
3. Weak 的典型应用场景
不过,上面的例子并不是展示 Weak<T> 最好的例子,因为 a 和 b 形成的循环依赖是我们人为设置的,并不符合它们的自然关系。通常,介绍 Weak<T> 会使用树形结构作为例子,因为在树形结构中,父节点拥有子节点,而子节点不拥有但可以持有对父节点的“依赖”,这种关系是组合使用 Rc<T> 和 Weak<T> 形成双向关联关系的同时又不会出现循环依赖的典型场景。下面我们就详细了解一下。
考虑一个典型的树形结构,如果我们只是让父节点“拥有”子节点(也就是单向关联关系),则在 Rust 中可以这样定义:
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>
}
使用 Rc 包裹 Node 是因为一个 Node 可能在外部单独创建然后“装配”到树中,所以它很可能有两个以上的 Owner,就像前面我们装配 a 和 b 两个 List 是一样的。使用 RefCell 包裹 Vec 显然是考虑到可能使用一个 Node 的不可变引用去添加 children 节点。但是,很多时候,我们需要建立父子节点的“双向链接”,也就是晃能从一个节点导航到它的子节点,也能从一个节点导航到它的父节点,这就必须得在 Node 中添加对父节点的引用,那这个父节点应使用什么类型呢?
如果父节点声明为 parent: RefCell<Rc<Node>>,则势必会出现循环依赖问题,因为多数节点既是父节点又是子节点,这很容易形成循环依赖。而从另一个角度考虑,子节点也不适合成为父节点的“所有者”,这不符合业务层面上的逻辑关系,所以,最好的选择就是将父节点声明为 parent: RefCell<Weak<Node>>,以下是具体实现:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>
}
以上就是一个非常典型的具有父子双向关系的树形结构,由于 Weak 的引入,避免了循环依赖的发生。最后,我们使用这种 Node 构建一个以 5 根节点,3 为叶节点的简单的树:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
// 将叶节点添加为 children,完成父->子的关联
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// 将 branch 设为叶节点的 parent, 完成子->父的关联
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!(
"branch strong = {}, weak = {}",
Rc::strong_count(&branch),
Rc::weak_count(&branch),
);
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
程序输出:
leaf strong = 1, weak = 0
branch strong = 1, weak = 1
leaf strong = 2, weak = 0
leaf parent = None
leaf strong = 1, weak = 0
上述代码的第 24 – 45 行是一个作用域,在这个作用域中创建了 branch 并将其与 leaf 装配在了一起,在装配好后,离开作用域前,branch 有自己的一个强引用还有一个在 leaf 中作为 parent 的弱引用,而 leaf 有一个自己的一个强引用以及在 branch 中作为 child 的强引用。一旦 45 行结束,branch 会被自动销毁,通过 leaf 找到它的 parent 已经是 None 了,而 leaf 在 branch 中作为 child 的那个强引用计数也被减去了,所以它的强引用计数变成了 1。



