6.4 恐慌安全

原文链接: https://rust-unofficial.github.io/too-many-lists/sixth-panics.html

嘿,你注意到这条注释了吗:

1
2
// 注意我们不再需要折腾 `take`,
// 因为一切都是 Copy,搞砸了也没有析构函数会跑……对吧?:) 对吧?:)))

对吗?

抱歉你忘了在读什么书?当然不对!(某种程度上。)

再看 pop_front 内部:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 让 Box 复活,以便移出值并 Drop 它
// (Box 继续神奇地帮我们处理)。
let boxed_node = Box::from_raw(node.as_ptr());
let result = boxed_node.elem;

// 让下一个节点成为新 front。
self.front = boxed_node.back;
if let Some(new) = self.front {
    // 清理它对已移除节点的引用
    (*new.as_ptr()).front = None;
} else {
    // front 现在是 null,链表空了!
    debug_assert!(self.len == 1);
    self.back = None;
}

self.len -= 1;
result
// Box 在这里隐式释放,知道里面没有 T。

看到 bug 了吗?可怕的是,其实是这一行:

1
debug_assert!(self.len == 1);

真的?我们该死的测试完整性检查居然是 bug??对!!!实现正确的话不该是,但它能把「len 没维护好」这种小事变成可利用的内存安全 bug!为什么?因为它会 panic!多数时候不用想 panic,但一旦写非常 unsafe 的代码、对「不变量」玩火,就得对 panic 高度警惕!

得谈 异常安全(又叫 panic 安全、unwind 安全……)。

默认情况下 panic 会展开(unwind)。展开就是「让每个函数立刻返回」的花哨说法。你可能想「大家都返回了程序就要死了,何必在意?」——错了!

有两个原因:函数返回时析构函数会跑;展开可以被捕获。两种情况下代码都可能继续跑,所以必须保证 unsafe 集合在可能 panic 时始终处于某种一致状态,因为每次 panic 都是隐式提前返回!

想想执行到那行时集合处于什么状态:

栈上有 boxed_node,元素已取出。若此时返回,Box 被 drop,节点被释放。看到了吗……?self.back 仍指向已释放节点!实现集合其余部分并用 self.back 时,可能导致 use-after-free!糟糕!

有趣的是,这行有类似问题,但安全得多:

1
self.len -= 1;

debug 构建里 Rust 默认检查溢出下溢,会 panic。是的,每个算术运算都是 panic 安全隐患!这行更好,因为在修复所有不变量之后,不会造成内存安全问题……只要我们不信 len 是对的,但下溢了它肯定错了,横竖都是死!debug assert 在某种意义上更糟,因为能把小问题升级成严重问题!

我提过几次「不变量」,因为对 panic 安全很有用!对外部观察者,集合始终维持某些性质。对 LinkedList,其中之一是:链表中可达的每个节点仍被分配且已初始化。

内部实现可以暂时打破不变量,只要在别人发现前修好。这其实是 Rust 所有权借用对集合的「杀手级应用」之一:若操作需要 &mut Self,我们保证独占访问,可以暂时打破不变量,确信没人能偷偷动它。

最典型的表达是 Vec::drain,它让你彻底粉碎 Vec 的核心不变量,从前面甚至中间移出值。之所以健全,是因为返回的 Drain 迭代器持有 Vec 的 &mut,所有访问都经过它!在 Drain 消失前没人能观察 Vec,然后析构函数能在别人注意前「修复」Vec,完美——

并不完美。可惜不能指望你无法控制的代码里的析构函数会跑,所以即使用 Drain 也要额外工作,让类型始终维持不变量,方式有点滑稽:一开始就把 Vec 的 len 设为 0,若有人泄漏 Drain,他们得到的是安全的 Vec……但也丢了一堆数据。你泄漏我?我泄漏你!以眼还眼!真正的正义!

若真能靠析构函数做 panic 安全,看 BinaryHeap::sift_up 案例。

总之 LinkedList 不需要这些花哨东西,只需更警惕在哪里打破不变量、信任/要求什么正确,避免在棘手操作中间引入不必要的 unwind。

这里有两个让代码更健壮的选择:

  • 更积极用 Option::take 等操作,因为它们更「事务性」,倾向于维持不变量。

  • 去掉 debug_assert,靠更好的测试和专用「完整性检查」函数,永不在用户代码里跑。

原则上我喜欢第一种,但对双向链表不太好用,因为一切双重编码。Option::take 修不了这里的问题,把 debug_assert 下移一行可以。但何必为难自己?去掉那些 debug_assert,确保会 panic 的东西在方法开头或结尾,那时不变量应已知成立。

(这样或许更准确地把它们想成前置条件和后置条件,但应尽量当作不变量!)

完整实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
use std::ptr::NonNull;
use std::marker::PhantomData;

pub struct LinkedList<T> {
    front: Link<T>,
    back: Link<T>,
    len: usize,
    _boo: PhantomData<T>,
}

type Link<T> = Option<NonNull<Node<T>>>;

struct Node<T> {
    front: Link<T>,
    back: Link<T>,
    elem: T, 
}

impl<T> LinkedList<T> {
    pub fn new() -> Self {
        Self {
            front: None,
            back: None,
            len: 0,
            _boo: PhantomData,
        }
    }

    pub fn push_front(&mut self, elem: T) {
        // SAFETY: 这是链表,你还想怎样?
        unsafe {
            let new = NonNull::new_unchecked(Box::into_raw(Box::new(Node {
                front: None,
                back: None,
                elem,
            })));
            if let Some(old) = self.front {
                // 把新 front 插到旧的前面
                (*old.as_ptr()).front = Some(new);
                (*new.as_ptr()).back = Some(old);
            } else {
                // 没有 front,说明是空链表,也要设置 back。
                self.back = Some(new);
            }
            // 这些总会发生!
            self.front = Some(new);
            self.len += 1;
        }
    }

    pub fn pop_front(&mut self) -> Option<T> {
        unsafe {
            // 只有存在 front 节点时才需要操作。
            self.front.map(|node| {
                // 让 Box 复活,以便移出值并 Drop 它
                // (Box 继续神奇地帮我们处理)。
                let boxed_node = Box::from_raw(node.as_ptr());
                let result = boxed_node.elem;

                // 让下一个节点成为新 front。
                self.front = boxed_node.back;
                if let Some(new) = self.front {
                    // 清理它对已移除节点的引用
                    (*new.as_ptr()).front = None;
                } else {
                    // front 现在是 null,链表空了!
                    self.back = None;
                }

                self.len -= 1;
                result
                // Box 在这里隐式释放,知道里面没有 T。
            })
        }
    }

    pub fn len(&self) -> usize {
        self.len
    }
}

这里什么会 panic?老实说需要 Rust 专家,幸好我是!

我能看到的可能 panic 的地方(除非有人用 debug_assert 重编译 stdlib,永远别这么干)是 Box::new(内存不足)和 len 算术。都在方法最前或最后,嗯,我们很安全!

……Box::new 能 panic 让你意外吗?Panic 就是这样!尽量维持不变量,就不用担心!

最后修改 August 23, 2026: 更新 (499855b16)