消除垃圾收集器:RAII 方式
已发表: 2022-03-11最开始有 C。在 C 中,内存分配分为三种类型:静态、自动和动态。 静态变量是嵌入在源文件中的常量,由于它们的大小是已知的并且永远不会改变,所以它们并不是那么有趣。 自动分配可以被认为是堆栈分配 - 当进入一个词法块时分配空间,并在退出该块时释放。 它最重要的特点与此直接相关。 在 C99 之前,自动分配的变量需要在编译时知道它们的大小。 这意味着任何字符串、列表、映射以及从它们派生的任何结构都必须存在于堆上,在动态内存中。
动态内存由程序员使用四个基本操作显式分配和释放:malloc、realloc、calloc 和 free。 其中前两个不执行任何初始化,内存可能包含垃圾。 除了免费之外,所有这些都可能失败。 在这种情况下,它们返回一个空指针,其访问是未定义的行为; 在最好的情况下,你的程序会爆炸。 在最坏的情况下,您的程序似乎可以工作一段时间,在爆炸之前处理垃圾数据。
以这种方式做事有点痛苦,因为你,程序员,有责任维护一堆不变量,这些不变量会导致你的程序在违反时爆炸。 在访问变量之前必须有一个 malloc 调用。 在使用变量之前,您必须检查 malloc 是否成功返回。 执行路径中的每个 malloc 必须恰好存在一个免费调用。 如果为零,则内存泄漏。 如果超过一个,您的程序就会爆炸。 释放变量后,可能不会尝试访问该变量。 让我们看一个实际情况的示例:
int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); } $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576该代码虽然很简单,但已经包含一个反模式和一个有问题的决定。 在现实生活中,您永远不应该将字节数写为文字,而应使用 sizeof 函数。 类似地,我们将 char * 数组分配给我们需要两倍的字符串大小(比字符串的长度多一倍,以考虑空终止),这是一个相当昂贵的操作。 更复杂的程序可能会构建更大的字符串缓冲区,从而允许字符串大小增长。
RAII的发明:新的希望
至少可以说,所有这些手动管理都令人不快。 在 80 年代中期,Bjarne Stroustrup 为他的全新语言 C++ 发明了一种新范式。 他将其称为资源获取即初始化,其基本见解如下:可以指定对象具有编译器在适当时间自动调用的构造函数和析构函数,这提供了一种更方便的方式来管理给定对象的内存需要,并且该技术对于不是内存的资源也很有用。
这意味着上面的 C++ 示例更简洁:
int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); } $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0看不到手动内存管理! 构造字符串对象,调用重载方法,并在函数退出时自动销毁。 不幸的是,同样的简单性可能会导致其他并发症。 让我们更详细地看一个例子:
vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.这一切似乎都相当简单。 矢量线被填充、返回和调用。 然而,作为关心性能的高效程序员,这让我们感到困扰:在 return 语句中,由于值语义,向量在其销毁前不久被复制到一个新向量中。
这在现代 C++ 中不再严格正确。 C++11 引入了移动语义的概念,其中原点处于有效状态(以便仍然可以正确销毁)但未指定状态。 返回调用是编译器优化移动语义的一个非常容易的情况,因为它知道在任何进一步访问之前不久就会销毁源。 但是,该示例的目的是说明为什么人们在 80 年代末和 90 年代初发明了一大堆垃圾收集语言,而在那个时候 C++ 移动语义还不可用。
对于大数据,这可能会变得昂贵。 让我们优化一下,只返回一个指针。 有一些语法更改,但除此之外它是相同的代码:
实际上,vector 是一个值句柄:一个相对较小的结构,其中包含指向堆上项目的指针。 严格来说,简单地返回向量是没有问题的。 如果返回的是一个大数组,该示例会更好。 由于试图将文件读入预先分配的数组是无意义的,我们使用向量来代替。 请假装它是一个不切实际的大数据结构。
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)哎哟! 现在行是一个指针,我们可以看到自动变量正在像宣传的那样工作:向量在其范围离开时被销毁,指针指向堆栈中的前向位置。 分段错误只是试图访问非法内存,所以我们真的应该预料到这一点。 尽管如此,我们还是想以某种方式从我们的函数中取回文件的行,很自然的事情就是简单地将我们的变量移出堆栈并进入堆。 这是通过 new 关键字完成的。 我们可以简单地编辑文件的一行,我们在其中定义行:
vector<string> * lines = new vector<string>; $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.不幸的是,尽管这看起来很完美,但它仍然有一个缺陷:它会泄漏内存。 在 C++ 中,指向堆的指针在不再需要后必须手动删除; 如果不是,则一旦最后一个指针超出范围,该内存将变得不可用,并且在进程结束时操作系统对其进行管理之前不会恢复。 惯用的现代 C++ 将在此处使用 unique_ptr,它实现了所需的行为。 当指针超出范围时,它会删除指向的对象。 然而,这种行为直到 C++11 才成为该语言的一部分。
在这个例子中,这可以很容易地解决:
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }不幸的是,随着程序扩展超出玩具规模,很快就很难推断出应该在何时何地删除指针。 当一个函数返回一个指针时,你现在拥有它吗? 你应该在完成后自己删除它,还是它属于某个稍后将立即释放的数据结构? 以一种方式出错并导致内存泄漏,在另一种方式中出错并且您已经破坏了有问题的数据结构以及可能的其他数据结构,因为它们试图取消引用现在不再有效的指针。
“进入垃圾收集器,飞行小子!”
垃圾收集器不是一项新技术。 它们是 John McCarthy 于 1959 年为 Lisp 发明的。 随着 1980 年的 Smalltalk-80,垃圾收集开始成为主流。 然而,1990 年代代表了该技术的真正开花:在 1990 到 2000 年间,发布了大量语言,所有这些语言都使用了一种或另一种垃圾收集:Haskell、Python、Lua、Java、JavaScript、Ruby、OCaml , 和 C# 是最著名的。
什么是垃圾收集? 简而言之,它是一组用于自动化手动内存管理的技术。 它通常作为具有手动内存管理的语言(例如 C 和 C++)的库提供,但它更常用于需要它的语言中。 最大的好处是程序员根本不需要考虑内存; 这一切都被抽象出来了。 例如,与我们上面的文件读取代码等效的 Python 代码如下:

def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count)) $ python3 python3.py makefile File makefile contains 38 lines. 行数组在第一次分配时产生,并且在不复制到调用范围的情况下返回。 它在超出该范围后的某个时间被垃圾收集器清理,因为时间不确定。 有趣的一点是,在 Python 中,非内存资源的 RAII 不是惯用的。 这是允许的——我们可以简单地编写fp = open(file_name)而不是使用with块,然后让 GC 清理。 但推荐的模式是尽可能使用上下文管理器,以便可以在确定的时间释放它们。
尽管抽象出内存管理是一件好事,但它是有代价的。 在引用计数垃圾回收中,所有变量分配和范围退出都会获得少量更新引用的成本。 在标记和清除系统中,在不可预知的时间间隔内,所有程序执行都会停止,而 GC 会清理内存。 这通常被称为停止世界事件。 使用这两种系统的 Python 等实现会受到两种惩罚。 这些问题降低了垃圾收集语言在性能至关重要或需要实时应用程序的情况下的适用性。 即使在这些玩具程序上,也可以看到性能损失:
$ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015sPython 版本的实时时间几乎是 C++ 版本的三倍。 虽然并非所有这些差异都可以归因于垃圾收集,但它仍然相当可观。
所有权:RAII Awakens
那么就这样结束了吗? 所有编程语言都必须在性能和编程易用性之间做出选择吗? 不! 编程语言研究仍在继续,我们开始看到下一代语言范式的第一个实现。 特别令人感兴趣的是名为 Rust 的语言,它承诺类似于 Python 的人体工程学和类似 C 的速度,同时使悬空指针、空指针等不可能——它们无法编译。 它怎么能提出这些要求?
允许这些令人印象深刻的声明的核心技术称为借用检查器,这是一种在编译时运行的静态检查器,拒绝可能导致这些问题的代码。 但是,在深入探讨其含义之前,我们需要先讨论一下先决条件。
所有权
回想一下我们在讨论 C++ 中的指针时,我们谈到了所有权的概念,粗略地说,它的意思是“谁负责删除这个变量”。 Rust 形式化并强化了这个概念。 每个变量绑定都拥有它绑定的资源的所有权,借用检查器确保只有一个绑定拥有该资源的全部所有权。 也就是说,Rust Book 中的以下代码段无法编译:
let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]); error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^Rust 中的赋值默认具有移动语义——它们转移所有权。 可以将复制语义赋予一个类型,这已经用于数字基元,但这并不常见。 因此,从第三行代码开始,v2 拥有相关向量,并且不能再作为 v 访问它。为什么这很有用? 当每个资源只有一个所有者时,它也会有一个时刻超出范围,这可以在编译时确定。 这反过来意味着 Rust 可以兑现 RAII 的承诺,根据其范围确定性地初始化和销毁资源,而无需使用垃圾收集器或要求程序员手动释放任何东西。
将此与引用计数垃圾收集进行比较。 在 RC 实现中,所有指针至少有两条信息:指向的对象和对该对象的引用数。 当该计数达到 0 时,该对象被销毁。这使指针的内存需求增加了一倍,并为其使用增加了一点成本,因为计数会自动递增、递减和检查。 Rust 的所有权系统提供了相同的保证,即对象在引用用完时会自动销毁,但这样做不会产生任何运行时成本。 分析每个对象的所有权,并在编译时插入销毁调用。
借款
如果移动语义是传递数据的唯一方法,那么函数返回类型将变得非常复杂、非常快。 如果你想编写一个函数,它使用两个向量来生成一个整数,之后不会破坏向量,你必须将它们包含在返回值中。 虽然这在技术上是可行的,但使用起来很糟糕:
fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);相反,Rust 有借用的概念。 您可以像这样编写相同的函数,它会借用对向量的引用,并在函数结束时将其归还给所有者:
fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);在 fn foo 返回后,v1 和 v2 将其所有权归还给原始作用域,超出作用域并在包含作用域退出时自动销毁。
这里值得一提的是,借用检查器在编译时强制执行借用限制,Rust Book 非常简洁地说明了这一点:
任何借用的持续范围不得大于所有者的范围。 其次,你可能有这两种借用中的一种,但不能同时有两种:
对资源的一个或多个引用 (&T)
恰好一个可变引用 (&mut T)
这是值得注意的,因为它构成了 Rust 防止数据竞争的关键方面。 通过在编译时防止对给定资源的多次可变访问,它保证了不能编写结果不确定的代码,因为它取决于哪个线程首先到达资源。 这可以防止迭代器失效和释放后使用等问题。
实用的借用检查器
现在我们了解了 Rust 的一些特性,让我们看看我们如何实现我们之前见过的相同的文件行计数器:
fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } } 除了源代码中已经注释的项目之外,还值得仔细阅读并跟踪各种变量的生命周期。 file_name和file_lines一直持续到 main() 结束; 那时调用它们的析构函数,无需额外费用,使用与 C++ 的自动变量相同的机制。 调用read_lines_from_file时, file_name在其持续时间内不可变地借给该函数。 在read_lines_from_file中, buffer的行为方式相同,在超出范围时被销毁。 另一方面, lines持续存在并成功返回到main 。 为什么?
首先要注意的是,由于 Rust 是一种基于表达式的语言,return call 一开始可能看起来不像一个。 如果函数的最后一行省略了尾随分号,则该表达式就是返回值。 第二件事是返回值得到特殊处理。 假设他们希望至少与函数的调用者一样长。 最后一点是,由于涉及到移动语义,将Ok(lines)转换为Ok(file_lines)不需要复制,编译器只需将变量指向适当的内存位。
“只有到了最后,你才会意识到 RAII 的真正力量。”
自编译器发明以来,手动内存管理是程序员一直在发明方法来避免的噩梦。 RAII 是一种很有前途的模式,但在 C++ 中被削弱了,因为如果没有一些奇怪的解决方法,它根本不适用于堆分配的对象。 因此,垃圾收集语言在 90 年代出现爆炸式增长,旨在让程序员的生活更愉快,甚至牺牲性能。
然而,这并不是语言设计中的硬道理。 通过使用新的、强大的所有权和借用概念,Rust 设法将 RAII 模式的基于范围的模式与垃圾收集的内存安全性相结合; 所有这些都不需要垃圾收集器来阻止世界,同时提供任何其他语言都没有的安全保证。 这是系统编程的未来。 毕竟,“犯错是人之常情,但编译器永远不会忘记。”
进一步阅读 Toptal 工程博客:
- WebAssembly/Rust 教程:音高完美的音频处理
- 在 Java 中寻找内存泄漏
