【闲谈 rust 语言】内存安全与垃圾回收器
这篇文章通俗地谈谈内存安全的概念、手动内存管理的问题、垃圾回收器的优劣,再讲讲 rust 解决内存安全问题时独特的做法。
现在流行的操作系统和数据库等基础软件,多是使用 C 或 C++ 编写的。在 C 和 C++ 中,每当使用动态内存分配(如使用变长数组、变长字符串)时,需要手动分配和释放内存。以 C 语言为例:
// 为变量 a 分配内存区域
char * a = malloc(64);
// 使用变量 a
// ...
// 释放 a 的内存区域
free(a);
每次使用动态内存分配的变量之前,总是要在一开始用 malloc 分配内存区域,在末尾用 free 释放掉它。这样很麻烦,而且经常会遗漏 free ,所以后来“垃圾回收器”被发明出来了。
垃圾回收器
如果使用包含有垃圾回收器的编程语言,就可以省掉 free 的烦恼。以 JavaScript 为例:
// 创建一个新的数组
var arr = new Array(64);
// 使用变量 arr
// ...
在一开始时,通过 new 分配的内存区域会由垃圾回收器来统一管理,并不需要在代码的末尾释放。每隔一段时间,垃圾回收器会做一个检测,自动识别出来到底哪些内存区域可以被释放了。这让写代码方便了很多。
当然,垃圾回收器的问题也是显而易见的。
一是垃圾回收器需要间歇性地检测识别可释放的内存区域,这会产生不受代码控制的“垃圾回收中断”,像是代码暂停执行了一样。
二是垃圾回收器并不是即时释放内存区域、很多内存区域并不会在不再使用时立即释放,导致总体占用的内存偏大。
三是垃圾回收器需要完全管理编程语言中所有分配出来的内存区域,这导致同时使用多门带有垃圾回收器的编程语言时,总是需要更多内存拷贝操作,编程语言之间交互的代码更加繁琐。
因而,对性能要求高的基础软件、常被其他编程语言引用的底层库,一般不使用带有垃圾回收器的编程语言来实现。
尽管如此,现在很多编程语言仍然带有垃圾回收器。因为除了代码编写方便之外,垃圾回收器还带来了一个重要的好处,称为“内存安全”。
内存安全问题
垃圾回收器总是在一片内存区域不被使用的时候才去释放它。而如果手工编写 free ,有时很难做到正确使用 free 。
最常见的错误用法是 use-after-free :free 调用得过早,导致内存错乱。例如:
void use_after_free_example_1() {
// 为变量 a 分配内存区域
char * a = malloc(64);
// 释放 a 的内存区域
free(a);
// 为变量 b 分配内存区域
char * b = malloc(64);
// 向 b 写入一段数据
strcpy(b, "data of b");
printf("%s\n", b); // 输出 data of b
// 此时 a 仍可以使用,但访问到了 b 的数据!
printf("%s\n", a); // 输出 data of b
// ...
}
在上面这个例子中,由于变量 a 释放得过早,使得后续分配变量 b 时, b 重新使用了 a 刚刚释放掉的这片内存区域,最终导致 a 与 b 相等。
在实践中,往往情况更加复杂。例如下面这种变形:
void use_after_free_example_2() {
// 为变量 a 分配内存区域
char * a = malloc(64);
// 让变量 c 和变量 a 指向同一片区域
char * c = a;
// 释放 a 的内存区域
free(a);
// 为变量 b 分配内存区域
char * b = malloc(64);
// 向 b 写入一段数据
strcpy(b, "data of b");
printf("%s\n", b); // 输出 data of b
// 此时 c 仍可以使用,但访问到了 b 的数据!
printf("%s\n", c); // 输出 data of b
// ...
}
在上面这个例子中,有变量 c 复用了变量 a 的内存区域,而 a 释放时,对变量 c 的使用就会导致内存错乱。
在实践中,这些复杂的变形往往很难仅凭少数几个开发者就看出问题,也不一定能通过测试就表现出来;即使测试表现出来了问题, debug 往往也很麻烦。何况,还有更多其他类似的内存安全问题,比如多次 free 同一块内存区域、使用未初始化的内存区域、使用数组时下标越界等等。
一经发布,这些问题会给不怀好意的攻击者留下攻击面。历史上最有名的案例之一是 OpenSSL 的 heartbleed 漏洞。这个漏洞说来也并不复杂:就是将某个已经内存错乱了的变量内容通过网络发送了出去。如果错乱了的内容中刚好包含了需要保密的内容(如证书密钥),就使得攻击者拿到这些保密内容了。当时,众多网站被迫更新了证书;据报道,一些来不及应对的网站受到了攻击。
另据统计, Google Chrome 中 70% 的安全问题都属于内存安全问题,其中的一半是最容易犯下的 use-after-free 错误。所以,不要以为编码足够小心就能避免这种问题了。
rust 的解决方式
出于性能考虑, rust 是没有垃圾回收器的语言,但 rust 有一套完整的体系来保证内存安全。
首先, rust 没有显式的 free 调用,而是在花括号块的末尾自动释放。例如:
fn example_1() {
{
// 为变量 a 分配内存区域
let a: Vec<u8> = Vec::new();
// 花括号末尾自动释放 a 的内存区域
}
let b: Vec<u8> = Vec::new();
// b 虽然重新利用了 a 刚释放的内存区域
// 但 a 仅在上面花括号内有效,这里不能再使用
// 这样就避免了 use-after-free 问题
}
如果作为花括号的计算结果抛到花括号外,则释放时机自动延迟到外层花括号末尾。例如:
fn example_1() {
let a = {
// 为变量 a 分配内存区域
let a: Vec<u8> = Vec::new();
a
// 这里不会释放 a
};
let b: Vec<u8> = Vec::new();
// a 还未释放
// b 和 a 的内存区域不同
// a 和 b 在这个花括号末尾释放
}
rust 的所有权规则规定,一个内存区域只能有一个所有者。例如:
fn example_2() {
// 为变量 a 分配内存区域
let a: Vec<u8> = Vec::new();
// 将 Vec 所有权从 a 转移到 c
let c = a;
// 此时 a 不能再使用,也不会在末尾被释放
let b: Vec<u8> = Vec::new();
// c 还未释放
// b 和 c 的内存区域不同
// b 和 c 在这个花括号末尾释放
}
rust 的借用规则规定,如果变量 c 持有变量 a 的引用,则 c 不能在 a 所在的花括号末尾被抛出。例如:
fn example_2() {
let c = {
// 为变量 a 分配内存区域
let a: Vec<u8> = Vec::new();
// c 是 a 的引用
let c = &a;
c
// 编译失败!
};
let b: Vec<u8> = Vec::new();
}
不符合所有权和借用规则的写法都将直接导致编译失败。(详细的规则比较复杂,这里不再列举了。)
rust 的一整套所有权和借用机制可以完整保证内存安全,而且没有额外的运行时开销。这种无垃圾回收器的内存安全机制就是 rust 最重要的设计之一,也是 rust 在编程语言领域理论价值的体现。