引用循环可能导致内存泄漏

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

创建引用循环

让我们看一下引用循环是如何发生的,以及如何防止它,首先从 Listing 15-25 中的 List 枚举和 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() {}

Listing 15-25: 一个 cons list 定义,它持有一个 RefCell<T>,以便我们可以修改 Cons 变体指向的内容

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

在 Listing 15-26 中,我们添加了一个 main 函数,它使用了 Listing 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());
}

Listing 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 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 之后,abRc<List> 实例的引用计数都为 2。在 main 函数结束时,Rust drop 变量 b,这会将 b Rc<List> 实例的引用计数从 2 减少到 1。此时,Rc<List> 在堆上占用的内存不会被 drop,因为其引用计数为 1,而不是 0。然后 Rust drop a,这也会将 a Rc<List> 实例的引用计数从 2 减少到 1。此实例的内存也无法被 drop,因为另一个 Rc<List> 实例仍然引用它。分配给列表的内存将永远保持未回收状态。为了可视化此引用循环,我们在图 15-4 中创建了一个图示。

Reference cycle of lists

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

如果您取消注释最后一个 println! 并运行程序,Rust 将尝试打印此循环,其中 a 指向 bb 指向 a,依此类推,直到堆栈溢出。

与真实世界的程序相比,在此示例中创建引用循环的后果并不是很可怕:在我们创建引用循环后,程序立即结束。但是,如果一个更复杂的程序在一个循环中分配了大量内存并长时间持有它,则该程序将使用比它需要的更多的内存,并可能使系统不堪重负,导致其耗尽可用内存。

创建引用循环并非易事,但也不是不可能。如果您有包含 Rc<T> 值或具有内部可变性和引用计数的类似嵌套类型组合的 RefCell<T> 值,则必须确保您不会创建循环;您不能依赖 Rust 来捕获它们。创建引用循环将是程序中的逻辑错误,您应该使用自动化测试、代码审查和其他软件开发实践来最大限度地减少它。

避免引用循环的另一种解决方案是重新组织数据结构,使某些引用表达所有权,而某些引用不表达所有权。因此,您可以拥有由一些所有权关系和一些非所有权关系组成的循环,只有所有权关系会影响值是否可以被 drop。在 Listing 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::downgrade 不是将 Rc<T> 实例中的 strong_count 增加 1,而是将 weak_count 增加 1。Rc<T> 类型使用 weak_count 来跟踪存在多少 Weak<T> 引用,类似于 strong_count。区别在于 weak_count 不需要为 0 才能清理 Rc<T> 实例。

因为 Weak<T> 引用的值可能已被 drop,要对 Weak<T> 指向的值执行任何操作,您必须确保该值仍然存在。通过调用 Weak<T> 实例上的 upgrade 方法来执行此操作,这将返回一个 Option<Rc<T>>。如果 Rc<T> 值尚未被 drop,您将得到 Some 结果,如果 Rc<T> 值已被 drop,则得到 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>

接下来,我们将使用我们的结构体定义,创建一个名为 leafNode 实例,其值为 3 且没有子节点,以及另一个名为 branch 的实例,其值为 5,并将 leaf 作为其子节点之一,如 Listing 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)]),
    });
}

Listing 15-27: 创建一个没有子节点的 leaf 节点和一个以 leaf 作为其子节点之一的 branch 节点

我们克隆 leaf 中的 Rc<Node> 并将其存储在 branch 中,这意味着 leaf 中的 Node 现在有两个所有者:leafbranch。我们可以通过 branch.childrenbranchleaf,但无法从 leafbranch。原因是 leaf 没有对 branch 的引用,也不知道它们是相关的。我们希望 leaf 知道 branch 是它的父节点。我们将在下一步执行此操作。

添加从子节点到其父节点的引用

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

从另一个角度思考关系,父节点应该拥有其子节点:如果父节点被 drop,其子节点也应该被 drop。但是,子节点不应拥有其父节点:如果我们 drop 子节点,父节点应仍然存在。这是弱引用的用例!

因此,我们将使 parent 的类型使用 Weak<T>,而不是 Rc<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());
}

节点将能够引用其父节点,但不拥有其父节点。在 Listing 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());
}

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

创建 leaf 节点看起来与 Listing 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> 引用。我们在 leafparent 字段中的 RefCell<Weak<Node>> 上使用 borrow_mut 方法,然后我们使用 Rc::downgrade 函数从 branch 中的 Rc<Node> 创建对 branchWeak<Node> 引用。

当我们再次打印 leaf 的父节点时,这次我们将得到一个持有 branchSome 变体:现在 leaf 可以访问其父节点了!当我们打印 leaf 时,我们也避免了 Listing 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_countRc::weak_count 获取的值来判断这一点。

可视化 strong_countweak_count 的更改

让我们通过创建一个新的内部作用域并将 branch 的创建移入该作用域,来查看 Rc<Node> 实例的 strong_countweak_count 值如何变化。通过这样做,我们可以看到当 branch 被创建并在超出作用域时被 drop 时会发生什么。修改如 Listing 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),
    );
}

Listing 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 中存储了 leafRc<Node> 的克隆,但仍将具有弱计数 0。

当内部作用域结束时,branch 超出作用域,Rc<Node> 的强计数减少到 0,因此其 Node 被 drop。来自 leaf.parent 的弱计数 1 对 Node 是否被 drop 没有影响,因此我们不会得到任何内存泄漏!

如果我们在作用域结束后尝试访问 leaf 的父节点,我们将再次得到 None。在程序结束时,leaf 中的 Rc<Node> 的强计数为 1,弱计数为 0,因为变量 leaf 现在是 Rc<Node> 的唯一引用。

管理计数和值 drop 的所有逻辑都内置在 Rc<T>Weak<T> 及其 Drop trait 的实现中。通过在 Node 的定义中指定从子节点到其父节点的关系应该是 Weak<T> 引用,您可以使父节点指向子节点,反之亦然,而不会创建引用循环和内存泄漏。

总结

本章介绍了如何使用智能指针来做出与 Rust 默认使用常规引用不同的保证和权衡。Box<T> 类型具有已知大小并指向堆上分配的数据。Rc<T> 类型跟踪对堆上数据的引用数量,以便数据可以有多个所有者。具有内部可变性的 RefCell<T> 类型为我们提供了一种类型,当我们需要一个不可变类型但需要更改该类型的内部值时可以使用它;它还在运行时而不是在编译时强制执行借用规则。

还讨论了 DerefDrop trait,它们启用了智能指针的许多功能。我们探讨了可能导致内存泄漏的引用循环,以及如何使用 Weak<T> 防止它们。

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

接下来,我们将讨论 Rust 中的并发性。您甚至会了解一些新的智能指针。