vlambda博客
学习文章列表

【闲谈 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 刚刚释放掉的这片内存区域,最终导致 ab 相等。

在实践中,往往情况更加复杂。例如下面这种变形:

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 在编程语言领域理论价值的体现。