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!糟糕!
有趣的是,这行有类似问题,但安全得多:
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 下移一行可以。但何必为难自己?去掉那些 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 就是这样!尽量维持不变量,就不用担心!