Appearance
17.3 析构函数泄漏
上面的例子展现了如何在 Rust 中不使用 unsafe 代码制造内存泄漏。在 Rust 中,在不经意间不小心制造内存泄漏的可能性是很低的。但是这个可能性还是存在的。
然而,内存泄漏并非最可怕的情况,因为内存泄漏只造成资源浪费,毕竟没有造成野指针等更为严重的内存安全问题。上面的例子实际上还暗示了另外一种危险性,即析构函数泄漏。在 Rust 中,RAII 手法用得非常普遍,它实际上要求程序的正确性依赖于析构函数的确定性调用。然而让我们担心的事情是,析构函数是有可能永远不会被调用的。
除了前面展示的通过循环引用导致的析构函数泄漏之外,还有许多种方式可以产生同样的效果。比如,我们构造两个首尾相连的 channel,发送端和接收端连到一起,那么在这两个 channel 里面传递的对象就进入了死循环,就永远不会被析构了。
析构函数泄漏是比内存泄漏更严重的情况。因为析构函数是可以“自定义”的,析构函数里面可能调用“任意的”代码。
我们一直在强调,Rust 给了我们一个非常强的保证,即“内存安全”。这个保证是非常严肃认真的。这个保证意味着,只要不使用 unsafe,用户永远无法构造出“内存不安全”的情况。然而,对于泄漏问题,Rust 做不到像内存安全这种程度的保证。所以,Rust 设计者不得不痛苦地承认,析构函数并不能被保证调用。大家不要误解了这段话,这并不是意味着 Rust 会轻轻松松、时时刻刻造成泄漏,它只是意味着,编译器没办法自动检查出所有可能的资源泄漏问题,并给出编译错误或警告。
承认析构函数可能不会被调用(即便在不使用 unsafe 代码情况下),并不会造成特别严重的问题——除非它违反了“内存安全”。“内存安全”一直是 Rust 坚持的原则和底线,这条原则是永远不能被破坏的,否则 Rust 就失去了存在的意义。这个结论直接导致了下面几个比较重要的后果。
其一,标准库中的std::mem::forget
函数去掉了 unsafe 标记。
其二,允许带有析构函数的类型,作为 static 变量和 const 变量。全局变量的析构函数最后是泄漏掉了的,不会被调用。以前曾经规定带析构函数的类型不允许作为全局变量,后来放宽了规定,允许作为全局变量,但是析构函数无法调用。
其三,标准库中不安全代码需要依赖析构函数调用的逻辑得到修改,其中涉及Vec::drain_range
和Thread::scoped
等方法。
Rust 标准库中有一个std::mem::forget
函数,这个方法的签名是fn forget<T>(t: T)
。它接受的参数不是引用类型,而是将参数 move 进入函数体中,类似于std::mem::drop
。但它与 drop 最大的区别是,它会阻止编译器调用这个变量的析构函数,也不会释放它在堆上申请的内存。它的作用就是制造泄漏。原来这个函数是 unsafe 的,但是,当设计者发现完全可以用安全代码写一个同样效果的 forget 函数,那么,它的 unsafe 标记也就没有什么意义了。因此,大家决定,去掉 forget 函数前面的 unsafe 标记。这个函数不再被标记为 unsafe,只是因为设计者意识到了泄漏并非内存安全问题,unsafe 关键字只能用于标记跟内存安全相关的问题,并非意味着鼓励用户随意使用这个函数。那么它有什么用呢?我们可以举几个例子:
我们有一个未初始化的值,我们不希望它执行析构函数;
在用 FFI 跟外部函数打交道的时候。
即便析构函数泄漏,也不应造成内存不安全。这个结论,直接导致了thread::scoped
函数从标准库中移除。scoped
函数是这样设计的:scoped
函数可以创建一个线程,跟spawn
函数不一样,它保证在当前函数退出以前,这个线程必定已经退出。这样一来,我们就可以直接使用引用&
来读父线程读局部变量,或者用&mut 来写局部变量,避免了 Arc 的运行效率损失,是非常有用的。scoped
函数与spawn
函数的区别就在于,它保证子线程一定会在当前函数退出之前退出,所以它的生命周期比当前函数的生命周期短。
rust
// 以下示例目前无法编译通过,scoped 已经被移除
use std::thread;
fn main() {
let mut vec = vec![0, 1, 2, 3, 4, 5, 6, 7];
{
let mut guards = Vec::new();
for x in &mut vec {
let guard = thread::scoped(move || {
*x += 1;
});
guards.push(guard);
}
// guards 析构,在析构函数中等待子线程被销毁
}
// 子线程已经全部退出
println!("{:?}", vec);
}
这个 scoped 函数的实现原理是,它返回一个 JoinGuard 类型,在这个类型的析构函数中阻塞当前线程,等待子线程结束。所以,函数退出之前,子线程必定已经被销毁。子线程中用到的指向当前函数栈的指针,也不会成为野指针。
粗看起来以上这个设计是不错的,而且它也的确在早期版本的 Rust 标准库中存在了一段时间。然而可惜的是,这个设计是有 bug 的,它会造成安全代码中的“内存不安全”现象。问题在哪里呢?问题在于“析构函数泄漏”。我们知道,Rust 无法保证“析构函数必定被调用”。如果有一个用户,想办法将这个 JoinGuard 传递到当前函数外面去了,或者用循环引用使得这个类型的析构函数永不调用,就出现了析构泄漏。如果这个类型出现了析构泄漏,那么会导致这个子线程的生命周期并不会限制在父线程的当前函数执行周期之内,父线程的当前函数退出,子线程却并未结束,父线程的函数调用栈已经发生变化,而子线程依然有能力访问以前指向的那块内存。这是一个典型的内存安全问题。
Rust 对“内存安全”是不允许的。虽然上面叙述的情况在正式代码中出现的几率很小,但是这依旧是一个潜在的问题。Rust 对库代码的质量标准是:不论使用何种奇巧,只要用户有可能在不使用 unsafe 构造出内存不安全,那这个库就是不安全的、不可接受的。因此,scoped 函数必须从标准库中去掉,它是不能被接受的。它违反了 Rust 的安全承诺,将安全性建立在“析构函数必定被调用”的假设之上,而这个假设是不成立的。它有可能导致不使用 unsafe 的情况下,也能制造出“内存不安全”。这是一个设计失败的 API。
那么用什么办法来解决这个问题呢?可以通过改变 API 的风格来实现。如果说 RAII 式的风格是外向式的,那么对应的“回调函数”式的风格就是内向式的。什么是外向式的和内向式的 API 风格?我们拿迭代器来打比方。Rust 的迭代器是典型的外向式的风格,它暴露了next()
方法,由使用者决定何时、何处、如何调用。我们还可以设计内向式的迭代风格,它的写法是for_each( || { this is a closure })
。在这种方式下,使用者只能传递一个闭包进去,而无权管理迭代器的调用。内向式的 API 对使用者来说灵活性就比较差,比如,我们没办法实现针对两个容器的两个迭代器,分别轮流调用 next()方法,或者在迭代过程中提前中止等。
相比之下,RAII 式的 API 暴露给使用者的灵活性更强,而回调函数式的 API 对使用者的约束性更强。我们如果把 scoped 函数变一种风格,它就可以变成安全的了。这个尝试,在第三方库中已经实现,如果大家需要这个功能,可以搜索 crossbeam 或者scoped_threadpool
这两个库。我们来看看scoped_threadpool
是如何使用的:
rust
extern crate scoped_threadpool;
use scoped_threadpool::Pool;
fn main() {
let mut pool = Pool::new(4);
let mut vec = vec![0, 1, 2, 3, 4, 5, 6, 7];
pool.scoped(|scope| {
for e in &mut vec {
scope.execute(move || {
*e += 1;
});
}
});
println!("{:?}", vec);
}
关于如何利用 cargo 工具引用外部库,在本章就不详细解释了。在这里我们只关心代码逻辑。线程内部直接使用了&mut vec 形式访问了父线程“栈”上的变量。这个 scoped 函数的使用方式跟前面介绍的版本相比更复杂;然而,它的优点是安全性并不依赖外部使用者确保“析构函数”的调用。因为这个改变,使得“等待线程结束”这个逻辑从库的使用者那边移动到了库的编写者那边。库的编写者当然可以保证这个逻辑必然被调用,如果我们把它暴露出来,交给使用者来调用,就不一定了。所以说,我们能从中学到的一点是:当你写一个库的时候,如果希望能确保某个方法一定会被调用,请保证这段代码在你自己的控制之中,不要只在文档中描述,要求使用者主动去调用。
我们比较一下scoped
函数和spawn
函数的签名规则:
rust
fn scoped<'pool, 'scope, F, R>(&'pool mut self, f: F) -> R
where F: FnOnce(&Scope<'pool, 'scope>) -> R
{}
fn spawn<F, T>(f: F) -> JoinHandle<T>
where F: FnOnce() -> T, F: Send + 'static, T: Send + 'static
{}
我们可以看到,对于闭包参数 F 类型的约束,spawn 有'static
生命周期限制,而 scoped 无需'static
生命周期限制。之所以有这样的区别,原因就是在 scoped 的内部实现中保证了子线程一定会在父线程当前函数退出前结束,这个约束条件不是随便能修改的。在它们的内部都使用了 unsafe 代码作为实现细节,在它们的外部都使用了合理的约束条件来保证线程安全。所以我们需要再向大家提醒一下 safe 和 unsafe 的边界究竟在哪里。哪些是编译器可以保证的,哪些是编译器无法保证的,不是简单就说得清楚的事情。千万不要自以为是地滥用 unsafe,如果暴露的外部接口和内部实现不匹配,就会给下游用户“挖坑”,很容易导致某些初学者误以为 Rust 的内存安全保证是骗人的。
泄漏是一个麻烦的问题,Rust 在这个问题上的设计涉及许多的妥协和平衡,其间引发了大量的纠结、讨论甚至争吵。正是:
txt
曾虑多情损梵行,
入山又恐别倾城。
世间安得双全法,
不负如来不负卿。