【动图解析】Rust:从链表的内存泄漏浅析Rc、Box智能指针和所有权机制

851 阅读10分钟

0. 背景

最近在和朋友探讨Rust的内存管理。链表作为一种基础数据结构,涉及到指针的使用和堆上内存的管理,使用不同指针实现不同的链表,对于理解Rust的指针和所有权机制很有帮助。

Rc指针是Rust中一种带有运行时引用计数的智能指针,使用Rc指针实现的链表,通过特殊方式,可以在不使用unsafe关键字的前提下造成内存泄漏。

1. 使用Rc指针实现的链表

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

struct Node {
    val: usize,
    next: RefCell<Option<Rc<Node>>>
}

impl Node {
    fn new(val:usize) -> Self{
        Node {val, next: RefCell::new(None) }
    }

    fn link(&self, node:Rc<Node>){
        self.next.replace(Some(node));  //修改指向下一节点的指针
    }
}

impl Drop for Node { // 使用析构函数追踪链表节点的内存释放
    fn drop(&mut self) {
        println!("Calling the destructor. The value is {}", self.val)
    }
}

上面是一个简单实现的无符号整型链表,使用RefCell<Option<Rc<Node>>>作为指向下一链表节点的指针。因为下一节点可能不存在,所以使用Option枚举进行表示,当下一节点不存在时,枚举为None。需要注意的是,在这里使用了RefCell对Rc指针进行储存。在Rust中,RefCell提供了一种特殊的可变性,即内部可变性。为什么在这里需要内部可变性呢?我们来尝试构造一个链表。

#[test]
fn test(){
    let head = Rc::new(Node::new(0));
    let node1 = Rc::new(Node::new(1));
    let node2 = Rc::new(Node::new(2));
    let node3 = Rc::new(Node::new(3));

    node3.link(head.clone());  //将指向第一个节点的指针存入最后一个节点
    node2.link(node3);
    node1.link(node2);
    head.link(node1);
}

我们首先通过Rc::new()构造器在堆上申请内存空间,用于储存链表节点Node,并将对应的Rc指针对象压入栈中:

image0_new.gif

通过上面的代码,我们成功设计了一个Rc指针的链表,并声明了4个链表节点。

随后,我们使用link()函数将每个节点连接起来。这里是内部可变性起作用的地方。按照常规思路,将链表节点连接起来需要修改节点中的next成员变量,以指向下一节点,因此我们的链表节点对象应该是可以被修改的,即可变的(mutable)。但Rc指针的设计上不允许这样做,Rc指针要求指向的内容是不可变的。为了在链表节点不可变的前提下实现对next成员变量的修改,我们需要引入RefCellRefCell允许在整体不可变的情况下,提供对RefCell自身储存内容的可变性,这个就是Rust中重要的内部可变性概念。通过使用RefCell储存指向下一节点的指针,我们可以实现在Node本身不可变的情况之下,通过RefCell提供的replace()函数修改指向下一节点的指针。

image1_new.gif

在这里,我们通过将链表的第一个节点的指针存入链表的尾节点,使整个链表变成环状。有意思的事情发生了,在这种情况下,尽管test()函数已经结束,但是指向每个节点的Rc指针引用计数都为1,链表占用的内存无法释放,也就是发生了内存泄漏

pic0.png

从图中我们可以很明显地看到,问题就出在node3.link(head.clone())这一步中。在这一步,我们通过clone()函数令指向head(第一个节点)的Rc指针计数+1,并将多出来的这个引用存入node3(最后一个节点)中,使链表成环。

既然这个成环的关键步骤是Rc指针引用计数导致的,我们很自然地想到,是否可以使用不提供多个引用的Box指针修改我们的这个链表。

2. 使用Box指针实现的链表

#[derive(Clone)]
struct Node {
    val: usize,
    next: Option<Box<Node>>
}

impl Node {
    fn new(val:usize) -> Self{
        Node {val, next: None }
    }

    pub fn link(&mut self, node:Box<Node>){
        self.next = Some(node);
    }
}

impl Drop for Node {
    fn drop(&mut self) {  // 使用析构函数追踪链表节点的内存释放
        println!("Calling the destructor. The value is {}", self.val)
    }
}

在这个链表中,我们使用Option<Box<Node>>替换了之前的RefCell<Option<Rc<Node>>>,实现了一个Box指针链表。

Box指针保证了它指向的内存有且仅有1个所有者,也正是这一点,Box指针允许直接对内容进行修改,因此我们去掉了RefCell。此外,在x86_64机器上,使用Option<Box<Node>>仅需要8字节,而RefCell需要额外的内存空间储存借用信息,所以RefCell<Option<Rc<Node>>>需要16字节。

我们尝试对Box链表进行相同的操作,看看会不会再次使链表成环:

#[test]
fn test(){
    let mut head = Box::new(Node::new(0));
    let mut node2 = Box::new(Node::new(2));
    let mut node3 = Box::new(Node::new(3));
    let mut node1 = Box::new(Node::new(1));

    node3.link(head.clone());
    node2.link(node3);
    node1.link(node2);
    head.link(node1);
}

幸运的是,可以看到终端输出了析构函数被调用的信息,这次没有发生内存泄漏:

Calling the destructor. The value is 0
Calling the destructor. The value is 1
Calling the destructor. The value is 2
Calling the destructor. The value is 3
Calling the destructor. The value is 0

同时,我们也注意到,析构函数被调用了5次。我们只声明了4个节点,为什么会多出来一个节点呢?我们来看下整个流程分析:

image2_new.gif

我们发现,问题依旧出现在head.clone()。与Rc指针链表不同,对Box指针进行clone()不会获得第二个对当前Box指针的引用,而是重新产生一个Box指针对象,并在堆上申请一块新的内存,同时将原有Box指针指向的内容深复制到新申请的内存区域。这就可以解释为什么会多出一个节点,这个节点是通过对head的Box指针调用clone()产生的新节点,其储存的所有成员变量都和深复制发生时的head一样,因此它储存的值val0,指向下一节点的指针不存在,为None:

pic1.png image3_new.gif

使用Box指针实现的单向链表,可以保证在常规操作之下不会出现环状链表,因为它保证了任何时候指向堆上内存的引用有且只有1个。在上面的例子中,如果我们尝试不对head进行clone(),而是直接将head节点存入node3中,那么head.link(node1)这段代码将无法编译通过,因为我们已经将唯一对head的指针对象移动进入node3中,再次使用head将会导致Rust编译器的借用检查不通过。上面的代码通过借用检查,可以保证在任何时候,总是有至少一个节点的指针对象储存在栈上,并随着test()函数的结束而被释放,不会导致内存泄漏。

node3.link(head); // head被移动进入node3中
node2.link(node3);
node1.link(node2);
head.link(node1);   //此处尝试再次使用已经被移动的head,编译不通过

但是如果我们要实现一个双向链表,就不适合使用Box指针。因为在双向链表中,除了最两端的两个节点以外,每个节点的指针必然被它的前节点和后节点同时持有,而Box指针仅提供唯一的指针对象,因此我们需要改用Rc指针。

3. 通过Rc指针实现的双向链表

我们首先尝试每个节点的后继节点和前置节点都使用Rc指针进行储存:

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

struct Node {
    val : usize,
    next : RefCell<Option<Rc<Node>>>,
    prior: RefCell<Option<Rc<Node>>>
}

impl Node {
    fn new(val:usize) -> Self{
        Self{val, next: RefCell::new(None), prior: RefCell::new(None)}
    }

    fn link(prior: &Rc<Node>, latter: &Rc<Node>){
        let rc_latter = Some(latter.clone());
        let rc_prior = Some(prior.clone());

        prior.next.replace(rc_latter);
        latter.prior.replace(rc_prior);
    }
}
impl Drop for Node {
    fn drop(&mut self) {
        println!("Calling the destructor. The value is {}", self.val)
    }
}

根据我们上面的经验可以预测到,当我们尝试连接3个节点时,内存泄漏同样会发生:

#[test]
fn test(){
    let node1 = Rc::new(DouLink::new(1));
    let node2 = Rc::new(DouLink::new(2));
    let node3 = Rc::new(DouLink::new(3));

    DouLink::link(&node1, &node2);
    DouLink::link(&node2, &node3);
}

实际上,在堆中的这个双向链表的状态是这样的:

pic2.png

可以发现,同样形成了循环引用,因此所有节点的引用计数都无法归零,析构函数无法正常被调用。

这时,我们可以使用Rc指针的弱引用实现一种可以正常被释放的双向链表:

4. 通过引入Weak指针的Rc指针链表

我们仅需要将每个节点中储存的前置节点的指针替换成Weak<DouLink>,同时在link()函数中用函数Rc::downgrade(prior)生成一个指向前置节点的Weak指针,就得到了以下的新双向链表:

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

struct Node {
    val : usize,
    next : RefCell<Option<Rc<Node>>>,
    prior: RefCell<Weak<Node>>  //修改指向上一节点的指针类型为Weak指针
}

impl Node {
    fn new(val:usize) -> Self{
        Self{val, next: RefCell::new(None), prior: RefCell::new(Weak::new())}
    }

    fn link(prior: &Rc<Node>, latter: &Rc<Node>){
        let rc_latter = Some(latter.clone());
        let weak_prior = Rc::downgrade(prior);  //通过Rc::downgrade()将指向上一节点的Rc指针转为Weak指针

        prior.next.replace(rc_latter);
        latter.prior.replace(weak_prior);
    }
}
impl Drop for Node {
    fn drop(&mut self) {
        println!("Calling the destructor. The value is {}", self.val)
    }
}

我们使用相同的test()函数进行测试,这次我们可以在屏幕上看到析构器输出的信息,代表这个双向链表被正常释放了。

Weak指针可以指向Rc指针指向的内容,却不会增加Rc指针的引用计数,这种引用叫做弱引用。与直接对Rc指针进行clone()产生的强引用不同,弱引用的存在不会阻止Rc指针的正常释放,因此可以打破循环引用的问题。在上面的例子中,我们使用了Weak指针打破了双向链表的循环引用。如果不刻意进行操作,可以将对链表第一个节点的唯一强引用储存在栈上,并在强引用弹出栈之后让整个链表的内存被释放。

pic3.png

5. 总结

内存管理一直是Rust编程中的重要话题,所有权机制和各种智能指针是Rust内存管理中必不可少的部分。

作为智能指针,Rc指针和Box指针有不同的特性和用途。Rc指针提供对堆中同一块内存的多个指针对象,但是不提供对内存数据的可变引用,本身不允许对指向内存的修改;Box指针保证任何时候仅存在对堆中同一块内存的唯一指针对象,同时允许对内存的修改。通过Rc指针和RefCell配合实现的链表,可以在不使用unsafe关键字的情况下手动制造内存泄漏。而Box指针配合Rust的借用检查器可以保证实现的单链表不会导致内存泄漏。在使用Rc指针时可以通过引入Weak指针解决循环引用的问题,避免内存泄漏。

Rust提供了一系列工具,尽可能提供可靠的内存管理,但是如果操作不当,依然会导致内存泄漏的发生。上面实现的简易链表只是为了演示而给出的示例,而在实际使用中可能会根据需求自己设计数据结构,并且有可能会用到不同类型的指针,对这些自定义数据结构的操作进行封装时要熟练掌握内存使用情况,避免出现内存泄漏甚至内存不安全的情况。