Rust 学习笔记(30)-循环引用

这一章我建议结合《Rust 语言圣经(Rust Course)》这本书一起看

参考章节《Rust 程序设计语言》第15.6章 引用循环与内存泄漏
参考章节《Rust语言圣经(Rust Course)》第3.5章 循环引用与自引用

这一章主要是在讲内存泄漏问题,如果你现在看不明白,暂时跳过也没关系(因为就算你暂时不了解它,你平常应该也很难写出内存泄漏的代码)
虽然 Rust不保证完全地避免内存泄漏,但你仍然难以写出内存泄漏的代码,除非你故意的,因此你暂时跳过这一章我认为也是可以的

这一章主要在阐述下面两个问题,了解它们,将对你写出更高质量的代码有很大的帮助。

  1. 什么情况可能会发生内存泄漏
  2. 如何最大程度的避免内存泄漏

我们先来看看第一个问题 什么情况下会发生内存泄漏

以下代码,摘自《Rust 程序设计语言》

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use crate::List::{Cons, Nil}; // 引入我们自定义的List中的 Cons 和 Nil 成员

use std::cell::RefCell;
use std::rc::Rc;

// 自定义枚举List 
// Cons 成员代表有值的情况,其内部存储一个i32的值,和下一个List 
// Nil  成员代表无值的情况
#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>), // 第二个值存放下一个List,它既是可变的也是可克隆的
    Nil,
}

impl List {
    // tail 方法获取下一个List
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

我们来使用这个复杂的List

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fn main() {
    // 创建一个List a,它有 Cons 值 5,和指向下一个值是 Nil 的 List
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a的初始化rc计数 = {}", Rc::strong_count(&a)); // a = 1
    println!("a指向的节点 = {:?}", a.tail()); // Nil List

    // 创建一个List b,它有 Const 值 10,和指向下一个值是 a 的克隆的 List
    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("在b创建后,a的rc计数 = {}", Rc::strong_count(&a)); // a = 2
    println!("b的初始化rc计数 = {}", Rc::strong_count(&b)); // b = 1
    println!("b指向的节点 = {:?}", b.tail()); // a List

    // a.tail 取出了其 Cons 成员指向的 Nil List,利用可变性,把它改成 b 的克隆
    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b); // 将 a 里面存储的 Nil List 改成 b
    }

    println!("在更改a后,b的rc计数 = {}", Rc::strong_count(&b)); // b = 2
    println!("在更改a后,a的rc计数 = {}", Rc::strong_count(&a)); // a = 2

    // 此时我们创建了循环引用 a -> b -> a ...

    // 下面一行println!将导致内存泄漏,直至我们可怜的8MB大小的main线程栈空间将被它冲垮,最终造成栈溢出
    // println!("a next item = {:?}", a.tail());
}

运行这个程序

1
2
3
4
5
6
7
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

可以看到 main 函数结束前,ab 的引用计数均是 2
b 离开作用域时 b 触发 Drop,此时引用计数会变为 1,并不会归 0,因此 b 指向的内存不会被释放,同样的
a 离开作用域时 a 触发 Drop,此时引用计数会变为 1,也不会归 0,因此 a 指向的内存也不会被释放,最终发生了内存泄漏
当你取消最后一行的 println! 语句的注释时,由于 a -> b -> a 因此它会一直打印下去,直至栈溢出

下图展示了这种引用循环关系

下面这幅图是我画的,我觉得书上的图不好

图示

因此总结一下

当你使用 RefCell<Rc<T>> 或者类似的类型嵌套组合(具备内部可变性和引用计数)时,就要打起万分精神,前面可能是深渊!

我们再来看看第二个问题 如何最大程度的避免内存泄漏

  1. 可以使用Weak
  2. 可以使用unsafe

unsafe 我们现在没学到,所以就让我们来看看Weak的使用

Weak 是一个弱化的 Rc,它不增加引用计数器,它不阻止对象的释放(Rc要等引用计数器=0后才会释放,而Weak不用)
它通过upgrade方法返回一个Option<Rc<T>>,当引用的对象不存在时返回None,我们可以通过downgrade方法产生一个Weak

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use crate::List::{Cons, Nil};

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Weak<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Weak<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let nil = Rc::new(Nil);

    let a = Rc::new(Cons(5, RefCell::new(Rc::downgrade(&nil))));
    println!("a的初始化rc计数 = {}", Rc::strong_count(&a)); // 1
    println!("a指向的节点 = {:?}", a.tail()); // Weak -> Nil

    let weak_a = Rc::downgrade(&a);
    let b = Rc::new(Cons(10, RefCell::new(Weak::clone(&weak_a))));

    println!("在b创建后,a的rc计数 = {}", Rc::strong_count(&a)); // 1
    println!("b的初始化rc计数 = {}", Rc::strong_count(&b)); // 1
    println!("b指向的节点 = {:?}", b.tail()); // Weak -> a

    // 修改a指向b
    let weak_b = Rc::downgrade(&b);
    if let Some(link) = a.tail() {
        *link.borrow_mut() = Weak::clone(&weak_b);
    }

    println!("在更改a后,b的rc计数 = {}", Rc::strong_count(&b)); // 1
    println!("在更改a后,a的rc计数 = {}", Rc::strong_count(&a)); // 1
    println!("a指向的节点 = {:?}", a.tail()); // Weak -> b
}

运行这个例子

1
2
3
4
5
6
7
8
a的初始化rc计数 = 1
a指向的节点 = Some(RefCell { value: (Weak) })
在b创建后,a的rc计数 = 1
b的初始化rc计数 = 1
b指向的节点 = Some(RefCell { value: (Weak) })
在更改a后,b的rc计数 = 1
在更改a后,a的rc计数 = 1
a指向的节点 = Some(RefCell { value: (Weak) })

从上面的例子可以看出引用计数器不会增加,被引用的数据在任何时候都可以释放,Weak不阻止释放,当引用数据无效时,Weak返回None
但我还没有理解到为什么 println! 打印到 Weak 就停了,而不会继续打印下去了?这一点我还没有想清楚(不过我猜这个应该是println!的机制问题)

总结一下

  1. 当组合使用RefCellRc 时要特别小心内存泄漏问题
  2. Weakunsafe 可以解决内存泄漏
updatedupdated2025-03-012025-03-01