写点什么

Rust 从 0 到 1- 智能指针 - 内存泄漏

用户头像
关注
发布于: 2 小时前
Rust从0到1-智能指针-内存泄漏

Rust 的内存安全机制保证使我们难以但并非是不可能的制造出永远不会被清理的内存(即 memory leak,内存泄露)。Rust 并不会阻止内存泄漏的主动行为,这与在编译时阻止数据竞争不同, Rust 并没有对内存泄露做出保证(提倡通过 RAII 机制避免内存泄漏 ),也就是说内存泄漏对于 Rust 来说被认为是内存安全的(数据竞争或是悬垂引用则是内存不安全的,因为它们可能会造成数据的不一致或损坏,而内存泄漏只是内存没有被释放,内存中的数据并未变化)。在 Rust 中我们可以通过 Rc<T> 和 RefCell<T> 制造出内存泄漏:通过创建指向彼此的引用循环,就会造成内存泄露,因为在循环中彼此的引用计数永远也到不了 0,也就永远不会被丢弃。

制造循环引用

让我们看看循环引用是如何产生的以及如何避免。 我们先定义出枚举类型 List ,参考下面的例子:

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() {}
复制代码

在上面的例子中我们定义了 List 的另一种变体。Cons 成员的第二个元素是 RefCell<Rc<List>>,我们希望能够修改 Cons 成员所指向的 List。我们还新增加了一个 tail 方法用于返回 Cons 指向的 List 。

接着我们使用 List 类型创建两个在指向彼此的列表 a 和 b,参考下面的例子:

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());}
复制代码

在上面的例子中我们首先创建了 a,然后创建了指向 a 的 b,然后修改 a,让其指向 b,最终 a 和 b 互相指向彼此,形成循环引用。尝试运行例子中的代码,我们会得到类似下面的结果:

$ cargo run   Compiling cons-list v0.1.0 (file:///projects/cons-list)    Finished dev [unoptimized + debuginfo] target(s) in 0.53s     Running `target/debug/cons-list`a initial rc count = 1a next item = Some(RefCell { value: Nil })a rc count after b creation = 2b initial rc count = 1b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })b rc count after changing a = 2a rc count after changing a = 2
复制代码

通过打印输出的结果我们可以看到将 a 修改为指向 b 之后,Rc<List> 类型 的 a 和 b 的引用计数为 2。在 main 函数结束后,Rust 先丢弃 b,这会使 b 的引用计数减 1,这时存储在堆上的数据并不会被释放,因为其引用计数是 1 而不是 0。接着,Rust 会丢弃 a,这同样会使 a 的引用计数从 2 变为 1,出于同样的原因内存也不会被释放。这时候,由于 a 和 b 的引用计数停留了在 1,无法归 0,而导致内存永远不会被释放(除非程序终止)。下图形象的展示了循环引用(来自官网):

如果我们取消最后一行 println! 的注释并运行程序,Rust 会尝试打印出 a 指向 b 指向 a 这样的循环直到栈溢出。

在我们的例子中,创建了循环引用之后程序就结束了,并不会造成什么严重的后果。真实的程序会更为复杂,如果在循环里分配了很多内存并占有很长时间,这不仅造成程序会占用多于它所需要的内存,而且有可能拖垮系统,导致可用内存不足。

创建引用循环并不容易,但并非不可能。在使用有包含 Rc<T> 的 RefCell<T> 类型或其它类似的嵌套,结合使用了内部可变性和引用计数的类型时,我们需要小心,确保没有形成循环引用;在这方面 Rust 无法帮助我们捕获到循环引用。Rust 认为创建循环引用属于程序逻辑上的错误,应该使用自动化测试、代码评审或其他软件开发实践来尽可能的避免。

另外一种解决方案是重新组织我们的数据结构,如果部分引用并不拥有数据的“所有权”或者说造成其引用计数增加,那么就不会影响数据最后被丢弃,只有所有权关系才会影响是否可以被丢弃(我理解就是不形成拥有“所有权”的循环)。但是在我们的例子中,由于我们希望 Cons 成员拥有其列表,所以无法重新组织数据结构。下面让我们看看一个由父结点和子结点构成的图的例子,它是如何避免循环引用的,以及适用于什么场景。

利用 Weak<T>避免循环引用

之前我们已经介绍过调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,而只有当 strong_count 为 0 时 Rc<T> 实例才会被清理。除此之外,我们还可以通过调用 Rc::downgrade 获得“弱引用”(weak reference),同样,其参数也是 Rc<T> 类型的引用。调用 Rc::downgrade 时会返回一个 Weak<T> 类型的智能指针。与 strong_count 不同,调用 Rc::downgrade 会将 weak_count 加 1,而 strong_count 则保持不变。类似于 strong_count,Rc<T> 类型使用 weak_count 来记录其弱引用数量。它们的区别在于,在清理 Rc<T>类型的实例时无需关心 weak_count 的计数。

强引用实现了对 Rc<T> 实例所有权的共享,而弱引用和所有权并没有关系,因此也不会造成引用循环,因为当强引用计数为 0 时,实例就会被清理,弱引用的循环就被打断了。

而由于 Weak<T> 引用的值可能已经被丢弃了,为了确保使用 Weak<T> 时其指向的值仍然有效。我们需要调用 Weak<T> 的 upgrade 方法,它会返回 Option<Rc<T>> 类型的值。如果其指向的值还未被丢弃,则返回 Some;否则返回 None。Rust 会确保正确处理 Some 和 None 的情况,而不会返回一个无效的指针。

下面作为例子,我们会创建一个树形结构,其中的节点会同时包含指向父和子的引用,而不像前面的列表,只包含指向下一个元素的引用。

创建包含子节点的节点类型

首先我们创建一个树形结构,其中的节点包含其所有子节点。其节点类型定义参考下面的例子:

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>> 封装进 RefCell<T> 类型中。

接下来,我们创建一个简单的树,参考下面的例子:

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)]), });}
复制代码

上面的例子中,根节点 branch 的值为 5 ,并指向其子节点 leaf,leaf 的值为 3,并且没有子节点。我们使用 Rc::clone 克隆了 leaf 的引用,并储存在 branch 中,这代表 leaf 现在拥有两个所有者:leaf 和 branch。由于 leaf 中没有指向 branch 的引用,我们可以通过 branch 获得 leaf,不过无法从 leaf 得到 branch。下面我们将为 leaf 添加其父结点。

增加父节点的引用

为了让子结点知道谁是它的父结点,我们可以在 Node 类型定义中增加一个 parent 字段。问题是 parent 应该是什么类型,根据前面的介绍我们知道它不能包含 Rc<T> 类型,因为这样会形成循环引用。

让我们换个思路去思考父子关系,父结点应该拥有其子结点:如果父结点被丢弃了,其子结点也应该被丢弃。然而子结点不应该拥有其父结点:如果丢弃子结点,其父结点应该依然存在。这正是弱引用的适合的场景!即,parent 使用 Weak<T> 类型而不是 Rc<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 结点时由于还没创建父结点,所以我们创建了一个空的 Weak<Node> 实例。因此,当尝试使用 upgrade 方法获取 leaf 的父结点引用时,会返回 None 。例子中的 println! 会打印出类似下面的内容:

leaf parent = None
复制代码

当我们创建了 branch 结点后,会通过 Rc::downgrade 函数获得 branch 的弱引用,并赋值给 leaf 中的 parent。这时我们再次打印出 leaf 的父结点,我们会得到存放了 branch 的 Some 值,并且也没有造成前面例子中导致栈溢出的循环:

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_count 和 Rc::weak_count 的变化来观察。

strong_count 和 weak_count 的变化

让我们通过将 branch 放入一个内部作用域来观察 Rc<Node> 实例的 strong_count 和 weak_count 的变化。通过这么做,我们可以观察到当 branch 创建和离开作用域被丢弃时会发生什么变化。参考下面的例子:

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 以后,其强引用计数为 1,弱引用计数为 0。在内部作用域中创建包含子节点 leaf 的 branch ,并将 leaf 的父节点指向 branch 后,branch 的强引用计数为 1,弱引用计数为 1(leaf 的父节点是使用的弱引用方式)。这时 leaf 的强引用计数为 2,弱引用计数仍然为 0。当内部作用域结束时,branch 离开作用域,其强引用计数减 1 变为 0,所以会被清理(即使这时候它的弱引用计数为 1),从而避免了内存泄漏。

如果我们在内部作用域结束后尝试访问 leaf 的父结点,会再次得到 None。在程序的结尾,leaf 的强引用计数为 1,弱引用计数为 0,因为这时候 leaf 是节点唯一所有者。

所有这些对计数的管理和内存的释放逻辑都包含在在 Rc<T> 和 Weak<T> 的内部以及其对 Drop trait 实现中。通过在子节点中指定父结点的 Weak<T> 引用,我们就可以实现父结点和子结点之间的彼此引用而又不会造成循环引用和内存泄露。

总结

在这一章我们介绍了如何使用智能指针来实现不同于普通引用的更多功能,以及其中的取舍。我们还介绍了 Deref 和 Drop trait ,它们为实现智能指针的功能提供了很大的帮助。最后还讨论了会造成内存泄露的循环引用,以及如何使用 Weak<T> 来避免。

如果你对此非常感兴趣,并希望实现自己的智能指针的话,可以进一步阅读官网的 “The Rustonomicon” 文档。

下面,我们将介绍 Rust 中的并发。其中还会涉及到一些新的智能指针。

发布于: 2 小时前阅读数: 2
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-智能指针-内存泄漏