个性化阅读
专注于IT技术分析

消除垃圾收集器:RAII方式

本文概述

最初有C。在C中, 有三种类型的内存分配:静态, 自动和动态。静态变量是嵌入在源文件中的常量, 并且它们具有已知的大小并且永不更改, 因此它们并不是那么有趣。可以将自动分配视为堆栈分配-在输入词法块时分配空间, 在退出该块时释放空间。它最重要的功能与此直接相关。在C99之前, 要求自动分配的变量在编译时知道它们的大小。这意味着任何字符串, 列表, 映射以及从这些字符串派生的任何结构都必须存在于动态内存中的堆中。

消除垃圾收集器:RAII方式

程序员使用四个基本操作来明确分配和释放动态内存:malloc, realloc, calloc和free。这些文件中的前两个文件不执行任何初始化操作, 内存可能包含碎片。除了免费以外, 所有其他人都可能失败。在这种情况下, 它们返回一个空指针, 其访问是未定义的行为;在最佳情况下, 你的程序会爆炸。在最坏的情况下, 你的程序似乎可以工作一段时间, 在爆炸之前处理垃圾数据。

用这种方式做事会很痛苦, 因为程序员(你)全权负责维护一堆不变式, 这些不变式会在违反程序时导致程序爆炸。必须先进行malloc调用, 然后才能访问该变量。在使用变量之前, 必须检查malloc是否成功返回。在执行路径中, 每个malloc必须存在一个完全免费的调用。如果为零, 则内存泄漏。如果不止一个, 程序将爆炸。释放变量后, 可能没有对该变量的访问尝试。让我们看一个实际的例子:

int main() {
   char *str = (char *) malloc(7); 
   strcpy(str, "srcmini");
   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 -o c c.c
./c
char * (null terminated): srcmini @ 66576
char * (null terminated): srcmini02.com @ 66576

该代码虽然很简单, 但已经包含一个反模式和一个可疑的决定。在现实生活中, 永远不要将字节数写为文字, 而应使用sizeof函数。同样, 我们将char *数组精确分配给所需字符串的两倍大小(比字符串的长度大一倍, 以考虑空终止), 这是一个相当昂贵的操作。更复杂的程序可能会构造更大的字符串缓冲区, 从而使字符串大小增加。

RAII的发明:新希望

至少可以说, 所有手动管理都是令人不愉快的。在80年代中期, Bjarne Stroustrup为他的全新语言C ++发明了一种新的范例。他将其称为”资源获取就是初始化”, 其基本见解如下:可以指定对象具有构造函数和析构函数, 这些构造函数和析构函数由编译器在适当的时间自动调用, 这为管理给定对象的内存提供了更为方便的方法。需要, 并且该技术对于不是内存的资源也很有用。

消除垃圾收集器:RAII方式2

这意味着上面的示例在C ++中更加简洁:

int main() {
   std::string str = std::string ("srcmini");
   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: srcmini @ 0x5fcaf0
string object: srcmini02.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 ++ move语义不可用。

对于大数据, 这可能会变得昂贵。让我们对其进行优化, 只返回一个指针。语法进行了一些更改, 但其他代码相同:

实际上, 向量是一个值句柄:一个相对较小的结构, 其中包含指向堆中各项的指针。严格来说, 简单地返回向量是没有问题的。如果返回的是大型数组, 则该示例将更好地工作。由于尝试将文件读入预分配的数组是没有意义的, 因此我们使用向量。请假装这是一个不切实际的大数据结构。

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;
}

不幸的是, 随着程序的扩展超出玩具的范围, 迅速地变得难以确定应该在何时何地删除指针。当函数返回指针时, 你现在拥有它吗?使用完后, 你应该自己删除它, 还是属于某个数据结构, 这些数据结构将在以后全部释放?一方面以错误的方式发生错误, 而在另一方以错误的方式发生内存泄漏, 并且你破坏了有问题的数据结构, 甚至可能破坏了其他数据结构, 因为它们试图取消引用现在不再有效的指针。

相关:调试Node.js应用程序中的内存泄漏

“进入垃圾收集器, flyboy!”

垃圾收集器不是一项新技术。它们由John McCarthy在1959年为Lisp发明。 1980年, 随着Smalltalk-80的出现, 垃圾收集开始成为主流。但是, 1990年代代表了该技术的真正发芽:在1990年至2000年之间, 大量语言被释放, 所有语言都使用一种或另一种垃圾回收:Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml和C#是最著名的。

消除垃圾收集器:RAII方式3

什么是垃圾收集?简而言之, 这是一套用于自动执行手动内存管理的技术。它通常用作具有手动内存管理功能的语言(例如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.015s

Python版本的实时时间几乎是C ++版本的三倍。尽管并非所有这些差异都可以归因于垃圾收集, 但它仍然是可观的。

所有权:RAII觉醒

那是结束吗?所有编程语言都必须在性能和易于编程之间做出选择吗?没有!程序语言的研究仍在继续, 我们开始看到下一代语言范例的第一个实现。特别令人感兴趣的是称为Rust的语言, 它承诺了类似于Python的人体工程学和类似C的速度, 同时使悬空指针, 空指针等不可能实现-它们不会编译。如何提出这些主张?

消除垃圾收集器:RAII方式4

允许这些令人印象深刻的要求的核心技术称为借用检查器, 它是一种在编译时运行的静态检查器, 它拒绝可能导致这些问题的代码。但是, 在深入探讨含义之前, 我们需要讨论先决条件。

所有权

回想一下我们在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);

v1和v2在fn foo返回后将其所有权返回到原始范围, 该范围超出范围并在包含范围退出时自动销毁。

这里值得一提的是, 对借用有一些限制, 在编译时由借用检查器强制执行, 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中, 缓冲区以相同的方式起作用, 当缓冲区超出范围时销毁。另一方面, 行仍然存在并成功返回到main。为什么?

首先要注意的是, 由于Rust是一种基于表达式的语言, 因此返回调用乍一看可能并不像它。如果函数的最后一行省略了结尾的分号, 则该表达式为返回值。第二件事是返回值得到特殊处理。假定他们希望生存至少与该函数的调用者一样长。最后要注意的是, 由于涉及移动语义, 因此无需复制即可将Ok(行)转换为Ok(file_lines), 编译器只需将变量指向适当的内存位即可。

“只有到最后, 你才能意识到RAII的真正力量。”

自从编译器发明以来, 手动内存管理是程序员一直在想办法避免的噩梦。 RAII是一种很有前途的模式, 但由于没有一些奇怪的解决方法, 它根本无法用于堆分配的对象, 因此在C ++中存在缺陷。因此, 在90年代出现了垃圾收集语言的爆炸式增长, 旨在使程序员生活更加愉快, 即使是以性能为代价。

消除垃圾收集器:RAII方式5

但是, 这并不是语言设计中的硬道理。通过使用新的强大的所有权和借用概念, Rust设法将RAII模式的作用域基础与垃圾回收的内存安全性合并在一起。所有这些都不需要垃圾收集器来破坏世界, 同时提供其他任何语言都没有的安全保证。这是系统编程的未来。毕竟, “犯错是人类的事情, 但编译器永远不会忘记。”

相关:寻找Java中的内存泄漏

赞(0)
未经允许不得转载:srcmini » 消除垃圾收集器:RAII方式

评论 抢沙发

评论前必须登录!