Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15.7 循环引用导致内存泄漏

15.7.1 内存泄漏

Rust 极高的安全性使得内存泄漏很难发生,但并非不可能

例如,使用 Rc<T>RefCell<T> 可能创造出引用循环并造成内存泄漏:每个指针的引用计数都不会减少到 0,因此这些值永远不会被清理。

看个例子:

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));
}
  • 首先创建一个链表 List,用 RefCell<T> 包裹 Rc<T>,以便可以修改内部值。
  • 通过 impl 块为 List 定义一个名为 tail 的方法,用于获取 Cons 变体携带的第二个元素。如果存在,就用 Some 包起来返回;如果是 Nil,则返回 None
  • 然后在 main 中创建两个 List 实例 ab,并且 b 内部共享了 a 的值。这种链表代码看着很绕,所以我把结构图放在这里: 形成引用循环前的 Cons 列表 a(5 → Nil)与 b(10 → a)
  • main 还通过 Rc::strong_count 获取 ab 的强引用计数,用自定义的 tail 方法获取 Cons 携带的第二个元素,并用 println! 打印出来。
  • 接下来,if let 语句把 aCons 的第二个值绑定到 link 上。它用 borrow_mut 获得对 RefCellRc<List> 的可变借用,再通过 Rc::cloneb 的克隆赋进去,从而把 a 的内部结构改成这样: 值为 5 和 10 的两个节点相互指向形成引用循环的示意图

输出:

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
  • 第 1 行到第 5 行:刚创建 a 时,引用计数为 1。声明 b 时共享了 a,因此 a 的引用计数变为 2b1
  • 第 6 行到第 7 行:if let 语句改变了 a 的内部结构,使 a 的第二个元素指向 b,于是 b 的引用计数增加到 2。此时 a 指向 bb 又指向 a,就形成了引用循环。

ab 都离开作用域时,Rust 丢弃变量 b,这会把 b 的引用计数从 2 减到 1。此时堆上的 Rc<List> 内存不会被删除,因为它的引用计数是 1 而不是 0。然后 Rust 丢弃 a,这会把 aRc<List> 实例的引用计数从 2 减到 1,如下所示。这个实例的内存也不能被删除,因为另一个 Rc<List> 实例仍然引用它。为列表分配的内存将永远保持未回收状态。

接下来,用下面这行代码看看循环里包含什么:

#![allow(unused)]
fn main() {
println!("a next item = {:?}", a.tail());
}

Rust 会尝试打印这个循环:a 指向 bb 又指向 a,如此往复,直到栈溢出。最终结果会是栈溢出错误。

15.7.2 如何防止内存泄漏

那么有没有办法防止内存泄漏呢?这只能依靠开发者,不能只依赖 Rust。

否则,就需要重新组织数据结构,把引用拆分成持有所有权和不持有所有权的两类。一些引用用来表达所有权,一些引用不表达所有权。在引用循环中,一部分具有所有权关系,另一部分没有。这样,只有与所有权相关的链接才会影响值是否被清理。

15.7.3 把 Rc<T> 换成 Weak<T> 以防止循环

我们知道,Rc::clone 会创建数据的强引用,并使 Rc<T> 内部的引用计数加 1;只有当 strong_count 变为 0 时,Rc<T> 才会被清理。

然而,Rc<T> 实例可以通过调用 Rc::downgrade 来创建对某个值的弱引用。该方法的返回类型是 Weak<T>(也是智能指针)。每次调用 Rc::downgrade 会增加 weak_count 而不是 strong_count,因此弱引用不影响 Rc<T> 的清理。

15.7.4 Strong vs. Weak

强引用关乎如何分析 Rc<T> 实例的所有权。弱引用并不表达所有权,使用它也不会创建引用循环:当强引用计数变为 0 时,弱引用会自动断开。

在使用弱引用之前,需要确认它所指向的值仍然存在。在 Weak<T> 实例上调用 upgrade 方法会返回 Option<Rc<T>>,并通过 Option 枚举来验证该值是否存在。

看个例子:

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 结构体表示一个节点,有两个字段:

  • value 字段存储当前值,类型是 i32
  • children 字段存储子节点,类型是 RefCell<Vec<Rc<Node>>>。这里使用 Rc<T> 是为了让所有子节点共享所有权。更具体地说,我们希望一个 Node 拥有它的子节点,同时也希望与保存该节点本身的变量共享这份所有权,以便可以直接访问树中的每个 Node。为此,我们将 Vec<T> 的元素定义为 Rc<Node> 类型的值。

这里的需求是:每个节点既能指向父节点,也能指向子节点。

再看 main 函数:

  • leaf 被创建为一个 Node 实例,value3children 为被 RefCell 包裹的空 Vector
  • branch 被创建为一个 Node 实例,value5,其 children 指向 leaf

这意味着 leaf 里面的 Node 有两个所有者。目前可以通过 branchchildren 字段访问 leaf;但反过来还不能通过 leaf 访问 branch,所以还需要修改。

要实现这一点,就需要双向引用。但双向引用会创建引用循环,因此需要使用 Weak<T> 来避免循环:

#![allow(unused)]
fn main() {
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}
}

我们添加一个 parent 字段表示父节点,并使用弱引用 Weak<T>。这里不用 Vec<>,因为这是树结构,一个节点只能有一个父节点。

要这样写,需要把 Weak<T> 引入作用域,并重构下面的代码。修改后的完整代码如下:

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

在创建 leaf 之后,先打印其 parent 字段的内容(此时 parent 还没有任何值)。创建 branch 之后,再打印一次 leafparent 字段内容(此时其值是 branch)。

语句 *leaf.parent.borrow_mut() = Rc::downgrade(&branch); 会创建一个指向 branchWeak<Node>,并存入 leafparent 字段:

  • leaf.parent 是表示 leaf 父节点的字段,类型是 RefCell<Weak<Node>>,因此可以用 borrow_mut 得到 RefMut<Weak<Node>>
  • 解引用运算符 * 让我们访问存储在 RefMut<Weak<Node>> 内部的 Weak<Node> 值。
  • downgrade 方法从 branch 创建一个 Weak<Node>,并赋给 parent

输出:

leaf parent = None
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) }, children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) }, children: RefCell { value: [] } }] } })
  • 第一次打印表明 parent 字段尚未被赋值,因此其值是 Option 下的 None 变体。
  • 第二次打印表明父节点已被设置为 branch;输出没有无限继续,说明这段代码没有创建引用循环。

最后,我们修改 main:添加打印语句并调整作用域,看看强引用和弱引用的数量:

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

代码逻辑是:

  • 创建 leaf 之后,打印它有多少强引用和弱引用。

  • 之后加上 {} 创建新作用域:

    • branch 的声明以及给 leaf 指定父节点的操作放进去。
    • 打印此时 branchleaf 的强引用、弱引用数量。
  • 离开作用域后:

    • 打印 leafparent
    • 打印 leaf 的强引用和弱引用

输出:

leaf strong = 1, weak = 0
branch strong = 1, weak = 1
leaf strong = 2, weak = 0
leaf parent = None
leaf strong = 1, weak = 0
  • 第 1 行:创建了 leaf,有一个强引用。
  • 第 2 行:创建了 branch。在用 Rc::downgrade(&branch) 设置 leaf.parent 之后,branch 有一个强引用和一个弱引用——这个弱引用计数来自 leafparent 字段,而不是来自 branch 自己那个空的 Weak::new()
  • 第 3 行:branch.children 持有对 leaf 的强引用,而 leaf 变量本身也是一个强引用,因此此时 leaf 有两个强引用。
  • 第 4 行:因为 branch 已经离开作用域,所以 leafparent 字段现在是 None
  • 第 5 行:branch 离开作用域导致它对 leaf 的强引用失效,于是 leaf 的强引用减 1,变为 1