对比学习 Go 和 Rust:内存管理

2022年6月2日 // Go Rust 编程

Go 和 Rust 都是内存安全的语言,但两者的实现路径却存在巨大的差异,这些几乎成了这两种语言的最大差异,并对他们的设计产生了深远的影响。内存管理涉及很多的内容,尽管本文的篇幅也不小,但离讲透彻还差很远——它只是对这两种语言内存管理方法的概述。

关于内存管理

内存管理是管理计算机内存资源的方式,其主要目标是在需要时动态地分配内存,在不需要时释放内存以便其能被重新利用。在现代计算机系统中,应用程序使用的内存是操作系统划分出来的虚拟内存。尽管实际上物理内存通常被分隔成多个内存片段,但应用程序包含的进程所看到则是连续的虚拟内存地址,这些虚拟地址构成虚拟地址空间(或称地址空间),这里说的内存管理实际上是对这些虚拟地址空间的管理。在程序运行时,其所使用的内存是动态分配和释放的。其方法说起来很简单,当需要某些数据时,就通过变量初始化为他们设定存储空间及其中存储的值,变量变得可用;而当不需要这些数据时,就收回他们的存储空间,变量也不再可用。该过程看似简单,其实很复杂,因为其实际操作过程中会涉及许多问题,例如各种对象的生存时间是不一样的,必须仅在不再使用时才释放内存;不再使用的对象必须释放,否则会造成内存泄漏,且释放得越及时越好;对于大的数据,为了提高效率,要避免频繁的复制操作;变量间会发生赋值操作,要根据数据类型的不同而决定复制变量值,还是复制变量对数据的引用;变量一旦离开其作用域,就应不再可用;对于并行或并发运行的程序,应防止变量在其他线程(或其他并发单元)中被不可预知地修改。各种编程语言的内存管理机制应同时解决所有这些问题,尽量做到以较低的开销分配和释放内存,以及快速访问特定位置的内存。为了达成以上目标,应用程序会以一定的数据结构来组织内存,这种数据结构一般同时包括栈(Stack,也称堆栈)和堆(Heap)。

栈是一种线性的数据结构,常用一维数组或链表来实现,只能在栈的顶端加入或移出数据,数据加入、移出栈是按照后进先出的规则进行的。栈数据结构的这种特征使其特别适用于动态分配和释放链式调用的子程序所需要的内存:当调用子程序时,传递给子程序的实参和子程序中的局部变量被压入栈中,在子程序调用结束是这些数据又被移出栈;越靠后被调用的子程序,其使用的内存放置位置越靠近栈的顶端;同时由于后来被调用的子程序总是最先退出,这时其使用的内存也最先被移出栈。如果需要持续保存子程序产生的放在栈中的数据,就将其复制一份,传回给其调用者。CPU 本身支持数据的入栈和出栈,因此在栈上分配内存的速度很快。但使用栈也存在问题:在子程序执行过程中,单个局部变量需要的内存大小在编译时有时是不固定的,而放在栈中的数据,是不能动态改变其占用内存大小的;有时子程序所需要的内存空间很大,当需要保留这些数据时,重新复制他们会非常低效。因此,一般只在栈中保存占用内存大小固定的小型数据。

为了解决栈内存在使用上的不足,会同时使用堆来管理内存。堆内存实际上就是应用程序可以自由分配和释放的地址空间,完全不必像栈内存那样必须按照后进先出的顺序,这就像从书架上取书一样可以随意地进行。之所以叫作堆,是因为使用堆这种数据结构来管理内存。堆具有树状结构,它通常使用准完全二叉树来实现。相对于栈,堆一般用来存储大型的、尺寸动态变化的数据,其存取效率要低一点。堆的实现比较复杂。不同的编程语言对堆内存的管理方法并不一样,一般分为两种:手动内存管理和自动内存管理。

手动内存管理指的是由程序设计者手动下达指令来标记并释放不再被使用的对象或垃圾,C、C++ 语言都是使用手动内存管理方法。手动内存管理能精确地控制内存资源,开销小,因此程序的尺寸小、性能强。但该方法很容易导致程序出现内存安全问题。手动内存管理程序的编写相对也更为复杂。

自动内存管理就是由程序的运行时环境管理内存。多数编程语言都支持一定程度上的自动内存管理,如前面所说的存储在栈上的局部变量就属于自动内存管理。但对于存储在堆上的数据,要实现自动内存管理,需要某种垃圾回收机制。垃圾回收是一种自动探测程序中不再需要的内存,并将其释放到自由内存池的机制。自动垃圾回收能减少程序员的工作量,并保证内存安全。但自动垃圾回收器(Garbage Collector, GC)的运行本身会造成一定的开销。像 Java、C#、JavaScript、Python、Go 等语言都具有自动垃圾回收能力。

内存管理总体策略

Go:自动垃圾回收

要明白 Go 如何进行内存管理,要先从其运行时开始说起。每个 Go 二进制程序都带有一个运行时,它主要包括两部分:调度器(Scheduler)和垃圾回收器(GC)。运行时主要进行垃圾回收、并发控制、堆栈管理等工作。不过,由于 Go 程序被预先编译为机器码,而不像其他托管语言那样采取 JIT 重编译,因此,Go 的运行时并不像其他托管语言那样包含虚拟机,Go 语言的运行时库更像 C 语言的 libc 库。

先说栈内存的管理。Go 支持 goroutine 这种如同轻量级的线程一样的东西,程序中可能有成千上万个 goroutine,每个 goroutine 都有自己独立的栈,这些成千上万的栈由调度器管理。由于 goroutine 的个数可能很多,他们各自对应的栈尺寸不可能很大。为了使栈保持小尺寸,Go 运行时使用可调整大小的有界栈。一个新的 goroutine 会被赋予几千字节的栈内存,多数情况下,这已经够用了。如果栈尺寸不合适,运行时会自动增加(或减少)栈容量,因此一般不会因栈空间不够而发生栈溢出。再加上每个函数调用的 CPU 开销都比较廉价(平均约三条指令),从而允许大量 goroutine 驻留在内存中。

再说堆内存的管理。Go 使用自动内存管理策略,它使用一种非分代的、并发的、三色标记清除(mark-and-sweep)的垃圾回收器(GC)。当一个对象不被任何变量所引用时,该对象在接下来的时间内就可能被 GC 清除。这种清除不是立刻进行的,而是由 GC 根据一定的触发条件定期清除。当目标机器具有多处理器时,GC 运行在一个单独的 CPU 上,与主程序呈并行关系。垃圾回收工作相当复杂,这里不具体介绍,感兴趣的可以看看 Go 的代码及文档以及其他文档。标记清除式垃圾回收的特点是占用空间小,但会暂停程序执行(stop the world)。经过多年的开发,目前由于垃圾收集造成的执行停顿已经被缩减到亚毫秒级(不到一毫秒)。除此之外,Go 还给予程序员足够多的控制内存布局和分配的权力,只要使用得当,就可以大幅降低垃圾回收开销。

Rust:所有权系统

Rust 没有运行时,因此不像 Go 那样由运行时的调度器管理栈,而是像大多数语言一样,由应用程序调用操作系统的函数管理栈,每个线程都保留有相对独立的栈内存空间。

同样由于没有运行时,也就没有垃圾回收器,导致 Rust 不能像 Go 那样对堆上的数据进行自动垃圾回收。Rust 也不像 C/C++ 那样需要手动申请和释放内存,而是通过独特的所有权系统管理内存。编译器在编译时会根据一系列的规则进行检查,确定所有对象的作用域与生命周期,从而能精确地做到当对象不再使用时将其销毁,且保证这种管理方式的开销相当小。具体来说,Rust 使用资源获取即初始化以及可选的引用计数方式管理内存。

资源获取即初始化(Resource Acquisition Is Initialization,RAII)是面向对象编程语言的一种惯用法,要求资源的有效期与持有资源的对象的生命期严格绑定。即对象初始化时,由对象的构造函数完成资源的分配(获取);对象释放时,由析构函数完成资源的释放。RAII 的字面意思可能会让人产生误解,它实际上更强调资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。在 Rust 中,存储在堆上的对象所占的内存资源是与某个变量的生命周期严格绑定的,即变量拥有其所对应的内存资源,一旦超过该变量的作用域,其对应拥有的内存将会被强制释放。具体来说,Rust 中所有的大括号包括的代码块都是一个独立的作用域,作用域内的变量在离开作用域时会失效,而变量绑定的数据(无论绑定的是堆内数据还是栈中数据)则自动被释放。

当然,若变量所绑定的对象内存总是在变量离开作用域时就被释放,这显然是无法满足实际需求的。因为,编程时经常需要将堆上的对象传入或传出作用域,即这些对象需要跨作用域存在。为了使对象跨作用域存在,有时需要将他们的所有权从一个变量移动给另外一个变量,原来的变量因失去所有权而失效;也可以不移动所有权,而只是允许多个变量共同引用一个对象,即由其他变量临时借用一个有主的对象。这就是 Rust 独有的所有权系统,其规则并不算太复杂,但因和常规的编程方法不一样而让人较难适应。Rust 正是借助这一套所有权系统,实现了高效、安全的内存管理。

指针和引用

以上已经写了很多文字内容了,可能有点枯燥,不过为了后续能进一步把问题讲清楚,我们还需要简单地讲一下指针和引用的概念:

  • 指针(Pointer):存储内存地址的对象。指针是一种引用。
  • 引用(Reference):一个使程序间接地访问特定数据的值。引用是一种抽象数据类型,它有多种实现方式,指针是实现引用的方式之一。
  • 解引用(Dereference):访问被引用数据的操作。
  • 指针运算(Pointer Arithmetic):通过算术运算修改指针目标地址的操作,如指针变量加/减一个整数,指针变量相加/减,指针变量比较。

Go 和 Rust 都支持指针。请分别看如下简单的 Go 和 Rust 代码:

i := 33
var p *int = &i
fmt.Println(*p)
let i = 33;
let p: &i32 = &i;
println!("{}", *p);

其中 p 是指针,*int&i32 分别是 Go 的指针类型Rust 的指针类型(请注意,Go 的指针类型是在类型前面加 *,而 Rust 则加 &,Rust 的一致性更好一点),&i 中的 & 是取地址运算符,*p 是在指针变量 p 前面加 *,它对此变量进行取值操作,即解引用。其实按一致性来说,解引用更应该使用 p*,但 * 本身还表示两个数相乘,所以最终还是使用 C 语言的解引用风格,即把 * 放在指针变量的前面。

以上 Rust 代码中的 *p 也可以写作 p,如下所示:

let i = 33;
let p: &i32 = &i;
println!("{}", p);

这是 Rust 编译器自动对 p 进行解引用,Go 在一些场合也会进行自动解引用(但这里不行),自动解引用可使代码更美观。

有了指针,就不需要装箱和拆箱,减少运算开销。指针在两种语言中还有很多其他用途。

在 Go 中,除了可以把一个变量或函数的入口地址赋值给一个指针变量之外,没有其他指针运算。没有指针运算,可以防止指针指向一个非法地址。编译器和硬件技术已经发展到了循环使用数组下标比循环使用指针运算更有效率的地步,因此没有指针运算并不会造成性能损失。 此外,指针运算的缺失还可以简化 Go 的 GC 的实现。

在 Rust 中,以上的 &i 被称作引用或借用,此处的代码只是按照常规的指针运算方法进行编程,发现能正常运行。其实该语言关于指针(引用)有更多可讲的,这些正是构成 Rust 所有权系统的关键。

除此之外,Go 中的数据类型大致可分为值类型和引用类型。其中值类型包括:所有整型、所有浮点型、布尔型、字符串、数组、结构体,各个值类型分别有各自的零值,值类型可以使用内建的 new 函数来初始化。引用类型包括:切片(slice)、映射(map)、信道(channel)、接口(interface)、函数(func)、指针(pointer),引用类型的零值都是 nil,其中的切片、映射和信道可以使用内建的 make 函数初始化。不过,严格来说,Go 中并没有引用类型。Go 语言规范中把指针看作是特殊的值,即指针值。在多数的 Go 编译器实现中,切片、映射和信道往往是用结构体实现的,其内部包括非导出的指针字段。当将一个所谓的引用类型变量赋值给另外一个变量时,执行的是浅拷贝,两个变量中的指针字段将指向相同的数据。这样,整体看起来,两个变量都引用量相同的底层数据,使得引用类型值表现得如同指针一样。

分配内存

本节将讨论具体对各种使用场景的对象,究竟是存储在栈上还是堆上。

Go:逃逸分析

Go 的编译器会尽量将函数内的局部变量分配在栈上。当编译器无法确定变量在函数返回后是否还被引用时,将会在堆上为其分配内存,从而避免悬垂指针错误。另外,如果局部变量很大,更可能将其放置在堆上而非栈上。在当前编译器实现中,如果对一个变量进行了取地址操作,则有可能将其分配在堆上。通过编译期间的逃逸分析,能够识别出变量在函数返回后是否还将生存,若不复存在,会将其放置在栈上。因此,并不是值类型就一定会放在栈上,而引用类型就一定放在堆上。

对 Go 程序员来说,可以不关注变量是在栈上或堆上分配也能编写出正确的程序,存储位置的选择是由编译器的实现方式决定的,与语言的语义无关。不过存储位置的确会影响程序的运行效率,并且可以通过调整代码来控制变量是存储在栈上还是堆上。比如,在定义函数或方法时,参数、返回值、接收者(仅对方法)应该是值还是指针是值得斟酌的。如对函数参数,通常仅在如下情景中才使用指针:需要对此参数作出对外界可见的修改,且参数不是切片和映射类型;参数很大,需要避免在传参时进行复制;参数虽然为切片或映射类型,但仍然需要对其对应的结构体进行某些改变,如改变切片的长度。对于方法的接收者来说,其考量与参数类似,但方法接受者同时还要考虑同一类型各个方法接收者的一致性,即同时都用值或同时都用指针。对于返回值,主要应考虑的是数据的大小。总之,有一点必须时刻记在心中:多数情况下,值的传递是非常高效的;而用指针则会使对象逃逸到堆上,进而需要依靠 GC 进行垃圾回收,这可不廉价。

以下示例将演示一下栈上数据是如何逃逸到堆上的:

package main

func main() {
	b := aNum()
	*b = 44
}

func aNum() *int {
	a := 33
	return &a // 由于对 a 进行取地址操作,它将被移动到堆上
}

以上 aNum 函数中,我们没有直接返回 a, 而是返回指向 a 的指针,这是有意的,主要是为了看看本该存储在栈上的数据如何逃逸到堆上。可以使用 go run -gcflags "-m -l" 形式的命令查看逃逸分析情况,其中 -m 打印逃逸分析信息,-l 禁止内联编译。如下所示:

% go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:9:2: moved to heap: a

目前 Go 的官网上并没有给出具体的逃逸分析规则,感兴趣者多看看相关文章,或者多实验一下。

Rust:智能指针

Rust 和 C/C++ 类似,它默认将对象放在栈上。要将对象放在堆上,需要通过智能指针实现。智能指针是一种数据结构,他们的行为类似于指针,但拥有额外的元数据和附加功能。Rust 有多种智能指针,如 StringVec<T> 就是。智能指针的详细说明将留待后续讲述,为了说明内存资源分配的方式,本文只讲述一种最简单的智能指针——装箱(Box<T>)。通过装箱,可以实现将数据存储在堆上,并在栈中保留一个指向堆数据的指针。创建一个 Box<T> 类型的变量很简单:

fn main() {
    let b = Box::new(33);
    println!("{}", *b);
}

以上代码将值 33 放置到堆上,变量 b 指向该值。同前面普通指针的自动解引用一样,这里 println!("{}", b); 中的 *b 也可以是 b

StringVec<T> 类型其实都是存储在栈上的结构体,他们其中的一个字段是指向堆上数据的指针。这一点与 Go 的切片和映射有点类似,不过 Rust 中对应的指针字段肯定指向堆,而 Go 则不一定。

好了,同样来看一个和前面逃逸分析中的 Go 代码类似的例子:

fn main() {
    let b = a_num();
    println!("b = {}", b);
}

fn a_num() -> &i32 {
    let a = 33;
    &a
}

该段代码无法通过编译,显示错误信息如下:

error[E0106]: missing lifetime specifier
 --> src/main.rs:6:15
  |
6 | fn a_num() -> &i32 {
  |               ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
6 | fn a_num() -> &'static i32 {
  |               ~~~~~~~~

原因是 a_num 函数中的局部变量 a 是被存储在栈上的,它不能像 Go 那样自动逃逸到堆上,导致它在函数退出时就被释放,因此该函数返回的指向 a 的引用就是无效的,该引用超过了变量的生命周期。此问题该怎么修复?让我们尝试用装箱把变量 a 的值分配在堆上试试:

fn main() {
    let b = a_num();
    println!("b = {}", b);
}

fn a_num() -> &Box<i32> {
    let a = Box::new(33);
    &a
}

仍然无法编译,错误信息和上一段代码类似,即仍然是生命周期问题。要解决此问题,请继续往下看。

释放堆内存

现在再次具体讨论一下堆内存资源是如何释放的。由于 Go 有 GC,因此不需要我们去花心思关注内存释放,那这里就只讲 Rust。

前面说过,Rust 使用 RAII 的方式释放堆内存。这样堆变量就和栈变量类似,即离开作用域就变得无效,同时堆变量值被丢弃(Drop)。请注意这种内存释放时机都是在编译期间通过静态分析后预先确定的,这不会引入任何运行时复杂度,再一次实现了零成本抽象。这听起来固然很好,但却不能满足堆变量的基本使用目标。例如,要是有多个不同作用域(生命周期)且指向相同堆中对象的指针变量,如果有第一个变量因离开作用域导致堆中内存被释放,再次访问其他变量将会出错,这就是所谓的悬垂指针问题。

在上一段代码中,a 在函数 a_num 执行结束后就被释放,此时再返回指向它的指针 &a,其所指向的内容已经无效了。这正是悬垂指针问题,因此当然不能编译通过了。

为了解决包括以上悬垂指针在内的一系列问题,Rust 规定智能指针变量不仅会指向堆上的值,同时还拥有该值。这样规定是为了明确绑定关系,即堆中的值不能在其拥有者已无效时还有效。非指针类型的栈变量和其值总是一对一关系,因此这种所有关系同样对栈变量天然有效。这种规定就是 Rust 的所有权规则,可以表述如下:

  • Rust 中的每一个值都有一个被称为其所有者的变量。
  • 值在任一时刻有且只有一个所有者。
  • 当所有者(变量)离开作用域时,这个值将被丢弃。

但前面的悬垂指针问题仍然没有解决啊!别急,因为 Rust 还规定智能指针变量不仅能拥有值,还能转让该值的所有权,即所谓所有权的移动。如何移动值的所有权呢?很简单,通过赋值就可以了,如下所示:

fn main() {
    let a = Box::new(33);
    let b = a;
    // println!("a = {}", a);
    println!("b = {}", b);
}

当将智能指针变量 a 赋值给 b 时,b 将获得堆上的值 33 的所有权,同时 a 因失去所有权而变得无效。因此上面被注释的 // println!("a = {}", a); 行是无效的。

同样,我们可以把函数内局部智能指针变量的所有权交给给函数的返回值,相当于把函数内生成的堆中值的所有权移动到函数外,从而解决前面遇到的悬垂指针问题:

fn main() {
    let b = a_num();
    println!("b = {}", b);
}

fn a_num() -> Box<i32> {
    let a = Box::new(33);
    a
}

Rust 和 Go 一样,在赋值时都是执行浅拷贝,从而保持赋值操作的高效。

一般来说,在变量赋值或在函数调用中传参时,以上移动语义(move)只适用于堆上数据,对于栈上数据,则使用拷贝语义(copy)。也就是说,当将一个栈变量的值赋值给另外一个栈变量时,只是复制一份新的值给被赋值的变量,并不转移所有权,之后两个变量都仍然可用。Rust 默认使用移动语义 move , 只有对那些实现了 Copy trait 的类型值才使用拷贝语义。整数类型、浮点数类型、布尔类型、字符类型、元组(当且仅当其包含的类型也都实现 Copy 的时候)都实现了 Copy trait。任何一组简单标量值的组合,以及任何不需要分配堆内存或某种其他形式资源的类型都可以实现 Copy trait,但不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。。你也可以为自定义的类型添加 Copy trait。

如果确实需要复制智能指针指向的堆上数据,而不仅仅是栈上数据,可以使用一个叫做 clone 的通用方法,这里就不详细介绍了。

再引入一个问题:假如要向一个函数传递一个堆中的值,应该怎么办呢?是不是需要把外部变量的所有权转移给函数参数?这当然可行,如下所示:

fn main() {
    let a = Box::new(33);
    print_num(a);
    // println!("a = {}", a);
}

fn print_num(b: Box<i32>) {
    println!("b = {}", b);
}

请注意,以上 main 函数最后的被注释行是无法执行的,因为在上一行 print_num(a); 执行时,a 的所有权已经转移到函数内了,此后 a 变得无效。那么,如何实现在函数执行结束后仍使 a 可用呢?你或许想到再通过返回值将 a 的所有权返回。但这种将所有权来回移动的方法实在太麻烦了。其实,我们前面给出的 &a 形式的指针仍然有效,请看:

fn main() {
    let a = Box::new(33);
    print_num(&a);
    println!("a = {}", a);
}

fn print_num(b: &Box<i32>) {
    println!("b = {}", b);
}

这里 &a 是指针,它是一种引用。和其他语言不同的是,无法通过该引用修改它所指向的值,因为引用默认是不可变的;另外,&a 并不会取得原来 a 拥有值的所有权。这种行为就像是把 a 所拥有的值暂时租借过来用一下一般,用完后自动归还。在 Rust 中,这种创建引用的行为称为借用。绕着弯解释:引用 &a 借用了其所引用变量 a 对所拥有值的所有权。在这里,引用和借用的语义只有细微的差别,将他们混用了也没有多大关系。

就像变量分为不可变变量和可变变量一样,借用也分为普通的(不可变)借用和可变借用。可变借用使用类似 &mut a 的形式。若要在以上 print_num 修改传入的参数,可写成如下形式:

fn main() {
    let mut a = Box::new(33);
    print_num(&mut a);
    println!("a = {}", a);
}

fn print_num(b: &mut Box<i32>) {
    println!("b = {}", b);
    *b = Box::new(44);
}

相对于移动,可变借用虽然也能修改目标值,但却不获得值的所有权。

Rust 对借用的还有更多限制:

  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
  • 引用必须总是有效的。

这样做的目的是为了避免数据竞争。数据竞争是多线程编程时常遇到的问题,它的发生条件包括:两个或多个线程同时访问一个内存位置;其中有一个或多个线程实施内存写入;其中有一个或多个线程是非同步的。数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复。Rust 避免了这种情况的发生,它甚至不会编译存在数据竞争的代码。

说了这么多,那么 Rust 具体是如何释放堆内存呢?答案是在堆变量作用域结尾的 } 处,会自动调用调用变量的 drop 方法来清理变量的堆内存。

讨论

  • Go 和 Rust 内存管理策略的不同是造成这两门语言各方面不同的最重要原因,两种语言的内存管理策略没有高下之分,他们正好分别满足了两种语言的设计目标。
  • Rust 的所有权系统的基本规则本身并不算复杂,但这套规则会导致相关编程方法产生大量的改变,引入较多复杂性,许多人在初始使用时会感到不适应,需要通过不断的练习,将这些规则固化于心。
  • 还是要为 Rust 的所有权系统点个赞,通过该系统使 Rust 在做到内存安全的同时,又做到了零成本抽象,使 Rust 可以打上安全、高性能、占用内存少、编译后二进制尺寸小等标签。
  • Go 的自动垃圾回收也有自身的优点,它使 Go 变得简单易用,同样做到了内存安全,并且适合高并发编程。