解密 Rust 的内存管理:从创建到消亡,值都经历了什么?

楔子

我们学习了很多有关所有权和生命周期的内容,想必现在,你对 Rust 内存管理的核心思想已经有足够的理解了。通过单一所有权模式,Rust 解决了堆内存过于灵活、不容易安全高效地释放的问题,既避免了手动释放内存带来的巨大心智负担和潜在的错误,又避免了全局引入追踪式 GC 或者 ARC 这样的额外机制带来的效率问题。

不过所有权模型也引入了很多新概念,从 Move / Copy / Borrow 语义到生命周期管理,所以学起来有些难度。但其实大部分新引入的概念,包括 Copy 语义和值的生命周期,在其它语言中都是隐式存在的,只不过 Rust 把它们定义得更清晰,更明确地界定了使用的范围而已。

由于内存管理是任何编程语言的核心,重要性就像武学中的内功。只有当我们把数据在内存中如何创建、如何存放、如何销毁弄明白,之后阅读代码、分析问题才会有一种游刃有余的感觉。所以本次我们继续沿着之前的思路,梳理和总结 Rust 内存管理的基本内容,然后从一个值的奇幻之旅讲起,看看在内存中,一个值,从创建到消亡都经历了什么,把之前学的融会贯通。

内存管理

之前介绍过栈和堆,它们都负责存储数据,并赋予值动态的生命周期,但两者的适用场景又有所不同。栈内存的分配和释放都很高效,在编译期就确定好了,因而它无法安全承载动态大小或者生命周期超出栈帧存活范围外的值。所以,我们需要运行时可以自由操控的内存,也就是堆内存,来弥补栈的缺点。

堆内存足够灵活,然而堆上数据的生命周期该如何管理,成为了各门语言的心头大患。

C 采用了未定义的方式,由开发者手动控制;C++ 在 C 的基础上改进,引入智能指针,半手工半自动。Java 和 Python 使用 GC 对堆内存全面接管,堆内存进入了受控(managed)时代。所谓受控代码(managed code),就是代码在一个运行时的监督下工作,由运行时来保证堆内存的安全访问。

整个堆内存生命周期管理的发展史如下图所示:

而 Rust 的创造者们,重新审视了堆内存的生命周期,发现大部分堆内存的需求在于动态大小,小部分需求是更长的生命周期。所以它默认将堆内存的生命周期和使用它的栈内存的生命周期绑在一起,并留了个小口子 leaked 机制,让堆内存在需要的时候,可以有超出帧存活期的生命周期。

默认情况下,堆内存一定有一个栈上的指针指向它,因为堆内存无法独立存在,而在 Rust 中栈内存和堆内存是绑定的。当栈内存被销毁时,指针指向的堆内存也会被释放。

我们看下图的对比总结:

有了这些基本的认知,我们再看看在值的创建、使用和销毁的过程中, Rust 是如何管理内存的。了解完本文的内容之后,你在看到一个 Rust 的数据结构,就可以在脑海中大致浮现出这个数据结构在内存中的布局:哪些字段在栈上,哪些字段在堆上,以及它大致的大小。

值的创建

当我们为数据结构创建一个值,并将其赋给一个变量时,根据值的性质,它有可能被创建在栈上,也有可能被创建在堆上。

在探讨内存模型时我们说过,理论上,编译时可以确定大小的值都会放在栈上,包括 Rust 提供的原生类型,比如字符、数组、元组(tuple)等,以及开发者自定义的固定大小的结构体(struct)、枚举(enum)等。如果数据结构的大小无法确定,或者它的大小确定、但是在使用时需要更长的生命周期,就最好放在堆上(当然,不变的部分依旧放在栈上,因此栈上至少会有一个指针指向堆内存,而且除了指针还可以有长度和容量等等)。

所以 Copy 类型的原生类型数据没什么可说的,我们来讨论一下 struct / enum / vec / String 这几种数据结构在创建时的内存布局。

struct

Rust 在内存中排布数据时,会根据每个字段的对齐长度(aligment)将数据进行重排,使其内存大小和访问效率最好。比如一个包含 A、B、C 三个字段的 struct,它在内存中的布局可能是 A、C、B。

为什么 Rust 编译器会这么做呢?

我们先看看 C 语言在内存中表述一个结构体时遇到的问题。来写一段代码,其中两个数据结构 S1 和 S2 都有三个字段 a、b、c,其中 a 和 c 是 u8,占用一个字节,b 是 u16,占用两个字节。S1 在定义时顺序是 a、b、c,而 S2 在定义时顺序是 a、c、b:

#include <stdio.h>

struct S1 {
    u_int8_t a;
    u_int16_t b;
    u_int8_t c;
};

struct S2 {
    u_int8_t a;
    u_int8_t c;
    u_int16_t b;
};

void main() {
    printf("size of S1: %d, S2: %d", sizeof(struct S1), sizeof(struct S2));
}

正确答案是:6 和 4,为什么明明只用了 4 个字节,S1 的大小却是 6 呢?这是因为 CPU 在加载不对齐的内存时,性能会急剧下降,所以要避免用户定义不对齐的数据结构时,造成的性能影响。

为了提高效率,计算机从内存中读取数据都是按照一个固定长度的来读取的。以 32 位机为例,它每次取 32 个位,也就是 4 个字节。并且每个总线周期都是从偶地址开始读取 32 位的内存数据,如果数据存放地址不是从偶数开始,则可能出现需要两个总线周期才能读取到想要的数据。

以 int 型数据为例,如果它在内存中存放的位置按 4 字节对齐,也就是说 1 个 int 的数据全部落在计算机一次取数的区间内,那么只需要取一次就可以了。如果不对齐,很不巧,这个 int 数据刚好跨越了取数的边界,这样就需要取两次才能把这个 int 的数据全部取到(因为每次都取 4 字节,所以还要丢弃不属于 int 的数据),这样效率就降低了。

对于这个问题,C 语言会对结构体会做这样的处理:

  • 首先确定每个字段的长度和对齐长度,原始类型的对齐长度和类型的长度一致
  • 每个字段的起始位置要和它对齐长度对齐,如果无法对齐,则添加 padding 直至对齐
  • 结构体的对齐大小和最大字段的对齐大小相同,而结构体的长度则四舍五入到它对齐的倍数

字面上看这三条规则,会觉得像绕口令,别担心,我们结合刚才的代码再来看,其实很容易理解。

struct S1 {
    u_int8_t a;
    u_int16_t b;
    u_int8_t c;
};

对于 S1,字段 a 是 u8 类型,所以其长度和对齐长度都是 1,b 是 u16,其长度和对齐长度是 2。然而因为 a 只占了一个字节,b 的偏移是 1,根据第二条规则,起始位置和 b 的长度无法对齐,所以编译器会添加一个字节的 padding,让 b 的偏移为 2,这样 b 就对齐了。

随后 c 长度和对齐长度都是 1,不需要 padding,这样算下来,S1 的大小是 5。但根据上面的第三条规则,S1 的对齐长度是 2,和 5 最接近的 2 的倍数是 6,所以 S1 最终的长度是 6。其实,这最后一条规则是为了让 S1 放在数组中,可以有效对齐。

所以,如果结构体的定义考虑地不够周全,会为了对齐浪费很多空间。我们看到,保存同样的数据,S1 和 S2 的大小相差了 50%。因此使用 C 语言时,定义结构体的最佳实践是,充分考虑每一个字段的对齐,合理地排列它们,使其内存使用最高效。但这个工作由开发者做会很费劲,尤其是嵌套的结构体,需要仔细地计算才能得到最优解。

而 Rust 编译器替我们自动完成了这个优化,这就是为什么 Rust 会自动重排你定义的结构体,来达到最高效率。我们看同样的代码,在 Rust 下,S1 和 S2 大小都是 4。

use std::mem::{align_of, size_of};

struct S1 {
    a: u8,
    b: u16,
    c: u8,
}

struct S2 {
    a: u8,
    c: u8,
    b: u16,
}

fn main() {
    println!("sizeof S1: {}, S2: {}", size_of::<S1>(), size_of::<S2>());
    println!("alignof S1: {}, S2: {}", align_of::<S1>(), align_of::<S2>());
    /*
    sizeof S1: 4, S2: 4
    alignof S1: 2, S2: 2
     */
}

我们也可以画张图,对比一下 C 和 Rust 的行为:

虽然 Rust 编译器默认为开发者优化结构体的排列,但你也可以使用 #[repr] 宏,强制让 Rust 编译器不做优化,和 C 的行为一致,这样可以让 Rust 代码方便地和 C 代码无缝交互。

在明白了 Rust 下 struct 的布局后( tuple 类似),我们看看 enum 。

enum

enum 在 Rust 里面是一个标签联合体(tagged union),它的大小是标签的大小加上最大类型的长度。

enum MyEnum {
    A(u32),
    B(f64, u64),
    C { x: u8, y: u8 },
}

这个枚举有三个变体:

  • A 包含一个 u32,占用 4 字节
  • B 包含一个 f64 和一个 u64,因为 f64 和 u64 各占用 8 字节,所以总共是 16 字节
  • C 包含两个 u8,每个占用 1 字节,但可能因为对齐而实际占用更多的空间

在这个例子中,B 变体是最大的,占用16字节。因此,MyEnum 枚举的大小至少是 16 字节。但是枚举还需要额外的空间来存储用于区分这三个变体的标签,通常一个 u8 足够了,但如果你的枚举变体超过了 256 个,那么就需要 u16,不过这种情况很少。

enum MyEnum {
    A(u32),
    B(f64, u64),
    C { x: u8, y: u8 },
}

fn main() {
    println!("{}", std::mem::size_of::<MyEnum>())  // 24
}

我们看到总共需要 24 字节,虽然标签只需要一个 u8 即可存储,但枚举最大类型的长度超过了 8 字节。在 64 位 CPU 下,标签会按照 8 字节对齐,所以它会加上自己额外的 padding 凑够 8 字节,因此总共是 24 字节。

enum MyEnum {
    A(u32),
    B(f64, u64),
    C { x: u8, y: u8 },
}

fn main() {
    // 介绍结构体的时候说过:
    // 每个域的起始位置要和其对齐长度对齐,如果无法对齐,则添加 padding 直至对齐

    // u8 是 1 字节,所以标签只需 1 字节
    println!("{}", std::mem::size_of::<Option<u8>>());  // 2
    // u16 是 2 字节,标签后面也要填充 padding 对齐成 2 字节
    println!("{}", std::mem::size_of::<Option<u16>>());  // 4
    // u32 是 4 字节,标签后面也要填充 padding 对齐成 4 字节
    println!("{}", std::mem::size_of::<Option<u32>>());  // 8
    // u64 是 8 字节,标签后面也要填充 padding 对齐成 8 字节
    println!("{}", std::mem::size_of::<Option<u64>>());  // 16
    // u128 是 16 字节,标签依旧按照 8 字节对齐
    // 因为在 64 位系统,标准数据类型的最大对齐数就是 8 字节
    println!("{}", std::mem::size_of::<Option<u128>>());  // 24
}

以上就是枚举,你也可以自己创建几个更复杂的枚举,自己分析一下。

Vec<T> 和 String

如果你查看过 String 的源码,应该会意识到 Vec<u8> 和 String 的大小是一样的,因为 String 的内部就是一个 Vec<u8>。

我们看一下它们占多少个字节。

fn main() {
    println!("{}", std::mem::size_of::<String>());  // 24
    println!("{}", std::mem::size_of::<Vec<u8>>());  // 24
    println!("{}", std::mem::size_of::<Vec<u16>>());  // 24
    println!("{}", std::mem::size_of::<Vec<String>>());  // 24
}

所以我们只需要分析 Vec<T> 即可,然后我们看到不管 T 是什么类型,Vec 的大小都是 24 字节。这是因为 Vec 的动态数据都存储在堆上,而栈上的数据是固定的,分别是:指向堆内存的指针、堆内存的长度、堆内存的容量。说白了栈上存储的就是一个结构体,结构体内部保存了指向堆内存的指针,只不过它除了指针还有其它字段,我们称这样的结构体实例为胖指针(fat pointer)。

因为很多数据结构光有一个指向堆区的指针是不够的,比如切片,它除了指针之外还需要有长度。因此这个时候就可以把指针和长度封装起来,组成一个结构体实例,而这个结构体实例就叫做胖指针。至于我们平时说的指针,就是一个保存了地址的 8 字节数据,胖指针则是将指针和其它元数据(比如长度、容量)封装起来的结构体实例。只不过这个结构体实例在 Rust 里面实现了 Deref trait,可以通过解引用操作符 * 进行解引用,因此我们称它为胖指针。

use std::ops::Deref;

struct Point {
    x: i32,
}

impl Deref for Point {
    type Target = i32;

    fn deref(&self) -> &Self::Target {
        &self.x
    }
}

fn main() {
    let p = Point{x: 3};
    println!("{}", *p);  // 3 
}

很多动态大小的数据结构,在创建时都有类似的内存布局:栈上存放胖指针,指向堆内存分配出来的数据,我们之前介绍的 Rc 也是如此。

关于值在创建时的内存布局,目前就先讲这么多。如果你对其它数据结构的内存布局感兴趣,可以访问 https://cheats.rs/ ,它是 Rust 语言的备忘清单,非常适合随时翻阅。

现在值已经创建成功了,我们对它的内存布局有了足够的认识。那在使用期间,它的内存会发生什么样的变化呢,我们接着看。

值的使用

在讲所有权的时候我们说过,对于 Rust 而言,一个值如果没有实现 Copy,那么在赋值、传参以及函数返回时会被 Move。其实 Copy 和 Move 在内部实现上,都是浅层的按位做内存复制,只不过 Copy 允许你访问之前的变量,而 Move 不允许。

在我们的认知中,内存复制是个很重的操作,效率很低。确实是这样,如果你的关键路径中的每次调用,都要复制几百 k 的数据,比如一个大数组,是很低效的。但如果你要复制的只是原生类型(Copy)或者栈上的胖指针(Move),不涉及堆内存的复制、也就是深拷贝(deep copy),那这个效率是非常高的,我们不必担心每次赋值或者每次传参带来的性能损失。

介绍内存模型的时候说过,栈上申请内存的效率是极高的,动一动栈指针,空间就出来了,将栈指针改回去,空间就释放了。整个过程只是动一动寄存器,效率是非常高的,而且这个过程由操作系统自动维护,不需要我们关心。所以栈上的数据传递,永远都是拷贝一份。

如果实现了 Copy,数据全在栈上,那么拷贝之后彼此独立。如果数据涉及到堆,那么依旧会产生拷贝,只不过拷贝的是栈上的胖指针,堆数据不拷贝,而这个过程在 Rust 里面叫 Move。一旦 Move,那么所有权就发生了转移,原来的变量就不能用了。

所以无论是 Copy 还是 Move,它的效率都是非常高的,因为只是拷贝了一份栈数据,而栈的效率是极高的。只不过 Copy 之后两个数据彼此独立,而 Move 之后由于两个胖指针都指向了同一份堆内存,会发生移动,导致原有的变量失效。

注意:虽然栈的效率很高,但空间也有限,因此不建议将特别大的值放在栈上。比如一个包含大量元素的大数组,如果要进行传递,由于需要复制整个数组(栈上数据只有拷贝),也会影响效率。所以,一般我们建议在栈上不要放大数组,如果实在需要,那么传递这个数组时,最好用传引用而不是传值。

然后在使用值的过程中,除了 Move,你还需要注意值的动态增长。因为 Rust 下,集合类型的数据结构,都会在使用过程中自动扩展。以一个 Vec<T> 为例,当你使用完堆内存目前的容量后,还继续添加新的内容,就会触发堆内存的自动增长(也就是扩容)。有时候,集合类型里的数据不断进进出出,导致集合一直增长,而最终集合的长度却远远小于容量,造成内存的使用效率很低,所以你要考虑使用 shrink_to_fit 等方法,来节约对内存的使用。

值的销毁

值的创建和使用都说完了,最后来谈谈值的销毁。之前笼统地谈到,当所有者离开作用域,它拥有的值会被丢弃。那从代码层面讲,Rust 到底是如何丢弃的呢?

这里用到了 Drop trait,Drop trait 类似面向对象编程中的析构函数,当一个值要被释放,它的 Drop trait 会被调用。比如下面的代码,变量 greeting 是一个字符串,在退出作用域时,其 drop() 函数被自动调用,会释放栈上的内存,以及堆上包含 "hello world" 的内存。

如果要释放的值是一个复杂的数据结构,比如一个结构体,那么这个结构体在调用 drop() 时,会依次调用每一个字段的 drop() 函数,如果字段又是一个复杂的结构或者集合类型,就会递归下去,直到每一个字段都释放干净。

struct Student {
    name: String,
    age: u8,
    scores: HashMap<String, u8>
}

比如 Student 结构体实例,有 name、age、scores 三个字段,其中 name 是 String,scores 是 HashMap,它们本身需要额外调用 drop()。又因为 HashMap 的 key 是 String,所以还需要进一步调用这些 key 的 drop()。整个释放顺序从内到外是:先释放 HashMap 下的 key,然后释放 HashMap 堆上的表结构,最后释放栈上的内存。

当然我们也可以自己实现 drop。

use std::collections::HashMap;

struct Student {
    name: String,
    age: u8,
    scores: HashMap<String, u8>
}
impl Drop for Student {
    fn drop(&mut self) {
        println!("结构体实例要被释放了,它的 name 是 {},age 是 {},scores 是 {:?}",
                 self.name, self.age, self.scores);
    }
}

fn main() {
    let stu = Student{
        name: String::from("古明地觉"),
        age: 17,
        scores: HashMap::from(
            [("语文".to_string(), 95),
                ("数学".to_string(), 100),
                ("英语".to_string(), 91)]
        )
    };
}  // 结构体实例要被释放了,它的 name 是 古明地觉,age 是 17,scores 是 {"英语": 91, "语文": 95, "数学": 100}

所以 Rust 一切皆类型,由 trait 定义类型的行为逻辑。比如希望结构体可以解引用,那么就实现 Deref trait,希望自定义析构逻辑,那么就实现 Drop trait。

再比如你希望自定义拷贝逻辑,那么只需要 Clone 这个 trait 即可。

#[derive(Debug)]
struct Student {
    name: String,
    age: u8,
}

impl Clone for Student {
    fn clone(&self) -> Self {
        Self {name: self.name.to_uppercase(), age: self.age + 1}
    }
}

fn main() {
    let stu1 = Student {
        name: String::from("satori"),
        age: 17,
    };
    let stu2 = stu1.clone();
    println!("{:?}", stu1);  // Student { name: "satori", age: 17 }
    println!("{:?}", stu2);  // Student { name: "SATORI", age: 18 }
}

还是比较神奇的,我们明明只是拷贝一份,但拷贝之后的结果却变了,原因就是我们重新实现了 Clone trait。

堆内存释放

所有权机制规定了,一个值只能有一个所有者,所以在释放堆内存的时候,整个过程简单清晰,就是单纯调用 Drop trait,不需要有其他顾虑。这种对值安全,也没有额外负担的释放能力,是 Rust 独有的。

在 Rust 的内存管理中,所有个体的行为都遵循着非常简单死板的规范,最终大量简单的个体能构造出一个高效且不出错的系统。反观其它语言,每个个体或者说值,都非常灵活,引用传来传去,最终却构造出来一个很难分析的复杂系统。单靠编译器无法决定,每个值在各个作用域中究竟能不能安全地释放,导致系统要么像 C/C++ 一样将这个重担部分或者全部交给开发者,要么像 Java 那样构建另一个系统来专门应对内存安全释放的问题。

在 Rust 里,你自定义的数据结构,绝大多数情况下,不需要实现自己的 Drop trait,编译器缺省的行为就足够了。但是,如果你想自己控制 drop 行为,你也可以为这些数据结构实现它。如果你定义的 drop() 函数和系统自定义的 drop() 函数都 drop() 某个相同的字段,Rust 编译器会确保这个字段只会被 drop 一次。

释放其它资源

我们刚才讲 Rust 的 Drop trait 主要是为了应对堆内存释放的问题,但其实它可以释放任何资源,比如 socket、文件、锁等等,Rust 对所有的资源都有很好的 RAII 支持。

比如我们创建一个文件 file,往里面写入 "hello world",当 file 离开作用域时,不但它的内存会被释放,它占用的资源、操作系统打开的文件描述符,也会被释放,也就是文件会自动被关闭。

use std::fs::File;
use std::io::prelude::*;
fn main() -> std::io::Result<()> {
    let mut file = File::create("foo.txt")?;
    file.write_all(b"hello world")?;
    Ok(())
}

在其它语言中,无论 Java、Python 还是 Golang,你都需要显式地关闭文件,避免资源的泄露。这是因为,即便 GC 能够帮助开发者最终释放不再引用的内存,它并不能释放除内存外的其它资源。而 Rust 再一次地,因为其清晰的所有权界定,使编译器清楚地知道:当一个值离开作用域的时候,这个值不会有任何人引用,它占用的任何资源,包括内存资源,都可以立即释放,而不会导致问题。

说到这,你也许觉得不用显式地关闭文件、关闭 socket、释放锁,不过是省了一句 close() 而已,有什么大不了的?然而不要忘了,在庞大的业务代码中,还有很大一部分要用来处理错误。当错误处理搅和进来,我们面对的代码逻辑更复杂,需要添加 close() 调用的上下文更多。虽然 Python 的 with、Golang 的 defer,可以在一定程度上解决资源释放的问题,但还不够完美。

因为多个变量和多种异常或者错误叠加,我们忘记释放资源的风险会成倍增加,很多死锁或者资源泄露就是这么产生的。从 Drop trait 中我们再一次看到,从事物的本原出发解决问题,会极其优雅地解决掉很多其它关联问题。比如所有权,几个简单规则,就让我们顺带处理掉了资源释放的大难题。

小结

以上我们就进一步探讨了 Rust 的内存管理,在所有权和生命周期管理的基础上,介绍了一个值在内存中创建、使用和销毁的过程,学习了数据结构在创建时,是如何在内存中布局的,大小和对齐之间的关系;数据在使用过程中,是如何 Move 和自动增长的;以及数据是如何销毁的。

数据结构在内存中的布局,尤其是哪些部分放在栈上,哪些部分放在堆上,非常有助于我们理解代码的结构和效率。当我们掌握了数据结构如何创建、在使用过程中如何 Move 或者 Copy、最后如何销毁,我们在阅读别人的代码或者自己撰写代码时就会更加游刃有余。

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

posted @ 2023-11-08 01:03  古明地盆  阅读(639)  评论(0编辑  收藏  举报