第2章 认识安全和不安全
我们都不想关心底层的实现细节。谁会关心空元组占用了多少空间呢?可悲的是,它有时很重要,我们需要担心这个问题。开发人员开始关心实现细节的最常见的原因是性能,但更重要的是,当与硬件、操作系统或其他语言直接交互时,这些细节就是关乎对错的问题。
当实现细节在安全的编程语言中开始变得重要时,程序员通常有三种选择。
- 调整代码以鼓励编译器/运行时进行优化
- 采用一种更不规范或更繁琐的设计来获得所需的实现
- 用一种能让你处理这些细节的语言重写实现
对于最后一种选择,程序员往往使用的语言是C。这对于对接那些只声明 C 语言接口的系统来说往往是必要的。
不幸的是,C 语言使用起来非常不安全(尽管有时有很好的理由),当试图与另一种语言交互时,这种危险会被放大。我们必须小心翼翼地确保 C 语言和其他语言的一致性,以使它们不会越俎代庖。
那么,这与 Rust 有什么关系呢?
嗯,与 C 不同,Rust 是一种安全的编程语言。
但是,和 C 语言一样,Rust 也是一种不安全的编程语言。
更准确地说,Rust 同时包含了一种安全和一种不安全的编程语言。
Rust 可以被认为是两种编程语言的结合。Safe Rust 和Unsafe Rust。顾名思义,Safe Rust 是安全的。Unsafe Rust 是,嗯,不安全的。事实上,Unsafe Rust 让我们做一些真正不安全的事情。Rust 的作者会恳求你不要做这些事情,但我们还是要做。
Safe Rust 是真正的 Rust 编程语言。如果你只写 Safe Rust,你将永远不必担心类型安全或内存安全的问题。你永远不会遇见悬空的指针,释放后使用(use-after-free),或任何其他类型的未定义行为。
标准库也为你提供了足够多的开箱即用的工具,你将能够用纯粹的Safe Rust 编写高性能的应用程序和库。
但是,也许你想调用另一种语言,也许你正在写一个标准库没有暴露的低级抽象,也许你正在写标准库(它完全是用 Rust 写的),也许你需要做点类型系统看不懂的底层数据操作。也许你需要Unsafe Rust。
Unsafe Rust 与Safe Rust 完全一样,具有所有相同的规则和语义。它只是允许你做一些额外的、绝对不安全的事情(我们将在下一节中定义)。
这种分离的价值在于,我们获得了使用像 C 这样的不安全语言的好处——获得对底层实现细节的控制——而与其他安全语言交互时却没有那么多问题了。
仍然有一些问题——最明显的是,我们必须意识到类型系统有一些程序必须遵守的假设的规则,且认真审核任何与 Unsafe Rust 交互的代码以遵守规则。这就是本书的目的:教你了解这些规则以及如何遵守它们。
Safe 和 Unsafe 如何交互
Safe Rust 和 Unsafe Rust 之间有什么关系?它们又是如何交互的?
Safe Rust 和 Unsafe Rust 之间的边界由unsafe关键字控制,unsafe是承接了它们之间交互的桥梁。这就是为什么我们可以说 Safe Rust 是一种安全的语言:所有不安全的部分都被限制在“unsafe”边界之内。如果你愿意,你甚至可以把#![forbid(unsafe_code)]扔进你的代码库,以静态地保证你只写 Safe Rust。
unsafe关键字有两个用途:声明编译器不会保证这些代码的安全性,以及声明程序员已经确保这些代码是安全的。
在 函数 和 trait 声明 上添加unsafe前缀表示其中存在未经检查的约束。对于函数,unsafe意味着函数的用户必须仔细阅读该函数的文档,以确保他们的使用方式遵循了该函数规定的约束。对于 trait 声明,unsafe意味着 trait 的实现者必须仔细阅读 trait 文档,以确保他们的实现遵循了该 trait 规定的约束。
在代码块上添加unsafe前缀可以声明在其中执行的所有不安全操作都经过了验证(遵循了内部不安全操作所规定的约束)。传递给slice::get_unchecked的索引在边界内时,就是一个可以这样添加unsafe前缀的例子。
在 trait 实现上添加unsafe前缀可以声明该实现满足了 trait 所规定的约束。例如,当一个类型的值移动到另一个线程是真正安全的时,便可在Send的实现前添加unsafe前缀。
标准库中有许多 unsafe 的函数,包括:
slice::get_unchecked,它不会检查传入索引的有效性,允许违反内存安全的规则。mem::transmute将一些数据重新解释为给定的类型,绕过类型安全的规则(详见conversions)。- 每一个指向一个 Sized 类型的原始指针都有一个
offset方法,如果传递的偏移量不在“界内”,则该调用是未定义行为。 - 所有 FFI(外部函数接口 Foreign Function Interface)函数的调用都是
unsafe的,因为 Rust 编译器无法检查其他语言的操作。
从 Rust 1.29.2 开始,标准库定义了以下 unsafe trait(还有其他 trait,但还没有稳定下来,有些可能永远不会稳定下来):
Send是一个标记 trait(一个没有 API 的 trait),用于保证实现了Send的类型可以安全地发送(移动)到另一个线程。Sync是一个标记 trait,用于保证线程间可以通过共享引用安全地共享实现了Sync的类型。GlobalAlloc允许自定义整个程序的内存分配器。
Rust 标准库也有很多地方在内部使用了 Unsafe Rust。这些实现一般都经过严格的人工检查,所以建立在这些实现之上的 Safe Rust 接口可以被认为是安全的。
之所以要像这样分离 Safe 和 Unsafe,归根到底在于 Safe Rust 的一个根本属性,即可靠性。
无论怎样,Safe Rust 都不能导致未定义行为。
Safe 与 Unsafe 分离的设计意味着 Safe Rust 和 Unsafe Rust 之间存在着不对等的信任关系。一方面, Safe Rust 本质上必须相信它所接触的任何 Unsafe Rust 都是正确编写的。另一方面,Unsafe Rust 在信任 Safe Rust 时必须非常小心。
例如,Rust 有PartialOrd和Ord trait 来区分“偏序”比较的类型和“全序”比较的类型(前者仅能进行比较而未必得出大小关系,而后者意味着每一个比较都有合理的结果)。
BTreeMap以没有定义全序关系的类型作为 key 是没有意义的,因此它要求其 key 实现Ord。然而,BTreeMap的实现中包含了 Unsafe 的代码。由于(用 Safe 代码就能写出的)不靠谱的Ord实现导致未定义行为是不可接受的,因此,BTreeMap 中的 Unsafe 代码必须健壮到这个地步:对于实际上并非全序关系的Ord实现也不会导致未定义行为——尽管我们指定Ord约束就是为了得到全序关系。
Unsafe Rust 代码不能信任 Safe Rust 代码逻辑无误。话虽如此,如果你输入的值,其类型并没有全序关系,BTreeMap仍然会变得乱七八糟。上一段只是说明它不会导致未定义行为。
有人可能会问,如果BTreeMap不能基于“它是 Safe 代码编写的”这一理由而信任Ord,那还有什么 Safe 代码是能信任的呢?例如,BTreeMap依赖于整数和切片的正确实现。这些也是 Safe Rust 编写的,不是么?
区别在于范围的不同。当BTreeMap依赖于整数和切片时,它依赖于一个完全特定的实现。这里的风险经过评估可以与收益相权衡。在这个特定场景下,风险基本为零;如果整数和切片出了问题,什么东西都会出问题,因此不可能被忽视。而且,它们和BTreeMap是由同一批人维护的,所以很容易对它们进行监控。
另一方面,BTreeMap的 key 类型是泛型的。信任它的Ord实现意味着信任过去、现在和未来的每一个Ord实现。这里的风险很高:总有人会犯错误,把Ord实现坏,甚至直接谎称提供了一个全序关系,因为“这个实现看上去够用”。对于这种情况,BTreeMap需要有备无患。
同样的逻辑也适用于信任一个传递给你的闭包的行为是正确的。
问题是能否无限信任泛型类型参数?unsafe trait 应运而生。理论上,BTreeMap类型可以要求 key 实现一个新的 trait,称为UnsafeOrd,而不是Ord,它可能看起来像这样:
#![allow(unused)] fn main() { use std::cmp::Ordering; unsafe trait UnsafeOrd { fn cmp(&self, other: &Self) -> Ordering; } }
然后,为一个类型实现UnsafeOrd就要带上unsafe前缀,表明开发者已经确保他们的实现遵循了该 trait 所预期的任何约束。在这种情况下,BTreeMap内部的 Unsafe Rust 有理由相信 key 类型的UnsafeOrd实现是正确的。否则错就在 unsafe trait 的实现,这与 Rust 的安全保证是一致的。
是否将 trait 标记为unsafe是 API 设计取舍的问题。Safe trait 实现起来更轻松,但任何依赖它的 Unsafe 代码面临不正确的实现也不能引发未定义行为。将 trait 标记为unsafe会将这个责任转移到实现者身上。按照 Rust 传统,往往避免将 trait 标记为unsafe,否则 Unsafe Rust 会无处不在,我们并不想看到这个结果。
Send和Sync被标记为 unsafe,是因为线程安全是一个根本的属性,要像应对一个有缺陷的Ord实现一样应对线程安全问题,对 unsafe 代码来说是不可能的。同理,GlobalAlloc被用于管理程序中所有的内存分配,诸如Box或Vec都建立在它的基础上。如果GlobalAlloc不正常了(例如把一块还被占用着的内存返回给了另一个请求),是绝无可能靠检测来补救的。
是否将你自己的 trait 标记为unsafe,也要基于类似的考虑做出决定。如果unsafe代码无法有效应对 trait 的错误实现,那么将 trait 标记为unsafe合情合理。
顺便一提,虽然Send和Sync是unsafe trait,但是当类型系统可以证明派生Send/Sync安全时,它们也会被自动实现。每个字段类型都满足Send的类型会自动派生Send。每个字段类型都满足Sync的类型会自动派生Sync。通过这种方式,这两个 trait 扩散unsafe的影响被控制到最小。而对于内存分配器,没多少人会去实现它们(说起来,直接使用内存分配器的人都很少)。
上文展示了 Safe Rust 和 Unsafe Rust 之间的平衡。将两者分离的设计,目的是让使用 Safe Rust 尽可能符合工效,反过来在编写 Unsafe Rust 时则需要额外的努力和细心。本书的其余部分主要是讨论需要什么形式的细心,以及Unsafe Rust 必须遵循什么约束。
Unsafe Rust 能做什么
在 Unsafe Rust 中唯一不同的是,你可以:
- 对原始指针进行解引用
- 调用 “Unsafe” 的函数(包括 C 函数、编译器的内建指令和原始分配器。
- 实现 “Unsafe” trait
- 访问或者修改可变的静态变量
- 访问 “union” 的字段
这就是全部了。这些操作被归入 unsafe 的原因是,误用其中的任何一项都会引起可怕的未定义行为。调用“未定义行为”使编译器有充分的权利对你的程序做任何坏事。你绝对不能调用“未定义行为”。
与 C 语言不同,Rust 中的“未定义行为”的范围相当有限。核心语言中,你只需要关心防止以下事情:
- 解除引用(使用
*运算符)悬空或不对齐的指针(见下文) - 破坏指针别名规则
- 调用一个 ABI 错误的函数,或者从一个 unwind ABI 错误的函数中 unwinding
- 引起数据竞争
- 执行用当前执行线程不支持的目标特性编译的代码
- 产生无效的值(无论是单独的还是作为一个复合类型的字段,如
enum/struct/array/tuple)- 一个不是 0 或 1 的
bool - 一个具有无效判别符的
enum - 一个空的
fn指针 - 一个超出[0x0, 0xD7FF]和[0xE000, 0x10FFFF]范围的
char - 一个
!(所有的值对这个类型都是无效的) - 一个从未初始化的内存读出的整数(
i*/u*)、浮点值(f*)或原始指针,或str中的未初始化的内存 - 一个悬空的、不对齐的、或指向无效值的引用/
Box - 一个胖指针、
Box或原始指针,具有无效的元数据:- 如果一个
dyn Trait指针 / 引用指向的 vtable 和对应 Trait 的 vtable 不匹配,那么dyn Trait的元数据是无效的 - 如果 Slice 的长度不是有效的 usize(比如,从未初始化的内存中读取的 usize),那么 Slice 的元数据是无效的
- 如果一个
- 一个由类型自定义的无效值,比如在标准库中的
NonNull和NonZero*(自定义无效值是一个不稳定的特性,但一些稳定的 libstd 类型,如NonNull使用了这个特性)。
- 一个不是 0 或 1 的
如果你想要了解更多关于“未定义行为”的信息,可以参考 Rust 参考手册。
赋值、传递给一个函数/原始操作、从一个函数/原始操作返回的时候,都会“产生”一个值。
如果一个引用/指针是空的,或者它所指向的地址并非都是合法的地址(合法地址都应该是已分配内存的),那么它就是悬垂的。它所指向的范围是由指针值和被指向类型的大小决定的(使用size_of_val)。因此,如果指向的范围是空的,悬垂与空是一样的。要注意,切片和字符串指向它们的整个范围,所以它们元数据中的长度不能太大。内存分配的长度、切片和字符串的长度不能大于isize::MAX字节。如果因为某些原因,这太麻烦了,可以考虑使用原始指针。
这就是所有 Rust 中可能会导致未定义行为的原因。当然,unsafe 的函数和 trait 可以自由地声明任意的其他约束,程序必须保持这些约束以避免未定义行为。例如,分配器 API 声明,释放未分配的内存是未定义行为。
然而,对这些约束的违反通常只会导致上述问题中的一个,一些额外的约束也可能来自于编译器,编译器为优化代码做出了特殊的假设。例如,Vec 和 Box 使用了内建指令,要求他们的指针在任何时候都是非空的。
Rust 在其他方面对其他可疑的操作是相当宽容的。Rust 认为以下情况是“安全的”:
- 死锁
- 有一个数据竞争
- 内存泄漏
- 整数溢出(使用内置的运算符,比如“+”)
- 中止程序
- 删除生产数据库
如果你想了解更多信息,可以参考 Rust 参考手册。
然而任何真正可能做这种事情的程序都是可能不正确的,Rust 提供了很多工具来尽可能检查出这些问题,但要这些问题完全被预防是不现实的。
使用 Unsafe
Rust 通常让我们以作用域的方式来限制 unsafe 代码块。不幸的是,现实要比这复杂得多。例如,考虑下面这个玩具函数:
#![allow(unused)] fn main() { fn index(idx: usize, arr: &[u8]) -> Option<u8> { if idx < arr.len() { unsafe { Some(*arr.get_unchecked(idx)) } } else { None } } }
这个函数是安全和正确的。我们先检查索引是否在界内,如果是,就以不检查的方式索引到数组中。我们说,这样一个正确实现的 unsafe 函数是健全的,这意味着安全代码不能通过它引起未定义行为(记住,这是安全 Rust 的唯一基本属性)。
但即使在这样一个微不足道的函数中,不安全的代码块也是值得怀疑的,比如将<改为<=:
#![allow(unused)] fn main() { fn index(idx: usize, arr: &[u8]) -> Option<u8> { if idx <= arr.len() { unsafe { Some(*arr.get_unchecked(idx)) } } else { None } } }
这个程序现在是不健全的,Safe Rust 会导致未定义行为,尽管我们只修改了安全代码。这就是安全的基本问题:它是并非只是局部的问题。我们的 unsafe 操作的健壮性必然取决于由其他 “safe” 操作建立的状态。
Safe 是模块化的,你不需要考虑任何其它的 Unsafe 块带来的潜在问题。例如,对一个切片使用一个未经检查的索引并不意味着你突然需要担心这个分片是空的或者包含未初始化的内存。没有任何根本性的变化。然而,Safe 又不是模块化的,因为程序本身是有状态的,你的 unsafe 操作可能依赖于任意状态。
当我们加入实际的持久化状态时,这种非局部性会变得更糟糕。例如,让我们看一下Vec的一个简单实现:
use std::ptr; // 注意:这个定义十分简单。参考实现 Vec 的章节 pub struct Vec<T> { ptr: *mut T, len: usize, cap: usize, } // 注意:这个实现未考虑大小为 `0` 的类型。参考实现 Vec 的章节 impl<T> Vec<T> { pub fn push(&mut self, elem: T) { if self.len == self.cap { // 这里并不重要 self.reallocate(); } unsafe { ptr::write(self.ptr.add(self.len), elem); self.len += 1; } } fn reallocate(&mut self) { } } fn main() {}
这段代码很简单,可以很简单地确认和验证,但是现在我们添加以下方法:
fn make_room(&mut self) {
// 增加容量
self.cap += 1;
}
这段代码是 100% 安全的 Rust,但它也是完全不健全的。改变容量违反了 Vec 的不变性(即cap反映了 Vec 中分配的空间)。这不是 Vec 的其他部分所能防范的。它不得不相信容量字段,因为没有办法验证它。
因为它依赖于一个结构字段的不变性,这段 “unsafe” 的代码不仅仅污染了整个函数:它污染了整个模块。一般来说,限制不安全代码的范围的唯一方法是在模块边界上设置权限。
然而,其实这个改动是可以完美地工作的。make_room的存在对于 Vec 的健全性来说不是个问题,因为我们没有把它标记为公共的。只有定义了这个函数的模块可以调用它。另外,make_room直接访问了 Vec 的私有字段,所以它只能写在与 Vec 相同的模块中。
因此,我们有可能基于复杂的不变性,编写一个完全安全的抽象。这对 Safe Rust 和 Unsafe Rust 之间的关系是非常重要的。
我们已经看到, unsafe 代码必须一部分信任 safe 代码,但不应该完全信任 safe 代码。出于类似的原因,访问控制对不安全代码也很重要:它可以防止我们不得不信任宇宙中所有的 safe 代码,防止它们扰乱我们的信任状态。
安全万岁!