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

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「引用循环可能导致内存泄漏」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探讨 Rust 的内存安全保障如何使得意外创建内存泄漏变得困难但并非不可能,特别是在使用 Rc<T>RefCell<T> 时,这可能会导致引用循环,从而阻止值被释放,进而导致内存泄漏。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/integer_types -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/function_syntax -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/expressions_statements -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/method_syntax -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/traits -.-> lab-100436{{"引用循环可能导致内存泄漏"}} rust/operator_overloading -.-> lab-100436{{"引用循环可能导致内存泄漏"}} end

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

Rust 的内存安全保障使得意外创建永远不会被清理的内存(即所谓的「内存泄漏」)变得困难,但并非不可能。完全防止内存泄漏并非 Rust 的保障之一,这意味着在 Rust 中内存泄漏是内存安全的。我们可以通过使用 Rc<T>RefCell<T> 来看到 Rust 允许内存泄漏:有可能创建循环引用,即对象之间相互引用。这会导致内存泄漏,因为循环中每个对象的引用计数永远不会达到 0,这些值也永远不会被释放。

创建引用循环

让我们看看引用循环是如何发生的以及如何防止它,首先从清单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,
        }
    }
}

清单15-25:一个cons列表定义,它持有一个RefCell<T>,这样我们就可以修改Cons变体所指向的内容

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

在清单15-26中,我们添加了一个main函数,它使用了清单15-25中的定义。这段代码在a中创建了一个列表,在b中创建了一个指向a中列表的列表。然后它将a中的列表修改为指向b,从而创建了一个引用循环。在这个过程中,有一些println!语句来显示在各个点上的引用计数是多少。

文件名:src/main.rs

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)
    );

    // 取消注释下一行以查看我们有一个循环;
    // 它将使堆栈溢出
    // println!("a next item = {:?}", a.tail());
}

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

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

我们修改a使其指向b而不是Nil,从而创建一个循环。我们通过使用tail方法获取对aRefCell<Rc<List>>的引用,并将其放入变量link中来实现这一点[3]。然后我们对RefCell<Rc<List>>使用borrow_mut方法,将其中的值从持有Nil值的Rc<List>更改为b中的Rc<List>[4]。

当我们运行这段代码时,暂时保留最后一个println!注释,我们将得到以下输出:

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会丢弃变量b,这会将bRc<List>实例的引用计数从2减为1。此时,Rc<List>在堆上的内存不会被释放,因为其引用计数是1而不是0。然后Rust会丢弃a,这也会将aRc<List>实例的引用计数从2减为1。这个实例的内存也无法被释放,因为另一个Rc<List>实例仍然引用它。分配给列表的内存将永远不会被回收。为了直观地展示这个引用循环,我们在图15-4中创建了一个示意图。

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

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

与实际程序相比,在这个示例中创建引用循环的后果并不是非常严重:在我们创建引用循环之后,程序就结束了。然而,如果一个更复杂的程序在循环中分配了大量内存并长时间持有它,那么程序将使用比所需更多的内存,可能会使系统不堪重负,导致可用内存耗尽。

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

另一种避免引用循环的解决方案是重新组织你的数据结构,使一些引用表示所有权,而一些引用不表示所有权。结果,你可以有由一些所有权关系和一些非所有权关系组成的循环,并且只有所有权关系会影响一个值是否可以被释放。在清单15-25中,我们总是希望Cons变体拥有它们的列表,所以重新组织数据结构是不可能的。让我们看一个使用由父节点和子节点组成的图的示例,看看非所有权关系何时是防止引用循环的合适方法。

使用 Weak<T> 防止引用循环{=html}

到目前为止,我们已经证明调用 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。不同之处在于,对于 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 的结构体,它持有自己的 i32 值以及对其子节点 Node 值的引用:

文件名:src/main.rs

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> 类型的值。我们还希望修改哪些节点是另一个节点的子节点,因此我们在 children 中有一个围绕 Vec<Rc<Node>>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 是它的父节点。接下来我们就来实现这一点。

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

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

从另一个角度考虑这种关系,父节点应该拥有它的子节点:如果父节点被丢弃,它的子节点也应该被丢弃。然而,子节点不应该拥有它的父节点:如果我们丢弃一个子节点,父节点仍然应该存在。这正是弱引用的用武之地!

所以,我们将 parent 的类型设为 Weak<T>,具体是 RefCell<Weak<Node>>,而不是 Rc<T>。现在我们的 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>>>,
}

一个节点将能够引用它的父节点,但并不拥有它的父节点。在清单 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 = {:?}",
        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> 引用实例 [1]。

此时,当我们尝试通过 upgrade 方法获取 leaf 的父节点引用时,会得到一个 None 值。我们可以在第一个 println! 语句的输出中看到这一点 [2]:

leaf parent = None

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

当我们再次打印 leaf 的父节点时 [5],这次我们会得到一个包含 branchSome 变体:现在 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_countRc::weak_count 得到的值来判断这一点。

可视化 strong_countweak_count 的变化

让我们通过创建一个新的内部作用域并将 branch 的创建移动到该作用域中来看看 Rc<Node> 实例的 strong_countweak_count 值是如何变化的。这样做,我们可以看到当创建 branch 然后它超出作用域被丢弃时会发生什么。修改内容如清单 15 - 29 所示。

文件名:src/main.rs

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 [1]。在内部作用域 [2] 中,我们创建 branch 并将其与 leaf 关联,此时当我们打印计数时 [3],branch 中的 Rc<Node> 的强引用计数为 1,弱引用计数为 1(因为 leaf.parentWeak<Node> 指向 branch)。当我们打印 leaf 中的计数时 [4],我们会看到它的强引用计数为 2,因为 branch 现在在 branch.children 中存储了 leafRc<Node> 的克隆,但弱引用计数仍为 0。

当内部作用域结束时 [5],branch 超出作用域,Rc<Node> 的强引用计数减少到 0,所以它的 Node 被丢弃。来自 leaf.parent 的弱引用计数 1 与 Node 是否被丢弃无关,所以我们不会有任何内存泄漏!

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

所有管理计数和值丢弃的逻辑都内置于 Rc<T>Weak<T> 以及它们对 Drop 特性的实现中。通过在 Node 的定义中指定从子节点到父节点的关系应该是一个 Weak<T> 引用,你能够让父节点指向子节点,反之亦然,而不会创建引用循环和内存泄漏。

总结

恭喜你!你已经完成了“引用循环可能导致内存泄漏”实验。你可以在 LabEx 中练习更多实验来提升你的技能。