引用循环可能导致内存泄漏
Rust 的内存安全保证使其难以(但并非不可能)意外地创建永远不会被清理的内存(称为内存泄漏)。完全防止内存泄漏并非 Rust 的保证之一,这意味着内存泄漏在 Rust 中是内存安全的。我们可以看到 Rust 允许内存泄漏,通过使用 Rc<T>
和 RefCell<T>
:可以创建循环引用,其中项目相互引用。这会导致内存泄漏,因为循环中每个项目的引用计数永远不会达到 0,并且这些值永远不会被丢弃。
创建引用循环
让我们看看引用循环是如何发生的以及如何防止它,从 List
枚举的定义和清单 15-25 中的 tail
方法开始
文件名: src/main.rs
use crate::List::{Cons, Nil}; use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] enum List { Cons(i32, RefCell<Rc<List>>), Nil, } impl List { fn tail(&self) -> Option<&RefCell<Rc<List>>> { match self { Cons(_, item) => Some(item), Nil => None, } } } fn main() {}
清单 15-25:一个 cons 列表定义,它持有 RefCell<T>
,以便我们可以修改 Cons
变体所引用的内容
我们正在使用清单 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!
语句来显示此过程中各个点的引用计数。
文件名: src/main.rs
use crate::List::{Cons, Nil}; use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] enum List { Cons(i32, RefCell<Rc<List>>), Nil, } impl List { fn tail(&self) -> Option<&RefCell<Rc<List>>> { match self { Cons(_, item) => Some(item), Nil => None, } } } 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()); }
清单 15-26:创建两个 List
值相互指向的引用循环
我们创建一个 Rc<List>
实例,在变量 a
中保存一个初始列表为 5, Nil
的 List
值。然后,我们创建一个 Rc<List>
实例,在变量 b
中保存另一个 List
值,该值包含值 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 中创建了一个图表。
图 15-4:列表 a
和 b
相互指向的引用循环
如果取消注释最后一个 println!
并运行程序,Rust 将尝试打印此循环,其中 a
指向 b
,b
指向 a
,依此类推,直到堆栈溢出。
与真实程序相比,在此示例中创建引用循环的后果不是很严重:在我们创建引用循环后,程序立即结束。但是,如果一个更复杂的程序在循环中分配了大量内存并长时间持有它,则该程序将使用比它需要的更多的内存,并且可能会使系统不堪重负,导致其耗尽可用内存。
创建引用循环并非易事,但也不是不可能。如果您的 RefCell<T>
值包含 Rc<T>
值或类似的具有内部可变性和引用计数的类型的嵌套组合,则必须确保不创建循环;您不能依赖 Rust 来捕获它们。创建引用循环将是您程序中的逻辑错误,您应该使用自动化测试、代码审查和其他软件开发实践来尽量减少这种情况。
避免引用循环的另一种解决方案是重新组织您的数据结构,以便某些引用表达所有权,而某些引用不表达所有权。因此,您可以拥有由某些所有权关系和某些非所有权关系组成的循环,只有所有权关系会影响是否可以丢弃值。在清单 15-25 中,我们始终希望 Cons
变体拥有其列表,因此重新组织数据结构是不可能的。让我们看一个使用由父节点和子节点组成的图表的示例,以了解何时使用非所有权关系是防止引用循环的合适方法。
防止引用循环:将 Rc<T>
转换为 Weak<T>
到目前为止,我们已经证明调用 Rc::clone
会增加 Rc<T>
实例的 strong_count
,并且仅当其 strong_count
为 0 时才会清理 Rc<T>
实例。您还可以通过调用 Rc::downgrade
并传递对 Rc<T>
的引用来创建对 Rc<T>
实例内值的弱引用。强引用是您共享 Rc<T>
实例所有权的方式。弱引用不表达所有权关系,并且它们的计数不影响何时清理 Rc<T>
实例。它们不会导致引用循环,因为一旦涉及值的强引用计数为 0,任何涉及一些弱引用的循环都将被打破。
当您调用 Rc::downgrade
时,您会获得类型为 Weak<T>
的智能指针。调用 Rc::downgrade
不会使 Rc<T>
实例中的 strong_count
增加 1,而是使 weak_count
增加 1。Rc<T>
类型使用 weak_count
来跟踪存在多少 Weak<T>
引用,类似于 strong_count
。区别在于 Rc<T>
实例要被清理,weak_count
不需要为 0。
由于 Weak<T>
引用的值可能已被丢弃,要对 Weak<T>
指向的值执行任何操作,您必须确保该值仍然存在。通过在 Weak<T>
实例上调用 upgrade
方法来执行此操作,该方法将返回 Option<Rc<T>>
。如果 Rc<T>
值尚未被丢弃,您将获得 Some
的结果,如果 Rc<T>
值已被丢弃,您将获得 None
的结果。由于 upgrade
返回 Option<Rc<T>>
,Rust 将确保处理 Some
和 None
的情况,并且不会有无效的指针。
例如,我们不会使用仅知道下一个项目的列表,而是创建一个知道其子项目和其父项目的树。
创建树数据结构:带有子节点的 Node
首先,我们将构建一个节点知道其子节点的树。我们将创建一个名为 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>
类型的值。我们还想修改哪些节点是另一个节点的子节点,因此我们在 Vec<Rc<Node>>
周围的 children
中有一个 RefCell<T>
。
接下来,我们将使用我们的结构定义,创建一个名为 leaf
的 Node
实例,其值为 3 且没有子节点,并创建一个名为 branch
的实例,其值为 5 且 leaf
作为其子节点之一,如清单 15-27 所示
文件名: 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)]), }); }
清单 15-27:创建一个没有子节点的 leaf
节点和一个以 leaf
作为其子节点之一的 branch
节点
我们克隆 leaf
中的 Rc<Node>
并将其存储在 branch
中,这意味着 leaf
中的 Node
现在有两个所有者:leaf
和 branch
。我们可以通过 branch.children
从 branch
到 leaf
,但无法从 leaf
到 branch
。原因是 leaf
没有对 branch
的引用,并且不知道它们是相关的。我们希望 leaf
知道 branch
是其父级。我们接下来将执行此操作。
添加从子级到其父级的引用
为了使子节点知道其父节点,我们需要向我们的 Node
结构定义添加一个 parent
字段。问题在于决定 parent
的类型应该是什么。我们知道它不能包含 Rc<T>
,因为这将创建引用循环,其中 leaf.parent
指向 branch
,branch.children
指向 leaf
,这将导致它们的 strong_count
值永远不为 0。
以另一种方式考虑关系,父节点应该拥有其子节点:如果父节点被丢弃,其子节点也应该被丢弃。但是,子节点不应该拥有其父节点:如果我们丢弃子节点,父节点仍然应该存在。这是一种弱引用的情况!
因此,我们将使用 Weak<T>
而不是 Rc<T>
来使 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
的方法
文件名: 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:一个 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
得到的值来判断。
可视化strong_count
和weak_count
的变化
让我们看看通过创建一个新的内部作用域并将branch
的创建移动到该作用域中,Rc<Node>
实例的strong_count
和weak_count
值是如何变化的。通过这样做,我们可以看到当branch
被创建,然后在超出作用域时被丢弃会发生什么。修改后的代码如列表 15-29 所示
文件名: 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 strong = {}, weak = {}", Rc::strong_count(&leaf), Rc::weak_count(&leaf), ); { 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!( "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), ); }
列表 15-29:在内部作用域中创建branch
并检查强引用计数和弱引用计数
创建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
trait 的实现中。通过在Node
的定义中指定从子节点到其父节点的关系应该是一个Weak<T>
引用,你就可以让父节点指向子节点,反之亦然,而不会创建引用循环和内存泄漏。
总结
本章介绍了如何使用智能指针来做出与 Rust 使用常规引用时的默认行为不同的保证和权衡。Box<T>
类型具有已知的大小,并指向堆上分配的数据。Rc<T>
类型会跟踪堆上数据的引用数量,以便数据可以有多个所有者。具有内部可变性的RefCell<T>
类型为我们提供了一种类型,当我们需要一个不可变的类型但需要更改该类型的内部值时可以使用;它还在运行时而不是在编译时强制执行借用规则。
还讨论了Deref
和Drop
trait,它们启用了智能指针的许多功能。我们探讨了可能导致内存泄漏的引用循环,以及如何使用Weak<T>
来防止它们。
如果本章引起了您的兴趣,并且您想实现自己的智能指针,请查看“Rustonomicon”以获取更多有用的信息。
接下来,我们将讨论 Rust 中的并发性。你甚至会了解一些新的智能指针。