引用循环可能会泄露内存

Reference Cycles Can Leak Memory

Rust 的内存安全保证,使得意外创建出从未清理过的内存(称为 内存泄漏,memory leak)很难,但并非不可能。完全防止内存泄漏不是 Rust 的保证之一,这意味着内存泄漏在 Rust 中是内存安全的。通过使用 Rc<T>RefCell<T>,我们可以看到 Rust 允许内存泄漏:创建出其中项目在循环中相互指向的引用是可能的。这会造成内存泄漏,因为循环中各个项目的引用计数永远不会达到 0,而值永远不会被弃用。

创建引用循环

Creaing a Reference Cycle

咱们以清单 15-25 中的 List 枚举和 tail 方法开始,来看看循环引用是如何发生的,以及怎样防止他:

文件名:src/main.rs

#![allow(unused)]
fn main() {
{{#include ../projects/ref_cycle_demo/src/main.rs::18}}
}

清单 15-25:包含 RefCell<T> 的构造列表定义,因此我们可以修改 Cons 变种指向的内容

我们正在使用清单 15-5List 定义的另一种变体。 Cons 变种中的第二个元素现在是 RefCell<Rc<List>>,这意味着我们不像在示例 15-24 中那样能够修改 i32 值,我们打算修改 Cons 变种指向的 List 值。我们还添加了一个 tail 方法,以便在我们有 Cons 变种时方便地访问第二个项目。

在下面清单 15-26 中,咱们添加了用到清单 15-25 中那些定义的 main 函数。此代码创建了变量 a 中的一个清单,以及变量 b 中指向 a 中清单的一个清单。随后他将 a 中的清单指向了 b,这就创建了一个循环引用。其间有着一些 println! 语句,来显示此过程中不同点位的那些引用计数。

文件名:src/main.rs

#![allow(unused)]
fn main() {
{{#include ../projects/ref_cycle_demo/src/main.rs:20:}}
}

清单 15-26:创建出相互指向的两个 List 的循环引用

我们创建一个 Rc<List> 实例,在变量 a 中持有一个 List 值,初始列表为 5, Nil。然后我们创建一个 Rc<List> 实例,在变量 b 中保存另一个 List 值,其中包含值 10,并指向 a 中的列表。

我们修改 a 使其指向 b 而不是 Nil,从而创建一个循环。为此,我们使用 tail 方法获取对 aRefCell<Rc<List>> 的引用,我们将其放入变量 link 中。然后我们使用 RefCell<Rc<List>> 上的 borrow_mut 方法,将里面的值从一个持有 Nil 值的 Rc<List> 更改为 b 中的 Rc<List>

咱们暂时保持最后的 println! 注释掉,而运行此代码时,咱们将得到下面的输出:

$ cargo run
   Compiling ref_cycle_demo v0.1.0 (/home/lennyp/rust-lang/ref_cycle_demo)
    Finished dev [unoptimized + debuginfo] target(s) in 1.20s
     Running `target/debug/ref_cycle_demo`
a 的初始 rc 计数 = 1
a 的下一条目 = Some(RefCell { value: Nil })
b 的创建后 a 的 rc 计数 = 2
b 的初始 rc 计数 = 1
b 的下一条目 = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
在修改 a 之后 b 的 rc 计数 = 2
在修改 a 之后 a 的 rc 计数 = 2

在咱们将 a 中的列表改为指向 b 后,ab 中的 Rc<List> 实例的引用计数均为 2。在 main 的最后,Rust 弃用了变量 b,这使得 b 中的 Rc<List> 实例的引用计数从 2 减少到 1Rc<List> 在内存堆中的内存此时不会被弃用,因为其引用计数为 1 而不是 0。然后 Rust 弃用 a,将 a 中的 Rc<List> 实例的引用计数从 2 减少到 1。由于另一 Rc<List> 实例仍指向他,因此该实例的内存也不能被弃用。分配给列表内存将永远保持未被收集的状态。为直观地表示这个引用循环,咱们创建了下图 15-4 中的图表。

相互指向的列表 a 与 b 的一个循环引用

图 15-04:列表 ab 相互指向的引用循环

如果咱们取消对最后一个 println! 的注释并运行程序,Rust 会尝试打印这个循环,a 指向 b 指向 a,如此反复,直到程序溢出栈,overflows the stack。

与现实世界的程序相比,在这个例子中创建一个引用循环的后果并不可怕:在我们创建引用循环之后,程序就结束了。然而,如果某个更复杂的程序在一个循环中分配了大量的内存,并长期占用这些内存,那么这个程序将使用比其需要的更多的内存,并可能使系统不堪重负,导致其可用内存耗尽。

创建引用循环不容易做到,但也不是不可能。如果咱们有着其中包含 Rc<T> 值的 RefCell<T> 值,或类似的带有内部可变性与引用计数类型的嵌套组合,咱们必须确保不创建循环;咱们不能依靠 Rust 来捕获他们。创建引用循环将是咱们程序中的逻辑错误,咱们应该使用自动测试、代码审查和其他软件开发实践来减少这种错误。

另一避免引用循环的办法是重新组织咱们的数据结构,使一些引用表达所有权,而一些引用不表达。由此,咱们可以有由一些所有权关系和一些非所有权关系组成的循环,而只有所有权关系会影响一个值是否可以被丢弃。在清单 15-25 中,我们总是希望 Cons 变体拥有他们的列表,所以重新组织数据结构是不可能的。咱们来看一个使用由父节点和子节点组成的图的示例,看看什么时候非所有权关系是防止引用循环的合适方式。

防止引用循环:将 Rc<T> 变为 Weak<T>

Preventing Referencing Cycles: Turning an Rc<T> into Weak<T>

到目前为止,我们已经证明了调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,而 Rc<T> 实例只有在其 strong_count0 时才会被清理掉。咱们还可以通过调用 Rc::downgrade 并传递对 Rc<T> 的引用,来创建对 Rc<T> 实例中值的 弱引用,weak reference。强引用是咱们共用 Rc<T> 实例所有权的方式。弱引用不表达所有权关系,他们的计数不会影响 Rc<T> 实例被清理的时间。他们不会引起引用循环,因为任何涉及弱引用的循环,都会在所涉及的值的强引用计数为 0 时被打破。

当咱们调用 Rc::downgrade 时,咱们会得到一个 Weak<T> 类型的灵巧指针。调用 Rc::downgrade 不是将 Rc<T> 实例中的 strong_count 增加 1,而是将 weak_count 增加 1。与 strong_count 类似,Rc<T> 类型使用 weak_count 来记录存在多少个 Weak<T> 引用。不同的是,在 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会确保 SomeNone 的情况都得到处理,而且不会出现无效的指针。

作为一个例子,我们不是使用其项目只知道下一项目的列表数据结构,而是将创建一个其项目了解其子项目 其父项目的树。

创建一个树形数据结构:带有子节点的节点

Creating a Tree Data Structure: a Node with Child Nodes

首先,我们将构建一棵树,其中的节点知道他们的子节点。我们将创建一个名为 Node 的结构体,他拥有自己的 i32 值以及对其子 Node 值的引用:

文件名:src/main.rs

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}
}

我们希望 Node 拥有他的子节点,并且我们希望与变量共用该所有权,以便咱们可以直接访问树中的每个 Node。为此,我们将 Vec<T> 项定义为 Rc<Node> 类型的值。我们还打算修改那些是另一节点的子节点的节点,因此我们在 Vec<Rc<Node>> 周围、children 字段中有一个 RefCell<T>

接下来,我们将使用我们的结构体定义,创建一个名为 leafNode 实例,其值为 3,没有子节点;另一个名为 branch 的实例,其值为 5leaf 是其子节点之一,如下清单 15-27 所示:

文件名:src/main.rs

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 现在有两个所有者:leafbranch。我们可以通过 branch.childrenbranch 获取到 leaf,但是没有办法从 leaf 获取到 branch。原因是 leaf 没有对 branch 的引用,不知道他们之间的关系。我们想让 leaf 知道 branch 是他的父节点。下一步我们将这样做。

在子节点中添加到其父节点的引用

Adding a Reference from a Child to Its Parent

为了让子节点知道他的父节点,我们需要在我们的 Node 结构体定义中添加一个父节点字段。问题在于确定出父节点的类型是什么。我们知道他不能包含一个 Rc<T>,因为这将创建一个引用循环,即 leaf.parent 指向 branch,而 branch.children 指向 leaf,这将导致他们的 strong_count 值永远为 0。

以另一种方式思考这些关系,一个父节点应该拥有他的子节点:如果一个父节点被弃用,他的子节点也应该被弃用。然而,一个子节点不应该拥有他的父节点:如果我们弃用某个子节点,父节点应该仍然存在。这就是弱引用的情况!

因此,我们将使用 Weak<T> 代替 Rc<T>,具体来说是 RefCell<Weak<Node>>。现在我们的节点结构定义如下所示:

文件名:src/main.rs

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}
}

节点将能够引用其父节点但不拥有其父节点。在下面清单 15-28 中,我们更新了 main 以使用这个新定义,这样 leaf 节点将有办法引用其父节点 branch

文件名:src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec! []),
    });

    println! ("叶子节点的父节点 = {:?}", 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.borrow().upgrade());
}

清单 15-28:对其父节点 branch 有弱引用的 leaf 节点

创建 leaf 节点看起来与清单 15-27 相似,除了父字段:leaf 开始时没有父节点,所以我们创建一个新的、空的 Weak<Node> 引用实例。

此时,当我们试图通过使用 upgrade 方法来获得对 leaf 的父节点的引用时,我们得到的是一个 None 值。我们在第一个 println! 语句的输出中看到了这一点:

叶子节点的父节点 = None

当我们创建 branch 节点时,他在 parent 字段中也会有一个新的 Weak<Node> 引用,因为 branch 没有父节点。我们仍然将 leaf 作为 branch 的子节点之一。一旦我们在 branch 中有了 Node 实例,我们就可以修改 leaf 来给他一个到其父节点的 Weak<Node> 引用。我们在 leafparent 字段中的 RefCell<Weak<Node> 上使用 borrow_mut 方法,然后我们使用 Rc::downgrade 函数从 branch 中的 Rc<Node> 创建一个对 branchWeak<Node> 引用。

当我们再次打印 leaf 的父节点时,这次我们会得到一个持有 branchSome 变体:现在 leaf 可以访问他的父节点了! 当我们打印 leaf 时,我们也避免了像清单 15-26 中那样最终以栈溢出结束的循环;Weak<Node> 引用被打印为 (Weak)

叶子节点的父节点 = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

没有无限的输出表明这段代码没有创建引用循环。我们也可以通过查看调用 Rc::strong_countRc::weak_count 得到的值来判断这一点。

可视化 strong_countweak_count 的变化

Visualizing Changes to strong_count and weak_count

咱们来看看通过创建一个新的内部作用域并将 branch 的创建移到该作用域中,Rc<Node> 实例的 strong_countweak_count 值如何变化。通过这样做,我们可以看到在 branch 被创建后,当他离开作用域时,会发生什么。修改部分如下清单 15-29 所示:

文件名:src/main.rs

#![allow(unused)]
fn main() {
{{#include ../projects/tree_demo/src/main.rs:11:}}
}

清单 15-29:在内层作用域中创建 branch 并检查强引用和弱引用计数

leaf 创建后,其 Rc<Node>strong count1weak count0

在内层作用域中,我们创建了 branch 并将其与 leaf 关联,此时当我们打印计数时, branch 中的 Rc<Node> 将有 1 的强计数和 1 的弱计数(因为 leaf.parent 指向 branch 的是 Weak<Node>)。当我们打印 leaf 中的计数时,我们将看到他的强计数为 2,因为 branch 现在有一个保存在 branch.children 中的 leafRc<Node> 的克隆,但仍然会有一个弱计数为 0

当内层作用域结束时,branch 超出作用域,Rc<Node> 的强计数减少到 0,所以他的 Node 被弃用。来自 leaf.parent 的弱计数 1Node 是否被弃用没有影响,所以我们没有任何内存泄露!

如果我们在作用域结束后试图访问 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> 类型及其内部可变性为我们提供了一种类型,当我们需要不可变类型但需要改变该类型的内部值时,我们可以使用这种类型;他还在运行时而不是在编译时强制执行借用规则。

我们还讨论了 DerefDrop 特质,这两个特质实现了灵巧指针的很多功能。我们探讨了可能导致内存泄露的引用循环以及如何使用 Weak<T> 来防止它们。

如果本章引起了你的兴趣,并且你想实现你自己的智能指针,请查看 "The Rustonomicon" ,以获得更多有用的信息。

接下来,我们将讨论 Rust 中的并发问题。咱们甚至会了解到一些新的灵巧指针。

Last change: 2023-12-01, commit: 71a00fa