Rust 泄露

基于所有权的资源管理是为了简化复合类型而存在的。你在创建对象的时候获取资源,在销毁对象的时候释放资源。由于析构过程做了处理,你不可能忘记释放资源,而且是尽可能早地释放资源!这简直是一个完美的方案,解决了我们所有的问题。

可实际上可怕的事情遍地都是,我们还有新的奇怪的问题需要解决。

许多人觉得Rust已经消除了资源泄露的可能性。实际应用中也差不多是这样。你不太可能看到安全Rust出现不可控制的资源泄露。

但是,从理论的角度来说,情况却完全不同。在科学家看来,“泄露”太过于抽象,根本无法避免。很可能就会有人在程序的开头初始化一个集合,塞进去一大堆带析构函数的对象,接下来就进入一个死循环,再也不理开始的那个集合。那个集合就只能坐在那里无所事事,死死地抱着宝贵的资源等着程序结束(这时操作系统会强制回收资源)。

我们可能要给泄露一个更严格的定义:无法销毁不可达(unreachable)的值。Rust也不能避免这种泄露。事实上Rust还有一个制造泄露的函数:mem::forget。这个函数获取传给它的值,但是不调用它的析构函数。

mem::forget曾经被标为unsafe,作为不要滥用它的一种警告。毕竟不调用析构函数一般来说不是一个好习惯(尽管在某些特殊情况下很有用)。但其实这个判断比较不靠谱,因为在安全代码中不调用析构函数的情况很多。最经典的例子是一个循环引用的计数引用。

安全代码可以合理假设析构函数泄露是不存在的,因为任何有这一问题的程序都可能是错误的。但是,非安全代码不能依赖于运行析构函数来保证程序安全。对于大多数类型而言,这一点不成问题:如果不能调用析构函数,那其实类型本身也是不可访问的,所以这就不是个问题了,对吧?比如,你没有释放Box<u8>,那么你会浪费一点内存,但是这并不会违反内存安全性。

但是对于代理类型,我们就要十分小心它的析构函数了。有几个类型可以访问一个对象,却不拥有对象的所有权。代理类型很少见,而需要你特别小心的类型就更稀少了。但是,我们要仔细研究一下标准库中的三个有意思的例子。

 

Drain

drain是一个集合API,它将容器内的数据所有权移出,却不占有容器本身。我们可以声明一个Vec所有内容的所有权,然后复用分配给它的空间。它产生一个迭代器(Drain),以返回Vec的所有值。

现在,假设Drain正迭代到一半:有一些值被移出,还有一些没移出。这表明Vec里有一堆逻辑上未初始化的数据!我们可以在删除值的时候在Vec里再备份一份,但这种方法的性能是不可忍受的。

实际上,我们希望Drain在销毁的时候能够修复Vec的后台存储。他要备份那些没有被移除的元素(drain支持子范围),然后修改Vec的len。这种方法甚至还是unwinding安全的!完美!

看看下面这段代码

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

{
    // 开始drain,vec无法再被访问
    let mut drainer = vec.drain(..);

    // 移除两个元素,然后立刻销毁他们
    drainer.next();
    drainer.next();

    // 销毁drainer,但是不调用它的析构函数
    mem::forget(drainer);
}

// 不好,vec[0]已经被销毁了,我们在读一块释放后的内存
println!("{}", vec[0]);

这个显然很不好。我们现在陷入了两难的境地:保证每一步产生一致的状态,需要付出巨大的性能代价(抵消掉了API带来的所有好处);而不保证一致状态则会在安全代码中产生未定义行为(使API失去稳定性)。

那我们能做什么呢?我们采用一种简单粗暴的方式保证状态一致性:开始迭代的时候就设置Vec的长度为0,然后在析构函数里根据需要再恢复。这样做,在一切正常的情况下,我们可以用最小的代价获得正确的行为。但是,如果有人就是不管不顾地在迭代中间mem::forget,那么结果就是泄露或者更坏(还可能让Vec处于一种虽然一致但实际上不正确的状态)。由于我们认为mem::forget是安全地,那么这种行为也是安全地。我们把造成更多泄露的泄露叫做泄露扩大化(leak amplification)。

Rust 泄露

Rc 的情况很有意思,第一眼看上去它根本不像是一个代理类型。毕竟,它自己管理着它指向的数据,并且在销毁Rc的时候也会同时销毁数据的值。泄露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 {
            // 如果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 {
                // 销毁数据然后释放空间
                ptr::read(self.ptr);
                heap::deallocate(self.ptr);
            }
        }
    }
}

要解决这个问题,我们可以检查ref_count并根据情况做一些处理。标准库的做法是直接废弃对象,因为这种情况下你的程序进入了一种非常危险的状态。当然,这是一个十分诡异的边界场景。

thread::scoped可以保证父线程在共享数据离开作用域之前join子线程,通过这种方式子线程可以引用父线程栈中的数据而不需要做什么同步操作。

pub fn scoped<'a, F>(f: F) -> JoinGuard<'a>
    where F: FnOnce() + Send + 'a

这里f是供其他线程执行的闭包。F: Send + 'a表示闭包引用数据的生命周期是'a,而且它可能拥有这个数据或者数据是一个Sync(说明&dataSend)。

因为JoinGuard有生命周期,它所用到的数据都是从父线程里借用的。这意味着JoinGuard不能比线程使用的数据存活更长。当JoinGuard被销毁的时候它会阻塞父线程,保在父线程中被引用的数据离开作用域之前子线程都已经终止了。

用法是这样的:

let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
    let guards = vec![];
    for x in &mut data {
        // 将可变引用移入闭包,然后再另外一个线程里执行它
        // 闭包有生命周期,其界限由可变引用x的生命周期决定
        // 返回的guard也和闭包有相同的生命周期,所以它也和x一样可变借用了data
        // 这意味着在guard销毁前我们不能访问data
        let guard = thread::scoped(move || {
            *x *= 2;
        });
        // 储存线程的guard供后面使用
        guards.push(guard);
    }
    // 所有的guard在这里被销毁,强制线程join(主线程阻塞在这里等待其他线程终止)。
    // 等到线程join后,数据的借用就过期了,数据又可以在主线程中被访问了
}
// 数据在这里已经完全改变了。

这个似乎完全能够正常工作!Rust的所有权系统完美地保证了这一点!……不过这一切的前提是析构函数必须被调用。

let mut data = Box::new(0);
{
    let guard = thread::scoped(|| {
        // 好一点的情况是这里会有数据竞争
        // 最坏的情况是这里会有释放后应用(use-after-free)
        *data += 1;
    });
    // 因为guard被forget了,线程不会阻塞
    mem::forget(guard);
}
// Box在这里被销毁,而子线程可能会也可能不会在这里访问数据。

Duang!保证析构函数能运行是这个api的基础,上面这段代码需要一个全新的设计才行。

Rust有一个分层的错误处理体系:如果有些值可以为空,就用Option如果发生了错误,而错误可以被正常处理,就用Result如果发生了错误,但是没办法正常处理,就让线程panic如果发生了更严重的问题,中止(abort)程序 ...