Skip to content

18.3 Panic Safety

C++中引入了“异常”这个机制之后,同时也带入了一个“异常安全”(exception safety)的概念。对这个概念不熟悉的读者,建议阅读以下文档:

http://www.stroustrup.com/except.pdf

异常安全存在四种层次的保证:

  • No-throw——这种层次的安全性保证了所有的异常都在内部正确处理完毕,外部毫无影响;

  • Strong exception safety——强异常安全保证可以保证异常发生的时候,所有的状态都可以“回滚”到初始状态,不会导致状态不一致的问题;

  • Basic exception safety——基本异常安全保证可以保证异常发生的时候不会导致资源泄漏;

  • No exception safety——没有任何异常安全保证。

当我们在系统中使用了“异常”的时候,就一定要想清楚,每个组件应该提供哪种层级的异常安全保证。在 Rust 中,这个问题同样存在,但是一般叫作 panic safety,与“异常”说的是同一件事情。

下面我们来用代码来示例“异常安全”问题会如何影响我们的代码实现。这次,我们用标准库中的一段代码来演示。下面的代码是从alloc/boxed.rs中复制出来的,clone()方法目的是复制一份新的 Box<[T]>:


rust
impl<T: Clone> Clone for Box<[T]> {
    fn clone(&self) -> Self {
        let mut new = BoxBuilder {
            data: RawVec::with_capacity(self.len()),
            len: 0,
        };

        let mut target = new.data.ptr();

        for item in self.iter() {
            unsafe {
                ptr::write(target, item.clone());
                target = target.offset(1);
            };

            new.len += 1;
        }

        return unsafe { new.into_box() };

        // Helper type for responding to panics correctly.
        struct BoxBuilder<T> {
            data: RawVec<T>,
            len: usize,
        }

        impl<T> BoxBuilder<T> {
            unsafe fn into_box(self) -> Box<[T]> {
                let raw = ptr::read(&self.data);
                mem::forget(self);
                raw.into_box()
            }
        }

        impl<T> Drop for BoxBuilder<T> {
            fn drop(&mut self) {
                let mut data = self.data.ptr();
                let max = unsafe { data.offset(self.len as isize) };

                while data != max {
                    unsafe {
                        ptr::read(data);
                        data = data.offset(1);
                    }
                }
            }
        }
    }
}

这段代码的实现稍微有点复杂,但是逻辑还是很清楚的。我们可以先不考虑标准实现,先想想如果我们自己来实现会怎么做。要 clone()一份 Box<[T]>,就要新分配一份内存空间,循环把每个元素 clone()一遍即可。有个小技巧是,构造 Box<[T]>可以用 RawVec::into_box()来完成,因此我们需要一个 RawVec 来做中转。但是标准库却用了一个更麻烦的实现方式,大家需要注意 BoxBuilder 这个类型的变量是干什么的。

为什么明明可以直接在一个方法里写完的代码,还要引入一个新的类型呢?原因就在于 panic safety 问题。注意我们这里调用了 T 类型的 clone 方法。T 是一个泛型参数,谁能保证 clone 方法不会产生 panic?没有谁能保证,我们只能尽可能让 clone 发生 panic 的时候,RawVec 的状态不会乱掉。

所以,标准库的实现利用了 RAII 机制,即便在 clone 的时候发生了 panic,这个 BoxBuilder 类型的局部变量的析构函数依然会正确执行,并在析构函数中做好清理工作。上面这段代码之所以搞这么复杂,就是为了保证在发生 panic 的时候逻辑依然是正确的。

大家可以去翻一下标准库中的代码,有大量类似的模式存在,都是因为需要考虑 panic safety 问题。Rust 的标准库在编写的时候有这样一个目标:即便发生了 panic,也不会产生“内存不安全”和“线程不安全”的情况。

在 Rust 中,什么情况下 panic 会导致 bug 呢?这种情况的产生需要两个条件:

  • panic 导致了数据结构内部的状态错误;

  • 这个错误的状态会在以后被观测到。

在 unsafe 代码中,这种情况非常容易出现。所以,在写 unsafe 代码的时候,需要对这种情况非常敏感小心,一不小心就可能因为这个原因制造出“内存不安全”。

在不用 unsafe 的情况下,Panic Safety 是基本有保障的。考虑一种场景、假如我们有两个数据结构,我们希望每次在更新其中一个的时候,也要对另外一个同步更新,如果不一致就会有问题。万一更新了其中一个,发生了 panic,第二个没有正常更新怎么办?代码示例如下:


rust
use std::panic;

fn main() {
    let mut x : Vec<i32> = vec![1];
    let mut y : Vec<i32> = vec![2];
    panic::catch_unwind(|| {
        x.push(10);
        panic!("user panic");
        y.push(100);
    }).ok();

    println!("Observe corruptted data. {:?} {:?}", x, y);
}

这里我们必须使用 catch_unwind 来阻止栈展开,否则这两个数据结构就一起被销毁了,无法观测到 panic 引发的错误状态。编译可见,这段代码是无法编译通过的,错误如下:


rust
error[E0277]: the trait bound `&mut std::vec::Vec<i32>: std::panic::UnwindSafe` is not satisfied

这是什么原因呢?因为 catch_unwind 的签名是这样的:


rust
pub fn catch_unwind<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R>

它要求闭包参数满足 UnwindSafe 条件,而标准库中早就标记好了&mut 型指针是不满足 UnwindSafe trait 的。有些类型,天生就不适合在 catch_unwind 的外部和内部同时存在。

有了这个约束条件,被 panic 破坏掉的数据结构被外部继续观测、使用的几率就小了许多。

当然,编译器是永远不知道用户的真实意图的,可能在某些场景下,用户就是要这样写,而且不认为这些数据结构是“被破坏”的状态。怎么修复上面这个编译错误呢?示例如下:


rust
use std::panic;
use std::panic::AssertUnwindSafe;

fn main() {
    let mut x : Vec<i32> = vec![1];
    let mut y : Vec<i32> = vec![2];
    panic::catch_unwind(AssertUnwindSafe(|| {
        x.push(10);
        panic!("user panic");
        y.push(100);
    })).ok();

    println!("Observe corruptted data. {:?} {:?}", x, y);
}

我们可以用 AssertUnwindSafe 把这个闭包包一层,就可以强制突破编译器的限制了。我们也可以单独为某个变量来包一层,可以起到一样的效果。AssertUnwindSafe 这个类型,不管内部包含的是什么数据,它都是满足 catch_unwind 函数约束的。这个设计至少能保证 catch_unwind 可能造成的风险是显式标记出来的。

同理,在多线程情况下也有类似的问题。比如,我们把第 29 章经常用的示例的多线程修改全局变量的程序改改,在其中某个线程中制造一个 panic:


rust
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;

const COUNT: u32 = 1000000;

fn main() {
    let global = Arc::new(Mutex::new(0));

    let clone1 = global.clone();
    let thread1 = thread::spawn(move|| {
        for _ in 0..COUNT {
            match clone1.lock(){
                Ok(mut value) => *value +=1,
                Err(poisoned) => {
                    let mut value = poisoned.into_inner();
                    *value += 1;
                }
            }
        }
    });

    let clone2 = global.clone();
    let thread2 = thread::spawn(move|| {
        for _ in 0..COUNT {
            let mut value = clone2.lock().unwrap();
            *value -= 1;
            if *value < 100000 {
                println!("make a panic");
                panic!("");
            }
        }
    });

    thread1.join().ok();
    thread2.join().ok();
    println!("final value: {:?}", global);
}

在 thread2 中,在达到某个条件的情况下会发生 panic。这个 panic 是在 Mutex 锁定的状态下发生的。这时,标准库会将 Mutex 设置为一个特殊的称为 poisoned 状态。处在这个状态下的 Mutex,再次调用 lock,会返回 Err 状态。它里面依然包含了原来的数据,只不过用户需要显式调用 into_inner 才能使用它。这种方式防止了用户在不小心的情况下产生异常不安全的风险。

Released under the MIT License