🦀 Hello fellow Rustacean! 🦀
开发程序时不用担心底层实现细节就好了。谁会在意空元组占用了多少空间呢?可悲的是,有时这很重要,我们需要关注它。开发者开始关注实现细节最常见的原因是性能,但更重要的是,当直接与硬件、操作系统或其他语言交互时,这些细节可能会成为正确性的问题。
当实现细节在一个安全的编程语言中开始变得重要时,程序员通常有三种选择:
- 修改代码以鼓励编译器/运行时执行优化
- 采用一种更不符合习惯或繁琐的设计来实现所需的实现
- 用一种允许你处理这些细节的语言重写实现
对于最后一个选择,程序员倾向于使用的语言是C。这通常是与仅声明C接口的系统进行交互所必需的。
不幸的是,C的使用非常不安全(有时是有充分理由的),并且当尝试与另一种语言互操作时,这种不安全性会加剧。必须小心确保C和其他语言在发生的事情上达成一致,并且它们不会互相妨碍。
那么这与Rust有什么关系呢?
嗯,与C不同,Rust是一种安全的编程语言。
但是,与C一样,Rust是一种不安全的编程语言。
更准确地说,Rust包含安全和不安全两种编程语言。
Rust可以被认为是两种编程语言的组合:安全Rust和不安全Rust。方便的是,这些名称的意思与它们所说的完全相同:安全Rust是安全的。不安全Rust,嗯,不是。事实上,不安全Rust允许我们做一些非常不安全的事情。Rust的作者会恳求你不要做的事情,但我们还是会做。
安全Rust是真正的Rust编程语言。如果你所做的只是编写安全Rust,你将永远不必担心类型安全或内存安全。你将永远不会遇到悬空指针、释放后使用或任何其他类型的未定义行为(又名UB)。
标准库还为你提供了足够的开箱即用的实用程序,你将能够在纯粹的、符合语言习惯的安全Rust中编写高性能的应用程序和库。
但也许你想与其他语言交流。也许你正在编写一个标准库未公开的底层抽象。也许你正在编写标准库(完全用Rust编写)。也许你需要做一些类型系统不理解的事情,只是摆弄一些该死的位。也许你需要不安全Rust。
不安全Rust与安全Rust完全相同,具有所有相同的规则和语义。它只是允许你做一些绝对不安全的事情(我们将在下一节中定义)。
这种分离的价值在于,我们获得了使用像C这样的不安全语言的好处——对实现细节的低级控制——而没有试图将其与完全不同的安全语言集成所带来的大部分问题。
仍然存在一些问题——最值得注意的是,我们必须意识到类型系统所假设的属性,并在任何与不安全Rust交互的代码中审计它们。这就是本书的目的:教你了解这些假设以及如何管理它们。
1 Rust中Safe和Unsafe交互
安全 Rust 和不安全 Rust 之间有什么关系?它们如何互动?
安全 Rust 和不安全 Rust 之间的分离由 unsafe 关键字控制,该关键字充当两者之间的接口。这就是为什么我们可以说安全 Rust 是一种安全的语言:所有不安全的部分都完全保留在 unsafe 边界之后。如果你愿意,你甚至可以在你的代码库中添加 #![forbid(unsafe_code)] 来静态地保证你只编写安全 Rust。
unsafe 关键字有两种用途:声明编译器无法检查的契约的存在,以及声明程序员已经检查过这些契约已被遵守。
你可以使用 unsafe 来指示函数和 trait 声明上未检查契约的存在。在函数上,unsafe 意味着函数的使用者必须检查该函数的文档,以确保他们以一种维护该函数所需契约的方式使用它。在 trait 声明上,unsafe 意味着 trait 的实现者必须检查 trait 文档,以确保他们的实现维护该 trait 所需的契约。
你可以在一个块上使用 unsafe 来声明在该块内执行的所有不安全操作都经过验证,以遵守这些操作的契约。例如,传递给 slice::get_unchecked 的索引是有效的。
你可以在 trait 实现上使用 unsafe 来声明该实现遵守该 trait 的契约。例如,实现 Send 的类型实际上可以安全地移动到另一个线程。
标准库有许多不安全的函数,包括:
- slice::get_unchecked,它执行未检查的索引,允许随意违反内存安全。
- mem::transmute 将某个值重新解释为具有给定的类型,以任意方式绕过类型安全(有关详细信息,请参见转换)。
- 每个指向固定大小类型的原始指针都有一个 offset 方法,如果传递的偏移量不在“范围内”,则会调用未定义的行为。
- 所有 FFI(外部函数接口)函数都是不安全的,因为另一种语言可以执行 Rust 编译器无法检查的任意操作。
从 Rust 1.29.2 开始,标准库定义了以下不安全 trait(还有其他的,但它们尚未稳定,其中一些可能永远不会稳定):
- Send 是一个标记 trait(一个没有 API 的 trait),它承诺实现者可以安全地发送(移动)到另一个线程。
- Sync 是一个标记 trait,它承诺线程可以通过共享引用安全地共享实现者。
- GlobalAlloc 允许自定义整个程序的内存分配器。
Rust 标准库的许多部分也在内部使用不安全 Rust。这些实现通常经过严格的手动检查,因此可以认为构建在这些实现之上的安全 Rust 接口是安全的。
所有这些分离的需要归结为安全 Rust 的一个基本属性,即健全性属性:
无论如何,安全 Rust 不可能导致未定义行为。
安全/不安全 Rust 的设计意味着安全 Rust 和不安全 Rust 之间存在不对称的信任关系。安全 Rust 本质上必须信任它接触的任何不安全 Rust 代码都是正确编写的。另一方面,不安全 Rust 不能不加小心地信任安全 Rust 代码。
例如,Rust 具有 PartialOrd 和 Ord trait,用于区分可以“仅仅”比较的类型和提供“完全”排序的类型(这基本上意味着比较行为是合理的)。
BTreeMap 对于部分排序的类型来说并没有真正的意义,因此它要求它的键实现 Ord。但是,BTreeMap 的实现内部有不安全 Rust 代码。因为草率的 Ord 实现(可以安全地编写)导致未定义行为是不可接受的,所以 BTreeMap 中的不安全代码必须编写得足够健壮,以防止实际上不是完全排序的 Ord 实现——即使这是要求 Ord 的全部意义。
不安全 Rust 代码不能信任安全 Rust 代码被正确编写。也就是说,如果您输入没有完全排序的值,BTreeMap 仍然会表现得完全不稳定。它只是永远不会导致未定义行为。
有人可能会想,如果 BTreeMap 因为 Ord 是安全的而不能信任它,那么它为什么可以信任任何安全代码?例如,BTreeMap 依赖于整数和切片来正确实现。这些也是安全的,对吧?
区别在于范围。当 BTreeMap 依赖于整数和切片时,它依赖于一个非常特定的实现。这是一个可以权衡利弊的衡量风险。在这种情况下,基本上没有风险;如果整数和切片坏了,每个人都会坏。此外,它们由维护 BTreeMap 的同一批人维护,因此很容易掌握它们的情况。
另一方面,BTreeMap 的键类型是泛型的。信任它的 Ord 实现意味着信任过去、现在和未来的每一个 Ord 实现。这里的风险很高:总有人会犯错并搞砸他们的 Ord 实现,甚至只是直接谎称提供了完全排序,因为“它似乎有效”。当这种情况发生时,BTreeMap 需要做好准备。
同样的逻辑适用于信任传递给你的闭包以使其行为正确。
这种无限泛型信任的问题正是 unsafe traits 存在以解决的问题。从理论上讲,BTreeMap 类型可以要求键实现一个名为 UnsafeOrd 的新 trait,而不是 Ord,它可能如下所示:
use std::cmp::Ordering;
unsafe trait UnsafeOrd {
fn cmp(&self, other: &Self) -> Ordering;
}
然后,某个类型会使用 unsafe 来实现 UnsafeOrd,表明它们已确保其实现保持了 trait 所期望的任何约定。在这种情况下,BTreeMap 内部的 Unsafe Rust 可以合理地信任键类型的 UnsafeOrd 实现是正确的。如果不是,那就是 unsafe trait 实现的错误,这与 Rust 的安全保证是一致的。
是否将 trait 标记为 unsafe 是一个 API 设计选择。安全的 trait 更容易实现,但任何依赖它的 unsafe 代码都必须防范不正确的行为。将 trait 标记为 unsafe 会将此责任转移给实现者。Rust 传统上避免将 trait 标记为 unsafe,因为它会使 Unsafe Rust 变得普遍,这是不可取的。
Send 和 Sync 被标记为 unsafe,因为线程安全是一个基本属性,unsafe 代码不可能像防范有 bug 的 Ord 实现那样来防范它。类似地,GlobalAllocator 负责记录程序中的所有内存,而 Box 或 Vec 等其他东西都构建在它之上。如果它做了一些奇怪的事情(在仍在使用的内存块上将其提供给另一个请求),则无法检测到并采取任何措施。
是否将您自己的 trait 标记为 unsafe 取决于同样的考虑。如果 unsafe 代码无法合理地期望防范 trait 的错误实现,那么将 trait 标记为 unsafe 是一个合理的选择。
顺便说一句,虽然 Send 和 Sync 是 unsafe trait,但当可以证明这样的派生是安全的时,它们也会自动为类型实现。Send 自动为所有仅由其类型也实现 Send 的值组成的类型派生。Sync 自动为所有仅由其类型也实现 Sync 的值组成的类型派生。这最大限度地减少了使这两个 trait 不安全所带来的普遍不安全性。而且没有多少人会实现内存分配器(或者直接使用它们)。
这就是 Safe Rust 和 Unsafe Rust 之间的平衡。这种分离旨在使使用 Safe Rust 尽可能符合人体工程学,但在编写 Unsafe Rust 时需要额外的努力和小心。本书的其余部分主要讨论必须采取的这种小心,以及 Unsafe Rust 必须遵守的约定。
2 Unsafe Rust 可以做什么?
不安全 Rust 中唯一不同的是,你可以:
- 解引用原始指针
- 调用不安全函数(包括 C 函数、编译器内联函数和原始分配器)
- 实现不安全 trait
- 访问或修改可变静态变量
- 访问联合体的字段 仅此而已。这些操作被归入不安全代码的原因是,滥用其中任何一项都会导致可怕的未定义行为。调用未定义行为会赋予编译器充分的权利,可以对你的程序做任意糟糕的事情。你绝对不应该调用未定义行为。
与 C 不同,Rust 中未定义行为的范围非常有限。所有核心语言关心的都是防止以下事情:
- 解引用(对...使用 * 运算符)悬空或未对齐的指针(见下文)
- 打破指针别名规则
- 使用错误的调用 ABI 调用函数,或者使用错误的 unwinding ABI 从函数 unwinding。
- 导致数据竞争
- 执行使用当前执行线程不支持的目标特性编译的代码
- 产生无效值(单独或作为复合类型(如 enum/struct/array/tuple)的字段):
- 不是 0 或 1 的 bool
- 具有无效判别式的枚举
- 空函数指针
- 范围 [0x0, 0xD7FF] 和 [0xE000, 0x10FFFF] 之外的 char
- ! (此类型的所有值均无效)
- 从未初始化内存读取的整数 (i*/u*)、浮点值 (f*) 或原始指针,或 str 中的未初始化内存。
- 悬空、未对齐或指向无效值的引用/Box。
- 具有无效元数据的宽引用、Box 或原始指针:
- dyn Trait 元数据无效,如果它不是指向 Trait 的 vtable 的指针,该 vtable 与指针或引用指向的实际动态 trait 匹配
- 如果长度不是有效的 usize(即,它不得从未经初始化的内存中读取),则切片元数据无效
- 具有自定义无效值的类型是这些值之一,例如为空的 NonNull。(请求自定义无效值是一个不稳定的功能,但一些稳定的 libstd 类型(如 NonNull)会使用它。)
有关“未定义行为”的更详细说明,你可以参考相关文档。
每当值被赋值、传递给函数/原始操作或从函数/原始操作返回时,就会发生“产生”值。
如果引用/指针为空,或者它指向的所有字节都不属于同一分配(因此特别地,它们都必须是某个分配的一部分),则该引用/指针是“悬空的”。它指向的字节跨度由指针值和被指向类型的大小决定。因此,如果跨度为空,“悬空”与“空”相同。请注意,切片和字符串指向它们的整个范围,因此重要的是长度元数据永远不要太大(特别是,分配,因此切片和字符串不能大于 isize::MAX 字节)。如果由于某种原因这太麻烦,请考虑使用原始指针。
仅此而已。这就是 Rust 中所有导致未定义行为的原因。当然,不安全函数和 trait 可以自由声明程序必须保持的任意其他约束,以避免未定义行为。例如,分配器 API 声明释放未分配的内存是未定义行为。
但是,违反这些约束通常只会传递性地导致上述问题之一。一些附加约束也可能来自编译器内联函数,这些内联函数对代码如何优化做出特殊假设。例如,Vec 和 Box 使用需要它们的指针始终为非空的内联函数。
否则,Rust 在其他可疑操作方面非常宽松。Rust 认为以下操作是“安全”的:
- 死锁
- 发生竞争条件
- 内存泄漏
- 整数溢出(使用内置运算符,例如 + 等)
- 中止程序
- 删除生产数据库
有关更多详细信息,你可以参考相关文档。
但是,任何实际设法做到这一点的程序都可能是不正确的。Rust 提供了许多工具来使这些事情很少发生,但这些问题被认为在绝对意义上是无法预防的。
3 处理不安全代码
通常,Rust 只提供给我们以范围化和二元的方式讨论不安全 Rust 的工具。不幸的是,现实情况远比这复杂。例如,考虑以下玩具函数:
fn index(idx: usize, arr: &[u8]) -> Option<u8> {
if idx < arr.len() {
unsafe {
Some(*arr.get_unchecked(idx))
}
} else {
None
}
}
这个函数是安全且正确的。我们检查索引是否在界内,如果是,则以非检查的方式索引到数组中。我们说这样一个正确地使用不安全代码实现的函数是健全的,这意味着安全代码不会通过它导致未定义行为(请记住,这是安全 Rust 的唯一基本属性)。
但是,即使在这样一个简单的函数中,不安全块的范围也值得怀疑。考虑将 < 更改为 <=:
fn index(idx: usize, arr: &[u8]) -> Option<u8> {
if idx <= arr.len() {
unsafe {
Some(*arr.get_unchecked(idx))
}
} else {
None
}
}
这个程序现在是不健全的,安全 Rust 可能会导致未定义行为,但我们只修改了安全代码。这是安全性的根本问题:它是非局部的。我们的不安全操作的健全性必然取决于由其他 "安全" 操作建立的状态。
安全性是模块化的,因为选择不安全并不需要您考虑任意其他类型的错误。例如,对切片进行非检查索引并不意味着您突然需要担心切片为空或包含未初始化的内存。没有任何根本性的改变。但是,安全性不是模块化的,因为程序本质上是有状态的,并且您的不安全操作可能依赖于任意其他状态。
当我们结合实际的持久状态时,这种非局部性会变得更糟。考虑 Vec 的一个简单实现:
use std::ptr;
pub struct Vec<T> {
ptr: *mut T,
len: usize,
cap: usize,
}
// 注意,此实现无法正确处理零大小类型。
impl<T> Vec<T> {
pub fn push(&mut self, elem: T) {
if self.len == self.cap {
// not important for this example
self.reallocate();
}
unsafe {
ptr::write(self.ptr.add(self.len), elem);
self.len += 1;
}
}
}
这段代码足够简单,可以合理地进行审计和非正式验证。现在考虑添加以下方法:
fn make_room(&mut self) {
self.cap += 1;
}
这段代码是 100% 安全的 Rust,但它也是完全不健全的。更改容量违反了 Vec 的不变性(即 cap 反映了 Vec 中分配的空间)。这不是 Vec 的其余部分可以防范的。它必须信任容量字段,因为没有办法验证它。
因为它依赖于结构字段的不变性,所以这段不安全的代码不仅仅是污染了整个函数:它污染了整个模块。通常,限制不安全代码范围的唯一万无一失的方法是在模块边界上使用隐私。
但是,这工作得很好。make_room 的存在对于 Vec 的健全性来说不是问题,因为我们没有将其标记为 public。只有定义此函数的模块才能调用它。此外,make_room 直接访问 Vec 的私有字段,因此只能在与 Vec 相同的模块中编写。
因此,我们有可能编写一个完全安全的抽象,该抽象依赖于复杂的不变性。这对于安全 Rust 和不安全 Rust 之间的关系至关重要。
我们已经看到,不安全代码必须信任一些安全代码,但不应信任通用的安全代码。隐私对于不安全代码来说很重要,原因类似:它可以防止我们不得不信任宇宙中所有的安全代码来弄乱我们信任的状态。
安全万岁!