Rust 如何在没有垃圾收集器的情况下解决内存管理问题?


每个计算机程序都需要内存和管理内存的方法。传统的内存管理范式要么容易出错,要么性能很差。Rust的内存管理系统是独一无二的,因为它无需使用垃圾收集器就能提供内存安全和可预测的高性能。

栈Stack和堆heap
首先,我们需要窥视一下编程语言的结构,了解管理计算机内存的含义。计算机的物理内存被称为RAM,它是一个为运行中的程序(称为进程)存储代码和数据的地方。每个进程都有一个相关的内存,称为其虚拟地址空间,它被划分为不同的区段:

代码:保存CPU指令。一个指令指针(IP)指向要执行的下一条指令。一旦进程开始运行,不会缩小或增长

数据:保存全局数据,如变量和数组。一旦进程开始运行,不会收缩或增长。

堆heap:数据段的动态部分,随着进程的运行而增长或缩小。进程使用低级别的库调用(如malloc)从操作系统中为自己分配一大块内存。这个块必须明确地释放,让操作系统知道你已经用完了它。否则,它将一直被分配,直到进程崩溃;这就是所谓的内存泄漏。

栈stack:存储局部变量和函数参数。与堆类似,栈是动态扩展和收缩的。然而,由于栈的后进先出(LIFO)结构,栈内存被自动管理。当一个函数被调用时,一个新的栈框架被推入栈(自动分配)。而当函数返回时,栈框架被销毁(自动去分配)。

因此,栈内存的分配和删除是快速的,并由底层系统架构自动处理。然而,当一个对象的生命周期超过一个本地函数时,必须使用堆内存。因此,当我们谈论编程中的内存管理时,我们谈论的是管理堆内存。

每种编程语言都有某种机制来管理内存。大多数编程语言使用手动内存管理或垃圾收集来管理计算机内存。从这两种机制中选择使用哪种形式的内存管理机制,通常是在运行中的程序的两个特性之间进行权衡:内存安全和性能。

C语言
像C这样的老式编程语言把管理动态内存,即堆内存的责任放在程序员身上。一个堆内存块可以通过调用低级库函数malloc和free来分配和释放。程序员有责任确保每个不再需要的堆分配的内存块被明确释放。否则,进程将继续吞噬不必要的内存;这被称为内存泄漏。

下面是如何在C++中泄漏大约400MB的内存:

include <cstdio>
include <cstdlib>

void leak(int n) {
    // Allocates memory that is never freed
    int *a = (int*) malloc(n * sizeof(int));
    return;
}

int main() {
    // Program will keep around useless 400MiB of 
    // memory allocated until it is terminated
    for (int i = 0; i < 100000; ++i) {
        leak(1000);
    }

    int input;
    scanf("%d", &input);
}

这只是手动内存管理中出现的许多不同类别的内存相关错误中的一个例子。

在C/C++中,手动内存管理很容易出错,而与内存有关的错误的后果可能是毁灭性的。这就是为什么C、C++和Rust之外的每个主要编程语言都使用垃圾收集。

垃圾回收机制
你每天使用的编程语言很有可能是一种垃圾收集型语言。Java、Python、JavaScript和Go等都是垃圾收集的语言。但什么是垃圾收集?

垃圾收集(GC)是一种运行时机制,自动释放程序不再使用的内存。它的工作原理是在后台定期运行一个单独的程序,称为垃圾收集器。垃圾收集器暂停主程序的执行,并运行一种算法,找到并释放未使用的内存。标记和扫除就是这种算法的一个例子。在高水平上,它遍历所有的引用并标记它们所使用的内存,然后扫除或释放未被标记的内存。垃圾收集是一个复杂的话题,需要几篇博文来解释,所以我们把它留在这里。

然而,值得注意的是,垃圾收集本身是一种开销。它暂停了主线程的执行,以便清扫垃圾内存,这导致了应用程序中不可预知的性能下降。GC的性能缺陷在实时的、对性能要求很高的应用程序中更加明显,当垃圾收集器被安排运行时,它们就不能再执行实时任务。事实上,Discord将他们的一个性能关键型服务从Go切换到Rust,正是因为这个原因。

Rust和RAII
进入Rust。在一个你必须选择内存安全或高性能的世界里,Rust为你提供了两者。Rust有一个自动的内存管理机制,可以在编译时丢弃与内存有关的错误,所以不需要垃圾收集器。

所有权是Rust中引入的一个新概念,它使堆内存的使用感觉像堆内存一样,即自动分配和删除内存。所有权是Rust编译器为了使自动内存管理成为可能而强制执行的一套规则。最重要的所有权规则是,Rust中每个有效的堆分配的值都有一个所有者变量。

在Rust中,我们可以使用Box结构来分配堆上的数据。在下面的片段中,Box::new在堆上创建了一个整数数组,并将其指针存储在变量a中。我们说a是堆上分配的整数数组的所有者。在第二行中,所有权从a转移到b。注意,堆的数据没有被复制。因为一次只能有一个所有者,所以堆上原来由a拥有的同一个整数阵列现在由变量b拥有:

let a = Box::new([0; 1000]);
let b = a;
// println!("{}", a[0]);

如果取消第三行的注释会产生一个编译时错误。

由于任何堆分配的数据只有一个所有者,Rust可以做出这样的假设:如果所有者不再有效,堆分配的值可以被丢弃。因此,每当所有者变量超出范围时,底层的堆内存就会自动去分配。以这种方式将资源(如内存)的分配寿命与对象的寿命绑定在一起,被称为资源获取即初始化(RAII)。RAII意味着内存泄漏的可能性很小,因为当所有者超出范围时,内存会被自动放弃。

下面是一个Rust程序,它的源代码与我们之前看的C++例子程序相同。然而,其行为是不同的。Rust编译器不允许程序员轻易泄露内存:

fn make_and_drop() {
    // Allocates heap-memory whose owner is variable '_a'
    let _a = Box::new([0; 1000]);
    // Memory is deallocated after function ends because
    // the owner variable's lifetime ends
}

fn main() {
    // No memory leak
    for _ in 0..100000 {
        make_and_drop();
    }

    let mut input = String::new();
    std::io::stdin().read_line(&mut input).unwrap();    
}

下面是valgrind的总结,确认所有堆块都被释放了。因此,没有内存泄漏:

valgrind target/debug/noleak
==28617== HEAP SUMMARY:
==28617==     in use at exit: 0 bytes in 0 blocks
==28617==   total heap usage: 100,010 allocs, 100,010 frees, 400,002,157 bytes allocated
==28617== 
==28617== All heap blocks were freed -- no leaks are possible
==28617== 
==28617== For lists of detected and suppressed errors, rerun with: -s
==28617== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)