泄漏
基于所有权的资源管理是为了简化组合:你在创建对象时获得资源,在对象被销毁时释放资源。由于销毁是自动为你处理的,这意味着你不能忘记释放资源,而且会尽快地释放!当然这很完美,我们所有的问题都解决了…………么?
一切都很糟糕,我们有新的、奇特的问题需要去解决。
很多人相信 Rust 能防止资源泄漏。在实践中,这基本上是对的。如果你看到一个安全的 Rust 程序以不受控制的方式泄漏资源,你会感到惊讶。
然而从理论的角度来看,无论你怎么看,都绝对不是这样的。在最严格的意义上,“泄漏”是如此抽象,以至于无法预防。在程序开始时初始化一个集合,用大量带有析构器的对象填充它,然后进入一个从未引用过它的无限事件循环,这是非常容易的。这个集合将毫无用处地坐着,守着它宝贵的资源,直到程序终止(无论如何,这时所有这些资源都会被操作系统回收)。
我们可以考虑一种更有限的泄漏形式:未能丢弃一个无法到达的值。Rust 也没有防止这种情况。事实上,Rust 有一个函数可以做到这一点。mem::forget
。这个函数消耗它所传递的值,然后不运行它的析构器。
在过去,mem::forget
被标记为不安全,作为对使用它的一种提示,因为不调用一个析构器通常不是一件好的事情(尽管对一些特殊的不安全代码很有用)。然而,这通常被认为是一种站不住脚的立场:在安全代码中,有很多方法可以不调用析构函数。最著名的例子是使用内部可变性创建一个引用计数指针的循环引用。
对于安全代码来说,假设析构器的泄漏不会发生是合理的,因为任何泄漏析构器的程序都可能是错误的。然而,不安全的代码不能依赖析构器的运行来保证安全。对于大多数类型来说,这并不重要:如果你泄露了析构函数,那么根据定义,该类型是不可访问的,所以这并不重要,对吗?例如,如果你泄露了一个Box<u8>
,那么你会浪费一些内存,但这几乎不会违反内存安全。
然而,我们必须注意的是代理类型的解构器泄露。这些类型管理对一个独立对象的访问,但实际上并不拥有它。代理对象是相当罕见的,你需要关注的代理对象就更少了。我们将专注于标准库中三个有趣的例子:
vec::Drain
Rc
thread::scoped::JoinGuard
Drain
drain
是一个 collections API,它将数据从容器中移出而不消耗容器。这使我们能够在对一个Vec
的所有内容都获得所有权后重新使用其底层的内存分配。它产生了一个迭代器(Drain),并按值返回 Vec 的内容。
现在,考虑一下迭代中的 Drain:一些值已经被移出,而另一些还没有。这意味着 Vec 的一部分现在充满了逻辑上未初始化的数据! 我们可以在每次移出一个值的时候对 Vec 中的所有元素进行后移,但这将会产生非常灾难性的性能后果。
相反,我们希望 Drain 能在 Vec 被删除时修复它底层需要的内存分配(译者注:也就是 Vec 的内存分配)。它应该自己运行直到完成,并回移任何没有被移除的元素(drain 支持子范围),然后修复 Vec 的len
。它甚至是 unwind 安全的。很简单!
现在考虑下面的情况:
let mut vec = vec![Box::new(0); 4];
{
// 开始 drain,vec 无法被再次访问
let mut drainer = vec.drain(..);
// 从 drain 中取出两个元素,然后立刻销毁它们
drainer.next();
drainer.next();
// 销毁 drainer,但是不调用它的 drop 函数
mem::forget(drainer);
}
// Oops,vec[0] 已经被 drop 了,我们正在读一块已经释放的内存
println!("{}", vec[0]);
这很明显不是好事。不幸的是,我们正处于两难境地:在每一步保持一致的状态有巨大的成本(并且会抵消 API 带来的任何好处)。如果不能保持一致的状态,我们就会在安全代码中出现未定义的行为(使 API 不健全)。
那么我们能做什么呢?好吧,我们可以选择一个微弱的一致性状态:当我们开始迭代时,将 Vec 的 len 设置为 0,并在必要时在析构器中修复它。这样一来,如果一切执行正常,我们就能以最小的开销获得所需的行为。但是如果有人胆敢在迭代过程中 forget 了我们,那大不了就是泄露更多(并且可能让 Vec 处于一个虽然意外的但其他方面保持一致的状态)。既然我们已经接受了 mem::forget 是安全的,那么这就必须绝对是安全的。我们把一个泄漏导致更多的泄漏称为泄漏放大。
Rc
Rc 是一个有趣的例子,因为乍一看,它似乎根本就不是一个代理值。毕竟,它管理着它所指向的数据,丢掉一个值的所有 Rcs 就会丢掉这个值。泄露一个 Rc 似乎并不特别危险。它将使 refcount 永久增加,并阻止数据被释放或丢弃,但这似乎就像 Box,对吗?
并不是这样。
让我们考虑一下 Rc 的一个简化实现:
struct Rc<T> {
ptr: *mut RcBox<T>,
}
struct RcBox<T> {
data: T,
ref_count: usize,
}
impl<T> Rc<T> {
fn new(data: T) -> Self {
unsafe {
// 如果 heap::allocate 像这样不是很好吗?
let ptr = heap::allocate::<RcBox<T>>();
ptr::write(ptr, RcBox {
data: data,
ref_count: 1,
});
Rc { ptr: ptr }
}
}
fn clone(&self) -> Self {
unsafe {
(*self.ptr).ref_count += 1;
}
Rc { ptr: self.ptr }
}
}
impl<T> Drop for Rc<T> {
fn drop(&mut self) {
unsafe {
(*self.ptr).ref_count -= 1;
if (*self.ptr).ref_count == 0 {
// drop 数据并且释放所占据的内存
ptr::read(self.ptr);
heap::deallocate(self.ptr);
}
}
}
}
这段代码包含了一个隐含的、微妙的假设:ref_count
可以装入usize
,因为内存中的 Rcs 不能超过usize::MAX
。然而这本身就假设ref_count
准确反映了内存中的 Rcs 数量,我们知道用mem::forget
是错误的。使用mem::forget
我们可以溢出ref_count
,然后用大量的 Rcs 将其降至 0。然后我们就可以愉快地对内部数据进行 use-after-free 了。负负得正?
这个问题可以通过检查ref_count
并做一些防御来解决。标准库的立场是直接 abort,因为你的程序肯定是摊上事儿了,摊上大事儿了。卧槽,这真是一个可笑的边界情况。
thread::scoped::JoinGuard
实际上这个 API 很早就从标准库中删除了,具体原因可以参考 https://github.com/rust-lang/rust/issues/24292。
原文也有人提过 issue 询问是否可以删除,得到了答复说,这个例子仍然是非常重要的,所以保留了下来:https://github.com/rust-lang/nomicon/issues/57。
thread::scoped API 旨在允许引用其父线程栈上的数据的线程被创建出来,而不需要对这些数据进行任何同步。它确保父线程在任何共享数据失效之前 join 子线程。
pub fn scoped<'a, F>(f: F) -> JoinGuard<'a>
where F: FnOnce() + Send + 'a
这里f
是一些闭包,供其他线程执行。这里我们定义F: Send +'a
意思是它捕获了生命周期为'a
的数据,而且它要么拥有该数据,要么该数据是Sync
的(暗示&data
是Send
)。
因为 JoinGuard 有一个生命周期,它通过借用捕获了所有它需要的父线程中的数据。这意味着 JoinGuard 不能超过其他线程正在处理的数据的生命周期。当JoinGuard被丢弃时,它会 block 父线程,确保子线程中捕获的数据在父线程中 drop 之前失效。
使用方法看起来像这样:
let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
let mut guards = vec![];
for x in &mut data {
// 将可变引用移入闭包,并且在另外一个线程执行闭包,
// 闭包有一个生命周期,由其保存的引用的生命周期决定,
// 返回的句柄和闭包也有相同的生命周期,
// 所以它也和闭包一样可变引用了 x,
// 也就意味着在句柄(线程)销毁之前,我们不能访问 x
let guard = thread::scoped(move || {
*x *= 2;
});
// 将线程句柄保存起来之后使用
guards.push(guard);
}
// 所有的句柄在这里被 drop, 强制线程 Join(主线程在此阻塞),
// 等到所有的线程 join 之后,其借用的数据就过期了,
// 因此又可以在主线程中访问了
}
// 在这里数据绝对已经改变了
原则上,这完全是可行的!Rust 的所有权系统完美地保证了这一点!……只是它必须依赖于一个保证被调用到的析构器才是安全的。
let mut data = Box::new(0);
{
let guard = thread::scoped(|| {
// 好一点的情况是存在数据竞争,更坏的是释放内存后使用的问题
*data += 1;
});
// 因为 guard 被主动 forget 了,不会调用 drop 方法,主线程不会阻塞等待 guard 结束
mem::forget(guard);
}
// Box在这里被销毁,而不确定子线程是否会在这里尝试访问它
在这里,一个会运行的析构器对 API 来说是非常基本的。因此它不得不被废弃,而采用完全不同的设计。