第7章 基于所有权的资源管理
OBRM(又称 RAII:资源获取即初始化)是你在 Rust 中经常会用到的技巧,特别是当你使用标准库的时候。
粗略的说,其模式如下:要获得一个资源,你要创建一个对象来管理它。要释放资源,你只需销毁这个对象,它就会为你清理资源。这种模式管理的最常见的“资源”就是内存。Box、Rc以及std::collection中的所有东西都是一种便利,可以正确管理内存。这在 Rust 中特别重要,因为我们没有 GC 来管理内存。重点来了:Rust 是关于控制的。然而,我们并不仅仅局限于内存。几乎所有其他的系统资源,如线程、文件或套接字,都可以通过这种 API 暴露。
构造
构造一个用户定义类型的实例只有一种方法:为其命名,并一次性初始化其所有字段:
#![allow(unused)] fn main() { struct Foo { a: u8, b: u32, c: bool, } enum Bar { X(u32), Y(bool), } struct Unit; let foo = Foo { a: 0, b: 1, c: false }; let bar = Bar::X(0); let empty = Unit; }
就这样。其他所有构造类型实例的方法都是在调用一个完全虚无的函数,这个函数做了一些事情,最后变成了唯一的真实构造函数。
与 C++ 不同,Rust 没有内置的各种构造函数。没有 Copy、Default、Assignment、Move 或其他构造函数。其原因是多方面的,但主要归结为 Rust 的显式哲学。
移动构造函数在 Rust 中是没有意义的,因为我们不允许类型“关心”它们在内存中的位置。每个类型都必须准备好被盲目地移动到内存中的其他地方。这意味着纯粹的栈上但仍可移动的侵入性链表在 Rust 中根本无法(安全地)实现。
赋值和复制构造函数也同样不存在,因为移动语义是 Rust 中唯一的语义。x = y最多只是把 y 的位移到 x 变量中。Rust 确实提供了两种方法来提供 C++ 的面向拷贝的语义:Copy和Clone。Clone 类似我们所说的复制构造函数,但它从未被隐式调用。你必须在你想要克隆的元素上明确地调用clone。Copy 是 Clone 的一个特例,它的实现只是“复制比特”。Copy 类型是隐式克隆的,只要它们被移动;但由于 Copy 的定义,这只是意味着不把旧的变量当作未初始化的 —— 也就是说,啥都没干(no-op)。
虽然 Rust 提供了一个Default特性来指定了一个类似默认构造函数的东西,但这个特性很少被使用。这是因为变量不是隐式初始化的。 Default 基本上只对泛型编程有用。在具体环境中,一个类型将为任何类型的“默认”构造函数提供一个静态的new方法。这与其他语言中的new没有关系,也没有特殊含义。它只是一个命名惯例。
TODO: talk about "placement new"?
析构
Rust 通过Drop trait 提供了完整的自动析构器,它提供了以下这个方法:
fn drop(&mut self);
这个方法给了类型一些时间来完成它正在做的事情。
在drop运行后,Rust 将递归地尝试删除self的所有字段。
这是一个方便的功能,这样你就不必写“析构器模板”来丢弃子字段。如果一个结构除了丢弃其子字段之外没有特殊的丢弃逻辑,那么就意味着根本不需要实现Drop!
在 Rust 1.0 中没有稳定的方法来阻止这种行为。
请注意,这里使用的是&mut self,意味着即使你想要阻止递归的 Drop(例如将字段移出 self),Rust 也会阻止你。对于大多数类型来说,这完全没有问题。
一个自定义的Box的实现可以这样写Drop:
#![feature(ptr_internals, allocator_api)] use std::alloc::{Allocator, Global, GlobalAlloc, Layout}; use std::mem; use std::ptr::{drop_in_place, NonNull, Unique}; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()) } } } fn main() {}
这样做是可行的,因为当 Rust 去丢弃ptr字段时,它只是看到一个Unique,没有实际的Drop实现。同样的,没有任何东西可以在释放后使用ptr,因为当 drop 退出时,它就变得不可访问了。
然而下面这段代码就不可行了:
#![feature(allocator_api, ptr_internals)] use std::alloc::{Allocator, Global, GlobalAlloc, Layout}; use std::ptr::{drop_in_place, Unique, NonNull}; use std::mem; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); } } } struct SuperBox<T> { my_box: Box<T> } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 释放 box 的内容,而不是 drop box 的内容 let c: NonNull<T> = self.my_box.ptr.into(); Global.deallocate(c.cast::<u8>(), Layout::new::<T>()); } } } fn main() {}
当我们在 SuperBox 的析构器中释放完box的 ptr 后,Rust 会很高兴地告诉 box 去 Drop 自己,然后,你就能开开心心去 debug use-after-free 和 double-free 的问题了。
请注意,递归 drop 行为适用于所有结构和枚举,无论它们是否实现了 Drop。因此,像这样的代码:
#![allow(unused)] fn main() { struct Boxy<T> { data1: Box<T>, data2: Box<T>, info: u32, } }
在它将被丢弃时,它的 data1 和 data2 的字段就会被析构,尽管它本身并没有实现 Drop。我们说这样的类型需要 Drop,尽管它本身不是 Drop。
类似地:
#![allow(unused)] fn main() { enum Link { Next(Box<Link>), None, } }
当且仅当一个实例存储了 Next 变量时,它的内部 Box 字段将被丢弃。
一般来说,这种设计非常好,因为当你重构数据布局时,你不需要担心添加/删除Drop的问题。当然,也有很多需要用析构器做更棘手的事情的例子。
经典的覆盖递归 drop 行为并允许在drop过程中移出 Self 的安全的解决方案是,使用一个 Option:
#![feature(allocator_api, ptr_internals)] use std::alloc::{Allocator, GlobalAlloc, Global, Layout}; use std::ptr::{drop_in_place, Unique, NonNull}; use std::mem; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); } } } struct SuperBox<T> { my_box: Option<Box<T>> } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 释放 box 的内容,而不是 drop box 的内容, // 需要将 box 字段设置为 None,防止 Rust 对 box 成员可能存在的drop操作 let my_box = self.my_box.take().unwrap(); let c: NonNull<T> = my_box.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); mem::forget(my_box); } } } fn main() {}
然而这有相当奇怪的语义:你是说一个应该总是 Some 的字段可能是 None,只是因为这发生在析构器中。当然,这也有一定的意义:你可以在析构器中调用 self 上的任意方法,这应该可以防止你在释放字段后这样做;而并不是说它能阻止你产生无效的状态。
总的来说,这是个可以接受的选择。当然,你应该在默认情况下达到这样的效果。然而,在未来,我们希望有一种更好的方式来指明一个字段不应该被自动 drop 掉。
泄漏
基于所有权的资源管理是为了简化组合:你在创建对象时获得资源,在对象被销毁时释放资源。由于销毁是自动为你处理的,这意味着你不能忘记释放资源,而且会尽快地释放!当然这很完美,我们所有的问题都解决了…………么?
一切都很糟糕,我们有新的、奇特的问题需要去解决。
很多人相信 Rust 能防止资源泄漏。在实践中,这基本上是对的。如果你看到一个安全的 Rust 程序以不受控制的方式泄漏资源,你会感到惊讶。
然而从理论的角度来看,无论你怎么看,都绝对不是这样的。在最严格的意义上,“泄漏”是如此抽象,以至于无法预防。在程序开始时初始化一个集合,用大量带有析构器的对象填充它,然后进入一个从未引用过它的无限事件循环,这是非常容易的。这个集合将毫无用处地坐着,守着它宝贵的资源,直到程序终止(无论如何,这时所有这些资源都会被操作系统回收)。
我们可以考虑一种更有限的泄漏形式:未能丢弃一个无法到达的值。Rust 也没有防止这种情况。事实上,Rust 有一个函数可以做到这一点。mem::forget。这个函数消耗它所传递的值,然后不运行它的析构器。
在过去,mem::forget被标记为不安全,作为对使用它的一种提示,因为不调用一个析构器通常不是一件好的事情(尽管对一些特殊的不安全代码很有用)。然而,这通常被认为是一种站不住脚的立场:在安全代码中,有很多方法可以不调用析构函数。最著名的例子是使用内部可变性创建一个引用计数指针的循环引用。
对于安全代码来说,假设析构器的泄漏不会发生是合理的,因为任何泄漏析构器的程序都可能是错误的。然而,不安全的代码不能依赖析构器的运行来保证安全。对于大多数类型来说,这并不重要:如果你泄露了析构函数,那么根据定义,该类型是不可访问的,所以这并不重要,对吗?例如,如果你泄露了一个Box<u8>,那么你会浪费一些内存,但这几乎不会违反内存安全。
然而,我们必须注意的是代理类型的解构器泄露。这些类型管理对一个独立对象的访问,但实际上并不拥有它。代理对象是相当罕见的,你需要关注的代理对象就更少了。我们将专注于标准库中三个有趣的例子:
vec::DrainRcthread::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 来说是非常基本的。因此它不得不被废弃,而采用完全不同的设计。