解密 Rust 的引用计数,让多个所有者拥有同一个值

楔子

之前介绍的单一所有权规则,能满足我们大部分场景中分配和使用内存的需求,而且在编译时,通过 Rust 借用检查器就能完成静态检查,不会影响运行时效率。但规则总会有例外,在日常工作中有些特殊情况该怎么处理呢?比如:

  • 一个有向无环图(DAG)中,某个节点可能有两个以上的节点指向它,这个按照所有权模型怎么表述?
  • 多个线程要访问同一块共享内存,怎么办?

我们知道,这些问题在程序运行过程中才会遇到,在编译期,所有权的静态检查无法处理它们。所以为了更好的灵活性,Rust 提供了运行时的动态检查,来满足特殊场景下的需求。这也是 Rust 处理很多问题的思路:编译时,处理大部分使用场景,保证安全性和效率;运行时,处理无法在编译时处理的场景,会牺牲一部分效率,提高灵活性。

那具体如何在运行时做动态检查呢?运行时的动态检查又如何与编译时的静态检查自洽呢?

Rust 的答案是使用引用计数的智能指针:Rc(Reference counter) 和 Arc(Atomic reference counter)。这里要特别说明一下,Arc 和 ObjC/Swift 里的 ARC(Automatic Reference Counting)不是一个意思,不过它们解决问题的手段类似,都是通过引用计数完成的。

Rc

我们先看 Rc,对某个数据结构 T,我们可以创建引用计数 Rc,使其有多个所有者。Rc 会把对应的数据结构创建在堆上,因为堆是唯一可以让动态创建的数据被到处使用的内存。

use std::rc::Rc;

fn main() {
    let num = Rc::new(123);
}

之后如果想对数据创建更多的所有者,我们可以通过 clone() 来完成。但需要注意的是,对一个 Rc 结构进行 clone(),不会复制其内部的数据,只会增加引用计数。而当一个 Rc 结构离开作用域被 drop() 时,也只会减少其引用计数,直到引用计数为零,才会真正清除对应的内存。

use std::rc::Rc;

fn main() {
    let num = Rc::new(123);
    let num2 = num.clone();
    let num3 = num.clone();
}

上面的代码我们创建了三个 Rc,分别是 num、num2 和 num3,它们共同指向堆上相同的数据,也就是说堆上的数据有了三个共享的所有者。在这段代码结束时,num3 先 drop,引用计数变成 2,然后 num2 再 drop、num 再 drop,引用计数归零,堆上内存被释放。

你也许会有疑问:为什么我们生成了对同一块内存的多个所有者,但是编译器却不抱怨所有权冲突呢?

仔细看这段代码:首先 num 是 Rc::new(123) 的所有者,这毋庸置疑;然后 num2 和 num3 都调用了 num.clone(),分别得到了一个新的 Rc,所以从编译器的角度,这三个变量都各自拥有一个 Rc。如果文字你觉得稍微有点绕,可以看看 Rc 的 clone() 函数的实现,位于源代码 src/alloc/rc.rs 中。

所以 Rc 的 clone() 方法正如我们刚才说的,不复制实际的数据,只是一个引用计数的增加。当然你可能又会产生疑惑:Rc 是怎么产生在堆上的?并且为什么这段堆内存不受栈内存生命周期的控制呢?

Box::leak() 机制

我们知道,存储在堆上的数据都会对应一个栈指针,也就是栈上的指针指向堆去的内存。也就是说,一个对象(值)要么完全在栈上,要么元数据在栈上,具体的值存储在内部的指针指向的堆上。比如整数、浮点数等等,它们的数据就完全在栈上;而字符串则是在栈上存了指针、长度和容量,具体的值则存在堆上。所以在所有权模型下,堆内存的生命周期,和创建它的栈内存的生命周期保持一致。栈上内存如果销毁,那么栈内存内部的指针指向的堆内存也一并释放。

以上就是我们之前对所有权的了解,但 Rc 的实现似乎与此格格不入。的确,如果完全按照之前了解的单一所有权模型,Rust 是无法处理 Rc 这样的引用计数的,因为 Rust 在同一时刻,只会让值拥有一个所有者。为此 Rust 必须提供一种机制,让代码可以像 C/C++ 那样,创建不受栈内存控制的堆内存,从而绕过编译时的所有权规则,而该机制便是 Box::leak()。

Box 我们前面说过,它是智能指针,可以强制把任何数据结构创建在堆上,然后在栈上放一个指针指向这个数据结构,但此时堆内存的生命周期仍然是受控的,跟栈上的指针一致。

fn main() {
    {
        // 在堆上创建 String,并返回智能指针 Box<String>,智能指针存储在栈上
        // 并且智能指针持有堆区数据的所有权
        let s1 = Box::new("xx".to_string());
        // 既然智能指针持有堆区数据的所有权,那么此时会发生所有权的转移,因此 s1 不能再用了
        let s2 = s1;
    }  // 到这里 s2 被释放,栈内存释放,会将内部指针指向的堆内存一并释放

    {
        let s1 = Box::new("xx".to_string());
        // 调用 clone 方法会将堆内存也深度拷贝一份,此时 s1 和 s2 是不同的 Box<String>
        let s2 = s1.clone();
    }

    {
        let num1 = Box::new(123);
        let num2 = num1;
        // 注意:如果继续使用 num1 同样会报错,尽管 i32 是可 Copy 的,但它被分配在了堆上
        // 所以它和 String 的表现是一样的
        // println!("{}", num1);
    }

    {
        let num1 = Box::new(123);
        // 对指针指针解引用,会拿到 Box 内部的数据
        let num2 = *num1;
        // 此时没有问题,因为 num2 是 i32,此时会将 Box 内部的数据拷贝一份
        // 拷贝后的 i32 是分配在栈上的
        println!("{} {}", num1, num2);  // 123 123
    }

    {
        let s1 = Box::new("xx".to_string());
        // 这里同样进行了解引用,但 String 不是可 Copy 的
        // 因此只会将指针、长度、容量拷贝一份,创建在栈上(结构体实例)
        // 但具体的堆区数据是不会拷贝的,因此 Box 内部的 String 的所有权被转移了
        let s2 = *s1;
        // 此时不能再继续使用 s1,会报错,因为 String 不是可 Copy 的
        // println!("{}", s1);
    }
}

所以一定要记住:Box 说白了就是一个箱子,在堆上将数据装箱(数据在堆上分配),然后返回一个智能指针。由于智能指针实现了 Deref trait,所以我们对其解引用,可以拿到箱子里面的具体数据。如果是可 Copy 的,那么就拷贝一份创建在栈上(因为默认情况下数据本来就创建在栈上),不是可 Copy 的,那么就转移所有权,具体做法就是堆区的动态数据不拷贝,而包含指针在内的元数据则拷贝一份在栈上。具体行为和默认创建普通变量是一致的。

由于数据分配在堆上,并且智能指针持有堆数据的所有权,因此拷贝指针会转移所有权。比如 let num2 = num1 之后,num1 就无法使用了,因为 Box 内部是可 Copy 的 i32,因为它是在堆上分配的。如果希望拷贝后彼此独立,那么就调用 clone 方法,此时也会将堆上的数据拷贝一份,并创建一个新的 Box<T>

因此对于 Box 来说,它依旧收到所有权机制的控制,说白了就是堆内存会收到栈内存的控制。而通过 Box::leak(),可以让创建的对象从堆内存上泄漏出去,不受栈内存控制,让其成为一个自由的、生命周期可以大到和整个进程的生命周期一致的对象。

所以我们相当于主动撕开了一个口子,允许内存泄漏。注意,在 C/C++ 下,其实你通过 malloc 分配的每一片堆内存,都类似 Rust 下的 Box::leak(),因为你不 free,那么它就永远驻留在堆区。Rust 的这种设计,符合最小权限原则(Principle of least privilege),最大程度帮助开发者撰写安全的代码。

有了 Box::leak(),我们就可以跳出 Rust 编译器的静态检查,保证 Rc 指向的堆内存,有最大的生命周期。然后我们再通过引用计数,在合适的时机,结束这段内存的生命周期。如果对此处有更深的兴趣,可以查看源码 src/alloc/rc.rs。

搞明白了 Rc,我们就进一步理解 Rust 是如何进行所有权的静态检查和动态检查了:

  • 静态检查,靠编译器保证代码符合所有权规则。这一过程在编译阶段即可完成,不会影响运行时,所以性能不受影响。最关键的是,编译器可以保证检测通过的代码都是安全的
  • 动态检查,通过 Box::leak 让堆内存拥有不受限的生命周期,然后在运行过程中,通过对引用计数的检查,保证这样的堆内存最终会得到释放。显然这一过程会影响效率,因为编译器要花费额外的代价维护引用计数

然后就是 Rc<T> 不是可 Copy 的,它会转移所有权。

use std::rc::Rc;


fn main() {
    let n1 = Rc::new(123);
    let n2 = n1;
    // n1 发生了移动,不可以再使用
    // 如果希望 n1 保持有效,那么请使用 n1.clone(),此时会增加引用计数

    // 再比如
    let s = Some(Rc::new("Hello".to_string()));
    // Option<T> 有一个 map 方法,接收一个函数 f
    // 如果值是 None,那么直接返回 None,如果是 Some(x),那么返回 Some(f(x))
    println!("{:?}", s.map(|v| v.len()));  // Some(5)
    // 但需要注意:map 方法的第一个参数是 self,这意味着调用完之后,s 如果有效,那么 s 一定是可 Copy 的
    // 而对于枚举来说,只有它内部的所有字段都是可 Copy 的,枚举才是可 Copy 的
    // 所以上面在调用完 map 之后,s 不再有效,因为 Rc 不可 Copy,调用 map 之后所有权转移了

    // 所以我们要调用 s.as_ref(),会基于 Some(T) 创建一个 Some(&T)
    let s = Some(Rc::new("Hello".to_string()));
    // 此时相当于将 Rc 的引用拷贝了一份,所以没有问题
    println!("{:?}", s.as_ref().map(|v| v.len()));  // Some(5)
}

还有一个问题,就是我们能不能修改 Rc 里面的值呢?答案是不行的,因为 Rc 是一个只读的引用计数器,你无法拿到 Rc 结构内部数据的可变引用,来修改这个数据。因此我们需要 RefCell,和 Rc 类似,RefCell 也绕过了 Rust 编译器的静态检查,允许我们在运行时,对某个只读数据进行可变借用。

内部可变性

前面在介绍 Rust 的变量是否可变时说过,无论是给变量重新赋值,还是修改变量的值,都要求变量必须使用 mut 关键字声明。

#[derive(Debug)]
struct Girl<'a> {
    name: &'a str,
    age: u8
}

fn main() {
    let mut girl = Girl{name: "古明地觉", age: 17};
    // 对 girl 重新赋值,将整个值给换掉
    girl = Girl{name: "古明地觉", age: 18};
    println!("{:?}", girl);  // Girl { name: "古明地觉", age: 18 }

    // 直接在本地修改值
    girl.age = 19;
    println!("{:?}", girl);  // Girl { name: "古明地觉", age: 19 }
}

所以变量可变有两种情况:

  • 对变量重新赋值,不管变量是什么类型,都可以这么做
  • 本地修改变量的值,显然只有当变量是元组、数组、结构体之类的复合类型,才可以这么做

但不管是哪一种情况,重新赋值也好,本地修改也罢,都要求变量是可变的,也就是变量要使用 mut 关键声明。但是问题来了,如果我们希望结构体实例的某一个字段可变,而其它不可变该怎么办呢?比如 Girl 结构体实例,我们希望它的 age 字段是可变的,因为年龄是会增长的,要怎么做呢?

可能有朋友觉得,创建变量的时候使用 mut 不就好啦。首先使用 mut 的话,那么 age 确实是可变的,但此时不光 age 可变,所有字段都可变,整个结构体实例也可变。我们想要的是,结构体实例不可变,除了 age 字段之外的其它字段也不可变。

use std::cell::Cell;

#[derive(Debug)]
struct Girl<'a> {
    name: &'a str,
    age: Cell<u8>,  // 这里使用 Cell 包起来
}

fn main() {
    // 此时不要使用 mut
    let girl = Girl{name: "古明地觉", age: Cell::new(17)};
    println!("{:?}", girl);  // Girl { name: "古明地觉", age: Cell { value: 17 } }
    // 修改 age 字段,我们将它自增 1
    girl.age.set(girl.age.get() + 1);
    println!("{:?}", girl);  // Girl { name: "古明地觉", age: Cell { value: 18 } }
}

Cell 一般用于实现了 Copy trait 的类型,因为只有实现了 Copy trait,才可以调用 get 方法,此时会将值拷贝一份。如果没有实现 Copy,那么调用 get 方法会报错。而对于那些没有实现 Copy 的类型,应该使用 RefCell。

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    {
        // 获得 RefCell 内部数据的可变借用
        let mut v = data.borrow_mut();
        *v += 1;
    }
    println!("data: {:?}", data.borrow());  // data: 2
}

在这个例子中,data 是一个 RefCell,其初始值为 1。可以看到,我们并未将 data 声明为可变变量,但可以通过使用 RefCell 的 borrow_mut() 方法,来获得一个可变的内部引用,然后对它做加 1 的操作。最后,我们通过 RefCell 的 borrow() 方法,获得一个不可变的内部引用,因为加了 1,此时它的值为 2。

你也许奇怪,这里为什么要把获取和操作可变借用的两句代码,用花括号分装到一个作用域下?因为根据所有权规则,在同一个作用域下,不能同时创建活跃的可变借用和不可变借用。通过这对花括号,我们明确地缩小了可变借用的生命周期,不至于和后续的不可变借用冲突。

但是到这里又有人好奇了,因为同一个作用域中的可变引用(借用)和不可变引用(借用)如果不发生重叠,那么是可以共存的。

fn main() {
    let mut data = "Hello".to_string();
    let v = &mut data;             // 可变引用
    println!("data: {:?}", data);  // 不可变引用
    v.push_str(" World");          // 可变引用
}

对于持有所有权的值来说,它的作用范围是从创建到销毁,而对于引用来说,它的作用范围是从创建到最后一次使用。只要可变引用和不可变引用不存在交集,那么两者可以共存。显然上面代码是无法共存的,因此两者存在交集,如果将最后一行调换一下顺序。

fn main() {
    let mut data = "Hello".to_string();
    let v = &mut data;             // 可变引用
    v.push_str(" World");          // 可变引用
    println!("data: {:?}", data);  // 不可变引用
}

此时代码就正常了,先拿到可变引用,然后追加字符串,最后拿到不可变引用,整个过程没有出现重叠,所以代码合法。那么问题来了,下面这段代码和上面的代码是相似的呀。

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    {
        // 获得 RefCell 内部数据的可变借用
        let mut v = data.borrow_mut();
        *v += 1;
    }
    println!("data: {:?}", data.borrow());  // data: 2
}

这里为啥一定要使用大括号包裹起来呢?非常简单,因为这是运行时检查,编译器无法意识到这一点,所以强制要求同一个作用域不能同时存在可变引用和不可变引用,即使它们不存在交集。

这就是编译期间检查和运行期间检查的区别,本来引用的活跃范围是从创建到销毁(和生命周期保持一致),但编译检查可以将范围缩小为"从创建到最后一次使用",也就是通过检查是否活跃从而让它的作用范围最小化。但运行时检查则不具备这一功能,通过 borrow_mut() 创建的引用,在整个作用域都会处于活跃状态,因此即使不存在交集,也是不合法的。

当然啦,如果没有这对花括号,这段代码是否能编译通过呢?很明显,编译是没有问题的,但运行时会出错,会得到:already mutably borrowed: BorrowError 这样的错误。可以看到,所有权的借用规则在此依旧有效,只不过它在运行时检测。

然后我们再看一个细节,就是在获取 RefCell 可变借用的时候,要使用 mut。

use std::cell::RefCell;

fn main() {
    let mut n = 123;
    let v = &mut n;
    *v += 1;
    println!("{}", n);  // 124

    let n = RefCell::new(123);
    {
        // 这里要使用 mut
        let mut v = n.borrow_mut();
        *v += 1;
    }
    println!("{:?}", n.borrow());  // 124
}

问题来了,为啥一个不用使用 mut,另一个非要使用呢?在第一个例子中:

    let mut n = 123;
    let v = &mut n;
    *v += 1;

变量 v 是一个指向 n 的可变引用,所以我们可以通过它来改变 n 的值。在这种情况下,v 本身不需要背声明为 mut,除非你想对 v 本身进行修改,比如将另一个变量的可变引用赋值给它。因此 v 作为引用始终指向 n,因为是可变引用,所以可以改变 n 的值。但是 v 本身不可变,也就是我们不能将新的变量的可变引用赋值给它。

在第二个例子中:

    let n = RefCell::new(123);
    {
        // 这里要使用 mut
        let mut v = n.borrow_mut();
        *v += 1;
    }
    println!("{:?}", n.borrow());  // 124

这里我们使用的是 RefCell<T>,它是一个提供内部可变性的智能指针,负责在运行时检查借用规则,而不是在编译时。当调用 borrow_mut() 时,会获得一个 RefMut<T> 类型的智能指针,这是一个表示可变借用的类型。要通过这个智能指针修改数据,我们需要使用 * 解引用运算符,就像对待常规引用一样。然而,RefMut<T> 需要被绑定到一个可变的局部变量,因为修改数据实际上是通过 DerefMut trait 调用其 deref_mut 方法来完成的,它需要一个可变的引用来调用。

当你实现 DAG 的时候,节点的类型便可以声明为:

Option<Rc<RefCell<Node>>>

通过这样的嵌套结构,可以让 DAG 的节点实现共享和修改。

Arc 和 Mutex / RwLock

我们用 Rc 和 RefCell 解决了 DAG 的问题,那么,开头提到的多个线程访问同一块内存的问题,是否也可以使用 Rc 来处理呢?答案是不行,因为 Rc 为了性能,使用的不是线程安全的引用计数器。因此我们需要另一个引用计数的智能指针:Arc,它实现了线程安全的引用计数器。

这就有点类似 Python 的引用计数,它为了保证线程安全,引入了 GIL 超级大锁。

Arc 内部的引用计数使用了 Atomic Usize ,而非普通的 usize。从名称上也可以感觉出来,Atomic Usize 是 usize 的原子类型,它使用了 CPU 的特殊指令,来保证多线程下的安全。因此 Rust 实现两套不同的引用计数数据结构,完全是为了性能考虑,从这里我们也可以感受到 Rust 对性能的极致渴求。如果不用跨线程访问,可以用效率非常高的 Rc;如果要跨线程访问,那么必须用 Arc。

同样的,RefCell 也不是线程安全的,如果我们要在多线程中,使用内部可变性,Rust 提供了 Mutex 和 RwLock。这两个数据结构应该都不陌生,Mutex 是互斥量,获得互斥量的线程对数据独占访问,RwLock 是读写锁,获得写锁的线程对数据独占访问,但当没有写锁的时候,允许有多个读锁。读写锁的规则和 Rust 的借用规则非常类似,我们可以类比着学。

Mutex 和 RwLock 都用在多线程环境下,对共享数据访问的保护上。比如要在多线程环境下构建 DAG,则需要把 Rc<RefCell<T>> 替换为 Arc<Mutex<T>> 或者 Arc<RwLock<T>>。更多有关 Arc/Mutex/RwLock 的知识,我们后续详细介绍。

小结

到此刻我们对所有权有了更深入的了解,掌握了 Rc / Arc、RefCell / Mutex / RwLock 这些数据结构的用法。如果想绕过"一个值只有一个所有者"的限制,可以使用 Rc / Arc 这样带引用计数的智能指针。其中,Rc 效率很高,但只能使用在单线程环境下;Arc 使用了原子结构,效率略低,但可以安全使用在多线程环境下。

然而 Rc / Arc 是不可变的,如果想要修改内部的数据,需要引入内部可变性,在单线程环境下,可以在 Rc 内部使用 RefCell;在多线程环境下,可以使用 Arc 嵌套 Mutex 或者 RwLock 的方法。

本文来自于:极客时间陈天《Rust 编程第一课》

posted @ 2023-11-06 13:17  古明地盆  阅读(293)  评论(0编辑  收藏  举报