引用循环可能导致内存泄漏
Rust 的内存安全保证使其难以但并非不可能意外创建永远不会被清理的内存(称为 内存泄漏)。完全防止内存泄漏并不是 Rust 的保证之一,这意味着内存泄漏在 Rust 中是内存安全的。我们可以通过使用 Rc<T>
和 RefCell<T>
来看到 Rust 允许内存泄漏:可以创建相互引用的循环引用。这会导致内存泄漏,因为循环中的每个项目的引用计数永远不会达到 0,值永远不会被释放。
创建引用循环
让我们看看引用循环是如何发生的以及如何防止它,从定义 List
枚举和 tail
方法开始,如清单 15-25 所示:
我们正在使用来自清单 15-5 的 List
定义的另一个变体。Cons
变体中的第二个元素现在是 RefCell<Rc<List>>
,这意味着我们不再像在清单 15-24 中那样修改 i32
值,而是希望修改 Cons
变体指向的 List
值。我们还添加了一个 tail
方法,以便在我们有一个 Cons
变体时方便访问第二个元素。
在清单 15-26 中,我们添加了一个 main
函数,该函数使用了清单 15-25 中的定义。这段代码在 a
中创建了一个列表,并在 b
中创建了一个指向 a
中列表的列表。然后它修改了 a
中的列表,使其指向 b
,从而创建了一个引用循环。在此过程中,沿途有 println!
语句显示各个点的引用计数。
我们创建一个 Rc<List>
实例,将一个 List
值存储在变量 a
中,初始列表为 5, Nil
。然后我们创建另一个 Rc<List>
实例,将另一个 List
值存储在变量 b
中,该值包含 10 并指向 a
中的列表。
我们修改a
,使其指向b
而不是Nil
,从而创建一个循环。我们通过使用tail
方法获取a
中的RefCell<Rc<List>>
的引用,将其放入变量link
中。然后我们使用RefCell<Rc<List>>
上的borrow_mut
方法,将内部的值从持有Nil
值的Rc<List>
更改为b
中的Rc<List>
。
当我们运行这段代码时,暂时保留最后一个println!
的注释,我们将得到以下输出:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/cons-list`
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
中的列表更改为指向 b
之后,a
和 b
中的 Rc<List>
实例的引用计数都是 2。在 main
结束时,Rust 会释放变量 b
,这将 b
的 Rc<List>
实例的引用计数从 2 减少到 1。此时,堆上的 Rc<List>
内存不会被释放,因为其引用计数为 1,而不是 0。然后 Rust 会释放 a
,这将 a
的 Rc<List>
实例的引用计数从 2 减少到 1。这个实例的内存也不能被释放,因为另一个 Rc<List>
实例仍然引用它。分配给列表的内存将永远无法回收。为了可视化这个引用循环,我们在图 15-4 中创建了一个图表。
如果您取消注释最后一个 println!
并运行程序,Rust 将尝试打印这个循环,a
指向 b
,b
指向 a
,如此循环直到堆栈溢出。
与现实世界中的程序相比,本例中创建引用循环的后果并不严重:在我们创建引用循环后,程序立即结束。然而,如果一个更复杂的程序在一个循环中分配了大量内存并长时间持有,程序将使用比实际需要更多的内存,可能会使系统不堪重负,导致其耗尽可用内存。
创建引用循环并不容易,但也不是不可能。
如果你有包含 Rc<T>
值的 RefCell<T>
值或类似的具有内部可变性和引用计数的类型的嵌套组合,你必须确保不会创建循环;你不能依赖 Rust 来捕获它们。
创建引用循环将是程序中的逻辑错误,你应该使用自动化测试、代码审查和其他软件开发实践来最小化这种错误。
避免引用循环的另一种方法是重组你的数据结构,使一些引用表达所有权,而一些引用不表达所有权。因此,你可以拥有由一些所有权关系和一些非所有权关系组成的循环,只有所有权关系会影响值是否可以被丢弃。在清单 15-25 中,我们总是希望 Cons
变体拥有它们的列表,因此重组数据结构是不可能的。让我们来看一个使用由父节点和子节点组成的图的例子,看看何时非所有权关系是防止引用循环的适当方法。
防止引用循环:将 Rc<T>
转换为 Weak<T>
到目前为止,我们已经证明调用Rc::clone
会增加Rc<T>
实例的strong_count
,并且只有当Rc<T>
实例的strong_count
为0时,该实例才会被清理。你还可以通过调用Rc::downgrade
并传递Rc<T>
的引用,来创建对Rc<T>
实例中值的弱引用。强引用是你共享Rc<T>
实例所有权的方式。弱引用不表示所有权关系,它们的计数不会影响Rc<T>
实例何时被清理。它们不会导致引用循环,因为一旦涉及的值的强引用计数为0,任何涉及弱引用的循环都会被打破。
当你调用 Rc::downgrade
时,你会得到一个类型为 Weak<T>
的智能指针。而不是将 Rc<T>
实例中的 strong_count
增加 1,调用 Rc::downgrade
会将 weak_count
增加 1。Rc<T>
类型使用 weak_count
来跟踪存在多少个 Weak<T>
引用,类似于 strong_count
。不同之处在于,weak_count
不需要为 0,Rc<T>
实例就可以被清理。
因为 Weak<T>
引用的值可能已经被释放,所以要对 Weak<T>
指向的值进行任何操作,必须确保该值仍然存在。通过调用 Weak<T>
实例上的 upgrade
方法来实现这一点,该方法将返回一个 Option<Rc<T>>
。如果 Rc<T>
值尚未被释放,你将得到一个 Some
结果;如果 Rc<T>
值已被释放,你将得到一个 None
结果。因为 upgrade
返回一个 Option<Rc<T>>
,Rust 将确保处理 Some
情况和 None
情况,而不会出现无效指针。
例如,我们将创建一个树,其项目不仅知道它们的子项目还知道它们的父项目。
Creating a Tree Data Structure: a Node
with Child Nodes
首先,我们将构建一个节点知道其子节点的树。
我们将创建一个名为 Node
的结构体,该结构体持有其自身的 i32
值以及
对其子 Node
值的引用:
文件名: src/main.rs
use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] struct Node { value: i32, children: RefCell<Vec<Rc<Node>>>, } fn main() { let leaf = Rc::new(Node { value: 3, children: RefCell::new(vec![]), }); let branch = Rc::new(Node { value: 5, children: RefCell::new(vec![Rc::clone(&leaf)]), }); }
我们希望一个 Node
拥有其子节点,并且我们希望与变量共享该所有权,以便我们可以直接访问树中的每个 Node
。为此,我们将 Vec<T>
项定义为 Rc<Node>
类型的值。我们还希望修改哪些节点是另一个节点的子节点,因此我们在 children
中使用了 RefCell<T>
包裹 Vec<Rc<Node>>
。
接下来,我们将使用我们的结构体定义,创建一个名为 leaf
的 Node
实例,其值为 3 且没有子节点,以及另一个名为 branch
的实例,其值为 5 且 leaf
是其子节点之一,如清单 15-27 所示:
我们在 leaf
中克隆 Rc<Node>
并将其存储在 branch
中,这意味着 leaf
中的 Node
现在有两个所有者:leaf
和 branch
。我们可以通过 branch.children
从 branch
到达 leaf
,但无法从 leaf
到达 branch
。原因是 leaf
没有对 branch
的引用,不知道它们是相关的。我们希望 leaf
知道 branch
是它的父节点。我们将在下一步实现这一点。
Adding a Reference from a Child to Its Parent
为了使子节点知道其父节点,我们需要在 Node
结构体定义中添加一个 parent
字段。问题在于决定 parent
的类型。我们知道它不能包含一个 Rc<T>
,因为这会创建一个引用循环,leaf.parent
指向 branch
而 branch.children
指向 leaf
,这会导致它们的 strong_count
值永远不会为 0。
思考这些关系的另一种方式是,父节点应该拥有其子节点:如果父节点被丢弃,其子节点也应该被丢弃。然而,子节点不应该拥有其父节点:如果我们丢弃一个子节点,父节点仍然应该存在。这是一个使用弱引用的情况!
所以我们将 parent
的类型改为使用 Weak<T>
,具体来说是一个 RefCell<Weak<Node>>
。现在我们的 Node
结构体定义如下:
文件名: src/main.rs
use std::cell::RefCell; use std::rc::{Rc, Weak}; #[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 parent = {:?}", leaf.parent.borrow().upgrade()); let branch = Rc::new(Node { value: 5, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![Rc::clone(&leaf)]), }); *leaf.parent.borrow_mut() = Rc::downgrade(&branch); println!("leaf parent = {:?}", leaf.parent.borrow().upgrade()); }
一个节点将能够引用其父节点,但不拥有其父节点。
在清单 15-28 中,我们更新 main
以使用这个新定义,这样 leaf
节点将有一种方法引用其父节点 branch
:
创建leaf
节点看起来与清单15-27相似,例外是parent
字段:leaf
一开始没有父节点,所以我们创建一个新的、空的Weak<Node>
引用实例。
此时,当我们尝试通过使用 upgrade
方法获取 leaf
的父节点的引用时,我们得到了一个 None
值。我们在第一个 println!
语句的输出中看到了这一点:
leaf parent = None
当我们创建 branch
节点时,它在 parent
字段中也会有一个新的 Weak<Node>
引用,因为 branch
没有父节点。
我们仍然有 leaf
作为 branch
的一个子节点。一旦我们在 branch
中有了
Node
实例,我们就可以修改 leaf
以给它一个指向其父节点的 Weak<Node>
引用。我们在 leaf
的 parent
字段中的 RefCell<Weak<Node>>
上使用
borrow_mut
方法,然后使用 Rc::downgrade
函数从 branch
中的
Rc<Node>
创建一个指向 branch
的 Weak<Node>
引用。
当我们再次打印 leaf
的父节点时,这次我们将得到一个包含 branch
的 Some
变体:现在 leaf
可以访问其父节点了!当我们打印 leaf
时,我们也避免了最终导致堆栈溢出的循环,就像我们在清单 15-26 中遇到的那样;Weak<Node>
引用被打印为 (Weak)
:
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })
缺乏无限输出表明这段代码没有创建引用循环。我们也可以通过查看调用Rc::strong_count
和Rc::weak_count
得到的值来判断这一点。
Visualizing Changes to strong_count
and weak_count
让我们看看通过创建一个新的内部作用域并将 branch
的创建移到该作用域中,Rc<Node>
实例的 strong_count
和 weak_count
值如何变化。通过这样做,我们可以看到当 branch
被创建然后在超出作用域时被销毁时会发生什么。修改内容如清单 15-29 所示:
在创建 leaf
之后,其 Rc<Node>
的强引用计数为 1,弱引用计数为 0。在内部作用域中,我们创建了 branch
并将其与 leaf
关联,此时当我们打印计数时,branch
中的 Rc<Node>
将具有强引用计数 1 和弱引用计数 1(因为 leaf.parent
通过 Weak<Node>
指向 branch
)。当我们打印 leaf
的计数时,我们会看到它的强引用计数为 2,因为 branch
现在在 branch.children
中存储了 leaf
的 Rc<Node>
的一个克隆,但弱引用计数仍为 0。
当内部作用域结束时,branch
超出作用域,Rc<Node>
的强引用计数减少到 0,因此其 Node
被释放。来自 leaf.parent
的弱引用计数 1 不会影响 Node
是否被释放,所以我们不会有任何内存泄漏!
如果我们尝试在作用域结束后访问 leaf
的父节点,我们将再次得到 None
。在程序结束时,leaf
中的 Rc<Node>
的强引用计数为 1,弱引用计数为 0,因为变量 leaf
现在再次成为指向 Rc<Node>
的唯一引用。
所有管理计数和值释放的逻辑都内置在 Rc<T>
和 Weak<T>
以及它们的 Drop
特性实现中。通过在 Node
的定义中指定从子节点到其父节点的关系应为 Weak<T>
引用,您可以在父节点和子节点之间相互指向,而不会创建引用循环和内存泄漏。
摘要
本章介绍了如何使用智能指针来做出与 Rust 默认使用常规引用时不同的保证和权衡。Box<T>
类型具有已知大小,并指向堆上分配的数据。Rc<T>
类型跟踪堆上数据的引用数量,以便数据可以有多个所有者。RefCell<T>
类型及其内部可变性使我们可以在需要不可变类型但需要更改该类型内部值时使用;它还在运行时而不是编译时强制执行借用规则。
还讨论了Deref
和Drop
特征,它们使智能指针的许多功能得以实现。我们探讨了可能导致内存泄漏的引用循环以及如何使用Weak<T>
来防止它们。
如果本章引起了你的兴趣,并且你想要实现自己的智能指针,可以查阅“Rustonomicon”获取更多有用的信息。
接下来,我们将讨论 Rust 中的并发。你甚至会学到一些新的智能指针。