Rust的内存安全革命

介绍

Rust是一种年轻的编程语言,为程序代码带来了新的质量。 你可能听说过它是快速的、安全的或容易实现并发的。 本文集中介绍Rust最重要的核心特性:内存管理。 该核心特性是Rust的主要创新之一,它的许多独特的特点是基于这种核心特性的。

本文是写给不知道Rust或刚刚开始学习它的程序员的。对于熟悉C、C++或其他使用手动管理内存以及使用垃圾回收器语言的读者来说会更容易理解Rust的特点。 本文是一个旨在介绍Rust核心概念并鼓励进一步学习的高层次介绍。本文不是教程,最后也没有Hello Wrold的Rust例子。

内存管理

现代应用程序使用计算机的内存主要有两种方式:栈和堆。这可能不适用于使用汇编或编写嵌入式系统软件的情况,但让我们还是关注一般的应用程序的场景。

随着程序进入和退出某些区域(通常是函数),以及循环和分支代码块,栈会自动扩展和缩小。所有现代的、高于汇编语言的语言都会自动执行此操作。它们的行为都是相似的,程序员声明变量,使用它,然后丢弃它。 编译器基于代码区域边界知道何时必须保留内存以及何时清除内存。 这是一个严格的流程,但它快速、安全且易于使用。

1
2
3
4
5
6
7
8
main {
A = 1 // 创建 A
loop {
B = 2 // 创建 B
// 删除 B
}
// 删除 A
}

对堆的处理更自由。 程序员可以从代码中的任何一点来请求它的一部分,然后在任何其他点释放它。 它并不明显与程序流程结合,编译器无法确定何时以及如何处理它。程序员有责任对其进行正确处理。

内存首先必须被获取到,然后被使用,最后被仅释放一次。这三个步骤似乎很简单,但将其与其他应用程序的流程混合会变得棘手,并且违反其中一个步骤都是灾难性的。 有时候一个错误可能没有任何后果,但是在其他时候,应用程序可能会被终止,甚至更糟糕的是,它的内存可能会悄无声息地被破坏。 这种行为不是确定性的。

1
2
3
4
5
6
main {
A = allocate() // 获取
do_stuff(A) // 使用
release(A) // 释放
// 删除指针
}

泄露

当内存没有被正确释放当时候,泄露就发生了。内存泄漏成为一个致命的负担,使得应用程序比实际所需使用更多的资源。在极端情况下,如果所有的内存都被占用,并且仍然有更多的需求,它会使程序甚至整个系统崩溃。

1
2
3
4
5
6
main {
A = allocate() // 获取
do_stuff(A) // 使用
// <运行时错误> 从未释放
// 删除指针
}

释放后使用

当内存被释放后程序还尝试去使用这块内存,这就是释放后使用。如果内存被还给了操作系统,而我们又尝试去访问它,这会导致致命的段错误,程序会立即被结束。另一个有趣的部分是当被释放的内存被分配器缓存并在下次获取时被重用,这使两个随机部分的代码使用相同位置的内存。

1
2
3
4
5
6
main {
A = allocate() // 获取
release(A) // 释放
do_stuff(A) // <运行时错误> 使用无效指针
// 删除指针
}

重复释放

内存被释放两次就是重复释放。如果内存被还回操作系统,它就终止程序对它对访问。重复释放的后果很大程度上取决于分配器,释放内存在其他地方使用或只是崩溃。

1
2
3
4
5
6
7
main {
A = allocate() // 获取
do_stuff(A) // 使用
release(A) // 释放
release(A) // <运行时错误> 释放无效指针
// 删除指针
}

传统解决方法

堆管理是个非常古老的问题,程序员发明了许多工具来减轻它。有两种主要的方法,都被证明是有用的,但每一种都有严重缺陷。

垃圾回收器

这是一个简单的方法。程序获得特殊的机制检测到从某时刻开始给定的内存块将永远不会被使用,因此它可以安全释放。该方法防止了内存泄露、释放后使用、重复释放。证明内存永远不会被再次使用的最简单的方法是证明它是不可访问的。当程序将内存的地址存储在栈上、静态变量或堆上时,该内存是可到访问的,堆本身是可到达的,因此可以在不猜测的情况下获得。而内存本身是可访问的,因此可以毫无疑问地获得它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
main {
...
A = <pointer to>──────┐
... |
} │
╔══ HEAP ALLOCATED ════════╗
║ AA = "reachable" ║
║ AB = <pointer to>──────┐ ║
╚════════════════════════│═╝
╔══ HEAP ALLOCATED ════════╗
║ ABA = "also reachable" ║
╚══════════════════════════╝
╔══ HEAP ALLOCATED ════════╗
║ BA = "unreachable" ║
║ BB = <pointer to>──────┐ ║
╚════════════════════════│═╝
╔══ HEAP ALLOCATED ════════╗
║ BBA = "also unreachable" ║
╚══════════════════════════╝

有许多智能的策略来检查可访问性,但它们都会产生显著的开销。例如,引用计数器会增加内存使用量并为每个堆访问增加开销。另一方面,追踪垃圾回收器允许自由访问,但引入了大量的内存可访问性分析,这些分析可以在后台不断运行,或者为了清理内存可以完全停止程序的执行。 无论如何,垃圾回收器都会为应用程序增加额外的工作量并增加内存使用量。

严格的规则

因此垃圾回收器是一个很好但消耗大量资源的解决方案。但是,如果成本难以承受或者根本没有可能使用它,我们可以做些什么呢? 程序员发明了一个特殊的规则,它使内存管理更容易。 它是基于所有权和生命周期的规则。

所有权

所有权是这样的一个想法,可以有很多指向分配内存的指针,但只有其中一个被视为拥有该内存。当拥有所有权的指针被销毁时,应该使用它来释放分配给它的内存。非所有权的指针可以被创建和销毁任意个,但它们永远不应该用于释放内存。这使得内存管理更加清晰,因为只有一个重要指针要跟踪和释放。它还解决了前面提到的三个堆问题中的两个问题:泄漏和重复释放。所有权可能是API和程序流程中的一个软性协议,但某些语言和库提供的工具使得此策略的执行更加明确且不易出错。例如,现代C++提供了内置的智能指针,它明确表示有拥有权的指针并实现像销毁时释放的合适行为。

1
2
3
4
5
6
7
8
9
10
11
12
main {
A = allocate() // 获取
do_stuff(A) // 使用
release(A) // 释放, 指针拥有内存
// 删除指针
}
do_stuff(B) {
do_more_stuff(B) // 使用
// 不会释放,该指针不是所有权的指针
// 删除指针
}

生命周期

生命周期是程序执行过程中的一段时间,而这段时间内一段特定的数据被有效使用。处理堆分配的内存指针时,这是非常重要的属性,这些指针并不拥有内存。 只要拥有内存的指针不释放内存,它们就可以安全使用。而有所有权的指针释放内存之后,再使用它们就是错误,因为它们的生命周期结束了。值得注意的是,任何包含给定生命期周期的指针的结构都应该被认为具有不超过指针的生命期周期。这不是一个可以执行的简单的规则,但它可以防止前面提到的第三个堆内存问题:释放后使用。 这补充了所有权的保证,使得程序的完全内存安全,而无需垃圾回收器这样的开销。

1
2
3
4
5
6
7
8
9
main {
A = allocate() // A's lifetime begins
do_stuff(A) // use A
B = A // B's lifetime begins
do_stuff(B) // use B
release(A) // release, A's and B's lifetimes end
do_stuff(A) // <RUN TIME FAIL> use A after its lifetime ended
do_stuff(B) // <RUN TIME FAIL> use B after its lifetime ended
}

Rust

有时Rust被描述为混合解决方案。 实际上,它所做的只是强化代码中的所有权和生命周期规则,然而结果是,用Rust写代码非常安全和无忧无虑,它类似于垃圾回收语言。编译器进行静态校验该程序是内存安全的,如果无法校验它是内存安全的,编译器会产生一个指出潜在风险的错误。 当编译通过后,代码保证不会导致内存损坏。 因为这些校验在构建输出二进制文件之前都发生了,所以这个过程对程序的执行没有任何影响,就像它是用纯C或C++编写的一样轻量。

所有权

Rust有非常严格的所有权概念。每一块被分配的内存被一些结构的单独实例所拥有。这些结构可以是任何类型,但通常他们最终是某种来自标准库的集合或Box(Rust的智能指针)。这些包装器负责在自己被销毁的时候释放所拥有的内存。没有简单的方法来显式分配内存和获取原始指针,而不需要任何负责任的包装器。

1
2
3
4
5
6
fn my_fn() {
let my_box = Box::new(1234); // 获取
println!("{}", my_box); // 使用
// 删除 my_box,
// 释放内存
}

递归销毁

所有权是递归的,所以如果一个结构存储另一个结构的值,它将获得后者及其所有子结构的所有权。这也意味着,当容器被销毁时,它必须递归地销毁其所有内容。Rust处理这样的情况可以说是开箱即用一样轻松。所有结构都定义了析构器,它遍历所有字段并首先销毁它们。结构的作者可以在销毁期间添加自己的步骤,例如在编写客户端时关闭数据库连接,但是在此之后字段仍然会被逐一销毁。默认的处理行为在绝大多数情况下都是足够的,因此结构很少会定义析构函数,但是不管有没有定义析构函数,它们都不会泄漏内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct MyStruct { // 结构定义
my_box: Box<u32>, // 它只有一个字段,
// 在堆上一个持有整数的Box类型数据
}
fn my_fn() {
let my_struct = MyStruct { // 创建结构实例
my_box: Box::new(1234), // 获取内存
};
println!("{}", my_struct.my_box); // 使用
// 删除 my_struct,
// 同时删除 my_box,
// 释放内存
}

用栈替代堆

Rust的所有权模式带来了一个强大的特性:复杂的堆管理简化为简单的栈管理。程序员不需要担心如何分配和释放内存,这些工作都通过使用局部变量来处理。甚至即使结构里嵌套了许多堆内存的引用,在栈上也总是只有一个根结构,当程序不再需要它的时候,它会自动销毁。

生命周期

不幸的是,编写那些访问数据需要拥有这些数据的程序并不方便。Rust提供普通的、非智能的、没有所有权的引用,这种引用使得没有所有权的访问成为可能。当这样的引用被创建时,它引用的值是借用的。借用会创建一个双向关系:引用必须具有不超过它引用的值的生命周期,但该值在引用的生命周期内不得移动。这两条规则任何一条被破坏的话,引用所指向的就是无效内存。Rust静态地跟踪并强制执行生命周期的正确性并拒绝危险的程序执行流程。

1
2
3
4
5
6
7
8
9
fn valid_flow() {
let value = "abc".to_string(); // 创建值
let borrow = &value; // 创建借用
println!("{}", value); // 使用值但是没有移动它
println!("{}", borrow); // 使用借用
// 删除借用
// 安全地删除值,
// 因为它不再被借用
}
1
2
3
4
5
6
7
fn borrow_outlives_value() -> &String {
let value = "abc".to_string(); // 创建值
let borrow = &value; // 创建借用
return borrow // 借用没有被删除
// <编译时错误> 删除值,
// 但是它仍然被借用
}
1
2
3
4
5
6
fn value_moved_during_borrow_lifetime() {
let value = "abc".to_string(); // 创建值
let borrow = &value; // 创建借用
let my_box = Box::new(value); // <编译时错误> 值被移动了
// 而它还被借用着
}

递归借用

结构的生命周期永远不能超过它们的任何字段的生命周期。如果其中一个字段恰好是引用,则必须证明整个结构实例在引用值之前被销毁。如果存在对具有生命周期限制的结构的引用,则引用本身的生命周期不能超过结构。只要编译器可以证明它是安全的,这种关系就可以嵌套并绑定任意次数。当编译器无法猜测正确的关系时,可以用简单的语法明确定义它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
fn nested_borrow_outlives_value() {
let value = "abc".to_string(); // 创建值
let borrow = &value; // 创建借用
// 使用的是值的生命周期
let my_box = Box::new(borrow); // 创建 my_box
// 用的是借用的生命周期,
// 这是值的生命周期
return my_box // <编译时错误>
// my_box 没有被删除,
// 但是值被删除了,
// 这造成借用的生命周期
// 超过了值的生命周期
}

规则的妥协

认为每一个系统都可以用限制性的、静态证明的安全性代码来表达是天真的想当然。在绝大多数的情况下,规则可以胜任,但有时规则也必须进行妥协,Rust提供工具来做这件事。

包装器

标准库提供一些包装器将所有权和借用的检查推迟到运行时。这就使有效性检查程序不那么繁忙,并提供了灵活性和很少的运行时开销。例如,Rc是一个没有所有者的Box(带有智能指针的内存)。 它是一个有引用计数器的可被垃圾回收的内存,它的最后一个引用消失的时候,它就被销毁。 Rust提供了更多的包装器,但它们稍微超出了本介绍的范围,它们适用于运行时规则,本文没有涉及。

不安全代码

当在库和工具中进入足够低的层次时,Rust的安全保证变得无法应用。 例如,box和集合触及内存分配和指针,但没有安全保证,因为它们自己做安全保证。 它们可以写在Rust中,因为它们的代码明确标记为不安全。这使得完全忽略安全检查,但这非常危险。 所有外部C库包装器在某些层次也必须使用不安全的代码。 他们定义安全规则,使其与其余代码无缝集成。 不安全的代码是Rust强大能力的来源,但它带来了巨大的责任。 应尽可能避免使用它。

实际情况

Rust看起来不错,它是由聪明的人使用其他聪明人的学术研究设计的,但它真的有用吗?是的,的确有用。大多数情况下,它只会强制元素之间的明确关系,进行合理安全的设计。毕竟,Rust是与Firefox Web浏览器的未来引擎Servo并行设计的。从一开始,它不仅在理论上是好的,而且在实际的、复杂的软件开发中也被证明是可用的。经过一年使用Rust进行商业编程后,我可以确认,Rust的规则不是一种负担,而是在架构和稳定性保证方面提供了很大的帮助。 我真的相信,Rust这种语言是属于未来的,我强烈推荐大家使用它。

原文链接: https://anixe.pl/content/news/rust_memory_safety_revolution