泄漏

基于所有权的资源管理旨在简化组合。您在创建对象时获取资源,并在对象销毁时释放资源。由于销毁是为您处理的,这意味着您不会忘记释放资源,并且它会尽快发生!这肯定是很完美的,我们所有的问题都解决了。

一切都很糟糕,我们有新的和奇特的问题需要尝试解决。

许多人喜欢相信 Rust 消除了资源泄漏。在实践中,这基本上是真的。你会惊讶地看到一个 Safe Rust 程序以不受控制的方式泄漏资源。

然而,从理论的角度来看,无论你怎么看,情况绝对不是这样。从最严格的意义上讲,“泄漏”是如此抽象以至于无法阻止。在程序开始时初始化一个集合,用大量带有析构函数的对象填充它,然后进入一个永远不会引用它的无限事件循环是很容易的。该集合将无用地存在,保留其宝贵的资源,直到程序终止(此时所有这些资源无论如何都会被操作系统回收)。

我们可以考虑一种更受限制的泄漏形式:未能释放无法访问的值。Rust 也不能阻止这种情况。事实上,Rust *有一个函数可以做到这一点*:mem::forget。此函数会消耗传递给它的值,*然后不会运行其析构函数*。

过去,mem::forget 被标记为不安全,作为一种针对使用它的检查,因为未能调用析构函数通常不是一个行为良好的事情(尽管对于一些特殊的不安全代码很有用)。然而,这通常被认为是一种站不住脚的立场:有很多方法可以在安全代码中未能调用析构函数。最著名的例子是使用内部可变性创建引用计数指针的循环。

对于安全代码来说,假设析构函数泄漏不会发生是合理的,因为任何泄漏析构函数的程序都可能是错误的。但是*不安全*代码不能依赖于析构函数来运行以确保安全。对于大多数类型来说,这无关紧要:如果您泄漏了析构函数,那么根据定义该类型是不可访问的,所以没关系,对吗?例如,如果您泄漏了一个 Box<u8>,那么您会浪费一些内存,但这不太可能违反内存安全。

然而,我们必须小心析构函数泄漏的地方是*代理*类型。这些类型管理对不同对象的访问,但实际上并不拥有它。代理对象非常罕见。您需要关心的代理对象甚至更少。但是我们将重点关注标准库中的三个有趣的示例

  • vec::Drain
  • Rc
  • thread::scoped::JoinGuard

Drain

drain 是一个集合 API,它在不消耗容器的情况下将数据移出容器。这使我们能够在声明对其所有内容的所有权后重用 Vec 的分配。它会产生一个迭代器 (Drain),该迭代器按值返回 Vec 的内容。

现在,考虑一下在迭代中间的 Drain:一些值已被移出,而另一些值尚未移出。这意味着 Vec 的一部分现在充满了逻辑上未初始化的数据!我们可以每次删除值时将 Vec 中的所有元素后移,但这会产生灾难性的性能后果。

相反,我们希望 Drain 在被删除时修复 Vec 的后备存储。它应该自行运行完成,后移任何未删除的元素(drain 支持子范围),然后修复 Vec 的 len。它甚至是展开安全的!简单!

现在考虑以下情况

let mut vec = vec![Box::new(0); 4];

{
    // start draining, vec can no longer be accessed
    let mut drainer = vec.drain(..);

    // pull out two elements and immediately drop them
    drainer.next();
    drainer.next();

    // get rid of drainer, but don't call its destructor
    mem::forget(drainer);
}

// Oops, vec[0] was dropped, we're reading a pointer into free'd memory!
println!("{}", vec[0]);

这显然是不好的。不幸的是,我们有点进退两难:在每一步保持一致的状态会付出巨大的代价(并且会否定 API 的任何好处)。未能保持一致的状态会在安全代码中给我们带来未定义的行为(使 API 不健全)。

那么我们能做什么呢?好吧,我们可以选择一个简单一致的状态:在开始迭代时将 Vec 的 len 设置为 0,并在析构函数中必要时修复它。这样,如果一切都像正常一样执行,我们就能以最小的开销获得所需的行为。但是,如果有人有*胆量*在迭代中间 mem::forget 我们,那么所做的只是*泄漏更多*(并可能将 Vec 置于意外但其他方面一致的状态)。既然我们已经接受了 mem::forget 是安全的,那么这肯定是安全的。我们称泄漏导致更多泄漏为*泄漏放大*。

Rc

Rc 是一个有趣的案例,因为乍一看它根本不像是代理值。毕竟,它管理它指向的数据,并且删除值的所有 Rcs 将删除该值。泄漏 Rc 似乎并没有特别危险。它会将引用计数永久递增,并阻止数据被释放或删除,但这似乎就像 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 {
            // Wouldn't it be nice if heap::allocate worked like this?
            let ptr = heap::allocate::<RcBox<T>>();
            ptr::write(ptr, RcBox {
                data,
                ref_count: 1,
            });
            Rc { 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 the data and then free it
                ptr::read(self.ptr);
                heap::deallocate(self.ptr);
            }
        }
    }
}

这段代码包含一个隐含且微妙的假设:ref_count 可以容纳在 usize 中,因为内存中不能有超过 usize::MAX 的 Rcs。然而,这本身假设 ref_count 准确地反映了内存中 Rcs 的数量,我们知道 mem::forget 会使这个假设失效。使用 mem::forget 我们可以使 ref_count 溢出,然后使用未完成的 Rcs 将其降至 0。然后我们可以愉快地使用后释放内部数据。糟糕糟糕不好。

这可以通过简单地检查 ref_count 并做*某些事情*来解决。标准库的立场是直接中止,因为您的程序已经变得非常退化。而且*我的天哪*这是一个非常荒谬的极端情况。

thread::scoped::JoinGuard

注意:此 API 已从 std 中删除,有关更多信息,您可以参考 issue #24292

本节保留在此处,因为我们认为这个示例仍然很重要,无论它是否是 std 的一部分。

thread::scoped API 旨在允许生成引用其父堆栈上的数据的线程,而无需对该数据进行任何同步,方法是确保父线程在任何共享数据超出范围之前加入该线程。

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 *确实* 被删除时,它会阻塞父线程,确保子线程在任何闭合数据在父线程中超出范围之前终止。

用法如下

let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
    let mut guards = vec![];
    for x in &mut data {
        // Move the mutable reference into the closure, and execute
        // it on a different thread. The closure has a lifetime bound
        // by the lifetime of the mutable reference `x` we store in it.
        // The guard that is returned is in turn assigned the lifetime
        // of the closure, so it also mutably borrows `data` as `x` did.
        // This means we cannot access `data` until the guard goes away.
        let guard = thread::scoped(move || {
            *x *= 2;
        });
        // store the thread's guard for later
        guards.push(guard);
    }
    // All guards are dropped here, forcing the threads to join
    // (this thread blocks here until the others terminate).
    // Once the threads join, the borrow expires and the data becomes
    // accessible again in this thread.
}
// data is definitely mutated here.

原则上,这完全可行!Rust 的所有权系统完美地确保了这一点!...除非它依赖于调用析构函数来确保安全。

let mut data = Box::new(0);
{
    let guard = thread::scoped(|| {
        // This is at best a data race. At worst, it's also a use-after-free.
        *data += 1;
    });
    // Because the guard is forgotten, expiring the loan without blocking this
    // thread.
    mem::forget(guard);
}
// So the Box is dropped here while the scoped thread may or may not be trying
// to access it.

唉。这里的析构函数运行对于 API 来说非常重要,它不得不被废弃,转而采用完全不同的设计。