高级 The Rust Project Developers 著,Pure White 中译 2026-09-13 14:59:14 · 0 阅读

第8章 Unwinding

译者注:unwind 可以翻译为展开、解卷等等,但是没有找到合适的信达雅的翻译,所以暂且保留英文原文,作为固定词语处理。

Rust 有一个分等级的错误处理方案:

  • 如果某些东西可能不存在,则使用 Option
  • 如果出了问题并且可以合理地处理,则使用 Result
  • 如果有什么东西出错了,而且不能合理地处理,线程就会 panic
  • 如果发生了灾难性的事情,程序就会直接中止(abort)

在大多数情况下,Option 和 Result 是绝大多数人的首选,特别是因为它们可以提供了 API,可以根据用户的决定被提升为 panic 或中止。panic 会导致线程停止正常的执行,并 unwind 它的堆栈,调用析构器,就像每个函数瞬间返回一样。

从 1.0 开始,Rust 在涉及到 panic 时有两种想法。在很久以前,Rust 很像 Erlang,有轻量级的任务,而任务的目的是在达到无法维持的状态时用 panic 来杀死自己。与 Java 或 C++ 中的异常不同,panic 不能在任何时候被捕获。panic 只能被任务的所有者捕捉到,这时必须对其进行处理,否则任务本身就会出现 panic。

unwind 对这个故事很重要,因为如果一个任务的析构器没有被调用,就会导致内存和其他系统资源的泄漏。由于任务会在正常执行过程中死亡,这将使 Rust 在长期运行的系统中变得非常糟糕。

随着我们今天所知道的 Rust 的出现,这种编程风格在对越来越少的抽象的推动下逐渐失去了时尚。轻量级的任务在重量级的操作系统线程中被杀死。尽管如此,在 1.0 版本的稳定版 Rust 上,panic 只能由父线程捕捉。这意味着捕捉 panic 需要使用一整个操作系统线程。不幸的是,这与 Rust 的零成本抽象理念有冲突。

有一个叫做catch_unwind的 API,可以在不产生线程的情况下捕捉到一个 panic。不过,我们还是鼓励你少用这个方法。特别是,Rust 目前的 unwind 实现为“不 unwind”的情况做了大量的优化。如果一个程序没有 unwind,那么这个程序在仅仅预备好 unwind 时就不应该有运行时成本。因此,实际 unwind 的成本会比 Java 中的成本高。在正常情况下,不要让你的程序来 unwind。理想情况下,你应该只为编程错误或极端的问题而 panic。

Rust 的 unwind 策略没有被指定为与任何其他语言的 unwind 在本质上兼容。因此,从其他语言 unwind 到 Rust,或者从 Rust unwind 到其他语言,都是未定义行为。你必须在 FFI 的边界上绝对地捕捉任何 panic!你在这时候(FFI 边界上捕捉到 panic 后)做什么完全由你自己决定,但你必须做一些事情。如果你没有做到这一点,最好的情况是你的应用程序会崩溃,在最坏的情况下,你的应用程序不会崩溃,但至于会发生什么?祝你好运。

异常安全

尽管程序应该很少使用 unwind,但是有很多代码是可以 panic 的。如果你 unwrap 一个 None,索引出界,或者除以 0,你的程序就会 panic。在 debug build 中,每一个算术运算如果溢出,都会引起 panic。除非你非常小心并严格控制代码的运行,否则几乎所有的东西都可能 unwind,你需要做好准备。

在更广泛的编程世界中,为 unwind 做好准备通常被称为异常安全。在 Rust 中,有两个级别的异常安全需要关注:

  • 在不安全的代码中,我们必须保证异常安全到不违反内存安全的程度。我们把这称为最小的异常安全。
  • 在安全代码中,保证异常安全到你的程序能做正确的事情的程度(也就是说,啥都不影响,都恢复了)。我们称其为最大限度的异常安全。

正如 Rust 中许多地方的情况一样,不安全的代码必须准备好处理有问题的安全代码,当它涉及到 unwind 时。有可能在某一时刻创建不健壮状态的代码必须注意,panic 不会导致该状态被使用。也就是说,这意味着当这些状态存在时,只有非 panicking 的代码才会被运行;或者你需要做一个防护,在 panic 的情况下清理该状态。这并不一定意味着 panic 所见证的状态是一个完全一致的状态。我们只需要保证它是一个安全的状态。

大多数不安全代码都是属于叶子代码(也就是不会再调用其它函数/逻辑),因此相当容易使异常安全化。它控制着所有运行的代码,而且大多数代码都不会发生 panic。然而,不安全代码在重复调用调用者提供的代码时,与未初始化的数组打交道是很常见的。这样的代码需要小心谨慎,并考虑异常安全。

Vec::push_all

Vec::push_all是一个临时性的 hack,可以在没有特例化的情况下,通过一个 slice 来高效地扩展一个 Vec。下面是一个简单的实现:

impl<T: Clone> Vec<T> {
    fn push_all(&mut self, to_push: &[T]) {
        self.reserve(to_push.len());
        unsafe {
            // 因为我们刚刚预留了空间,所以这里不会溢出
            self.set_len(self.len() + to_push.len());

            for (i, x) in to_push.iter().enumerate() {
                self.ptr().add(i).write(x.clone());
            }
        }
    }
}

我们绕过了push,以避免对我们明确知道有容量的 Vec 进行多余的容量和len检查。这个逻辑是完全正确的,只是我们的代码有一个微妙的问题:它不是异常安全的!set_lenaddwrite都没问题;但clone是我们忽略的 panic 炸弹。

Clone 完全不受我们的控制,而且完全可以自由地 panic。如果它这样做,我们的函数将提前退出;而因为 Vec 的长度被设置得太大了,如果 Vec 被读取或丢弃,未初始化的内存将被读取!

这种情况下的修复方法相当简单,如果我们想保证我们已经复制的值被丢弃,我们可以在每个循环迭代中设置len。如果我们只是想保证未初始化的内存不能被观察到,我们可以在循环之后设置len

BinaryHeap::sift_up

把一个元素扔到堆中,比扩展一个 Vec 要复杂一些。伪代码如下:

bubble_up(heap, index):
    while index != 0 && heap[index] < heap[parent(index)]:
        heap.swap(index, parent(index))
        index = parent(index)

将这段代码按字面意思翻译成 Rust 是完全没有问题的,但是有一个坑爹的性能问题:self元素被无用地反复交换。因此,我们可以这么做:

bubble_up(heap, index):
    let elem = heap[index]
    while index != 0 && elem < heap[parent(index)]:
        heap[index] = heap[parent(index)]
        index = parent(index)
    heap[index] = elem

这段代码确保每个元素尽可能少地被复制(事实上,在一般情况下,elem 有必要被复制两次)。但是它现在暴露了一些异常安全问题! 在任何时候,一个值都存在两个副本。如果我们在这个函数中 panic,就会有东西被重复 drop。不幸的是,我们对执行的代码并没有完全的掌控力——因为比较方法是用户定义的!

与 Vec 不同,这里的修复并不容易。一个可选的方案是将用户定义的代码和不安全的代码分成两个独立的阶段:

bubble_up(heap, index):
    let end_index = index;
    while end_index != 0 && heap[index] < heap[parent(end_index)]:
        end_index = parent(end_index)

    let elem = heap[index]
    while index != end_index:
        heap[index] = heap[parent(index)]
        index = parent(index)
    heap[index] = elem

如果用户定义的代码炸了,那就没有问题了,因为我们还没有真正接触到堆的状态。一旦我们开始接触堆,我们就只与我们信任的数据和函数打交道,所以不存在 panic 的问题。

也许你对这种设计并不满意,不过我不得不说,这确实是在作弊! 而且我们还得做复杂的堆遍历两次! 好吧,让我们咬咬牙,把不可信任的和不安全的代码混在一起。

如果 Rust 像 Java 一样有tryfinally,我们就可以做以下事情:

bubble_up(heap, index):
    let elem = heap[index]
    try:
        while index != 0 && elem < heap[parent(index)]:
            heap[index] = heap[parent(index)]
            index = parent(index)
    finally:
        heap[index] = elem

基本的想法很简单:如果比较出现问题,我们就把松散的元素扔到逻辑上未初始化的索引中,然后就直接返回。任何观察堆的人都会看到一个潜在的不一致的堆,但至少它不会导致任何双重释放问题。而如果算法正常终止,那么这个操作恰好与我们的结束方式不谋而合。

遗憾的是,Rust 没有这样的结构,所以我们需要推出我们自己的结构。这样做的方法是将算法的状态存储在一个单独的结构中,并为“最终”逻辑设置一个析构函数。无论我们是否 panic,这个析构函数都会在我们之后运行和清理:

struct Hole<'a, T: 'a> {
    data: &'a mut [T],
    /// `elt` 从始至终都是 Some
    elt: Option<T>,
    pos: usize,
}

impl<'a, T> Hole<'a, T> {
    fn new(data: &'a mut [T], pos: usize) -> Self {
        unsafe {
            let elt = ptr::read(&data[pos]);
            Hole {
                data: data,
                elt: Some(elt),
                pos: pos,
            }
        }
    }

    fn pos(&self) -> usize { self.pos }

    fn removed(&self) -> &T { self.elt.as_ref().unwrap() }

    fn get(&self, index: usize) -> &T { &self.data[index] }

    unsafe fn move_to(&mut self, index: usize) {
        let index_ptr: *const _ = &self.data[index];
        let hole_ptr = &mut self.data[self.pos];
        ptr::copy_nonoverlapping(index_ptr, hole_ptr, 1);
        self.pos = index;
    }
}

impl<'a, T> Drop for Hole<'a, T> {
    fn drop(&mut self) {
        // fill the hole again
        unsafe {
            let pos = self.pos;
            ptr::write(&mut self.data[pos], self.elt.take().unwrap());
        }
    }
}

impl<T: Ord> BinaryHeap<T> {
    fn sift_up(&mut self, pos: usize) {
        unsafe {
            // 取出 `pos` 的值,然后创建一个 hole
            let mut hole = Hole::new(&mut self.data, pos);

            while hole.pos() != 0 {
                let parent = parent(hole.pos());
                if hole.removed() <= hole.get(parent) { break }
                hole.move_to(parent);
            }
            // 无论是否 panic,这里的 hole 都会被无条件填充
        }
    }
}

Poisoning

尽管所有不安全的代码都必须确保其具有最小的异常安全,但并非所有类型都能确保最大的异常安全;而即使类型保证了,你的代码也可能导致额外的问题。例如,一个整数当然是异常安全的,但它本身没有语义。panic 的代码有可能无法正确地更新整数,从而产生不一致的程序状态。

通常是没问题的,因为任何见证异常的东西都会被销毁。例如,如果你发送一个 Vec 给另一个线程,而那个线程 panic 了,那么这个 Vec 是否处于一个奇怪的状态并不重要。它将被丢弃并永远消失。然而,有些类型特别擅长跨越 panic 边界获取值。

这些类型可以选择明确地 毒害(Poison) 自己,如果他们遇到了一个 panic。Poisoning 并不意味着什么特别的事情。一般来说,它只是意味着阻止正常的使用继续进行。这方面最明显的例子是标准库的 Mutex 类型。如果 Mutex 的一个 MutexGuards(当获得锁时返回的东西)在 panic 中被丢弃,Mutex 将自我中毒。今后任何试图锁定 Mutex 的行为都会返回Err或 panic。

Mutex 中毒不是为了 Rust 通常关心的真正的安全。它是作为一种安全防护措施,防止盲目地使用在锁定时发生了 panic 的 Mutex 中的数据。这样的 Mutex 中的数据可能正在被修改中,因此可能处于不一致或不完整的状态。需要注意的是,如果正确地编写了这样一个类型,就不会违反内存安全。毕竟,它必须是最低限度的异常安全的。

然而,如果 Mutex 包含,比如说,一个实际上不具备堆属性的 BinaryHeap,那么任何使用它的代码都不可能按照作者的意图运行。因此,程序不应该正常进行。不过,如果你确信你可以对这个值做一些事情,Mutex 还是暴露了一个方法来获得锁。毕竟,它安全的,只是可能是无稽之谈。

评论 (0)