Unsafe Rust 专题 Part 1 | 全面理解 Unsafe Rust
导言
众所周知, Rust 语言包含 Safe Rust 和 Unsafe Rust 两大部分。而 Unsafe Rust 是 Safe Rust 的超集。这意味着所有在 Safe Rust 中编写的代码也可以在 Unsafe Rust 中正常工作,但 Unsafe Rust 提供了额外的功能和操作,这些在 Safe Rust 中是无法直接使用的。
但正是因为有 Unsafe Rust 的存在,所以在社区最常被问到的一个问题是:为什么标准库中大量使用了 Unsafe Rust 还说 Rust 是安全语言呢?
并且在社区中有些人甚至对 Unsafe Rust 产生了 PTSD,逢 Unsafe 就说不安全。诚然,我们应该尽量少用 Unsafe ,但面对不得不用 Unsafe 的情况,我们应该知道如何安全地使用 Unsafe ,知道如何审查 Unsafe 代码,那么看见 Unsafe 就不会那么害怕和抵触了。
所以,本文的目标就是带读者系统地认识 Unsafe Rust ,真正地理解 Unsafe Rust 的妙用。顺便针对最近 Reddit 的热贴 Zig vs Unsafe Rust 谁更安全进行讨论。
本文目录:
为什么需要 Unsafe Rust Unsafe Rust 能做什么 Unsafe Rust 的安全哲学 安全抽象:维护安全不变量和有效性不变量 来自标准库的案例 来自 FFI 的示例 Unsafe Rust 编程准则 热点讨论:Unsafe Rust vs Zig Zig 语言简介 Zig 真的比 Unsafe Rust 更加安全? 小结
为什么需要 Unsafe Rust
总的来说,Safe Rust 是从 Unsafe Rust 抽象而来。这个世界本身就是 Unsafe 的,所以先有 Unsafe 后有 Safe。这是 Rust 语言的基本世界观。
这个世界观其实也非常符合客观世界,非常容易被人理解。宇宙是 Unsafe 的,所以我们探索宇宙需要 Safe 的飞船和宇航服;地球是 Unsafe 的,所以我们人类利用科技文明不断地创建出 Safe 的家园。
我们再从语言设计的角度来看,需要 Unsafe Rust 的理由如下:
为高级抽象提供了底层支持:Rust 提供了一些高级抽象,如引用计数、智能指针、同步原语等。这些抽象在底层需要使用 Unsafe 操作来实现,例如原始指针操作和内存管理。通过 Unsafe Rust,库开发者可以实现这些底层操作,并将其封装在安全的接口中。 高性能优化:为了确保内存安全,Rust 编译器会自动插入一些运行时检查,例如数组边界检查。在某些性能关键场景下,这些检查可能成为性能瓶颈。Unsafe Rust 允许开发者在确保安全性的前提下,绕过这些检查以获得更高的性能。 系统级编程:作为一种系统编程语言,Rust 需要能够处理底层操作,例如操作系统、嵌入式系统或驱动程序开发。Unsafe Rust 提供了这种灵活性,使得 Rust 可以胜任这些任务。 与其他语言的互操作:在现实项目中,Rust 代码可能需要与其他语言(如 C/C++)的代码进行交互。由于这些语言可能使用不同的内存管理和类型系统,Rust 需要提供一种方式来安全地与这些外部代码进行通信。Unsafe Rust 提供了这样一种机制,允许在 Rust 中处理原生指针和类型转换,从而实现与其他语言的互操作。 语言扩展性:Unsafe Rust 为未来的语言特性和库提供了可能性。通过允许底层操作,Unsafe Rust 可以使得 Rust 社区不断尝试新的想法,并将其整合到语言中。
综上所述,从 Rust 语言设计的角度来看,Unsafe Rust 是一种有意为之的设计折衷。它既满足了底层操作、性能优化和互操作等需求,又通过严格的限制和封装确保了整体的安全性。
Unsafe Rust 能做什么
前面提到过, Unsafe Rust 是 Safe Rust 的超集。所以,在 Unsafe Rust 中也包含了 Safe Rust 的所有编译器安全检查。然而,Unsafe Rust 也包含了 Safe Rust 中没有的操作,即,只能在 Unsafe Rust 中可以执行的操作:
原始指针操作:你可以创建、解引用和操作原始指针( *const T
和*mut T
)。这允许你直接访问内存地址,进行内存分配、释放和修改等操作。调用 Unsafe 函数:Unsafe Rust 可以调用被标记为 unsafe
的函数。这些函数可能会导致未定义行为,因此需要在unsafe
代码块中调用。这类函数通常用于实现底层操作,如内存管理、硬件访问等。实现 Unsafe trait:你可以实现被标记为 unsafe
的 trait。这类 trait 可能包含有潜在风险的操作,需要在实现时明确标注为unsafe
。访问和修改可变静态变量:在 Unsafe Rust 中,你可以访问和修改全局生命周期的可变静态变量。这些变量在整个程序运行期间都保持活动状态,可能导致潜在的数据竞争问题。 操作 Union
类型。由于多个字段共享相同的内存位置,union
的使用具有一定的风险。在访问union
的字段时,编译器无法保证类型安全,因为它无法确定当前存储的值属于哪个字段。为了确保安全地访问union
字段,你需要在unsafe
代码块中进行操作。关闭运行时边界检查:Unsafe Rust 允许你绕过数组边界检查。通过使用 get_unchecked
和get_unchecked_mut
方法,你可以在不进行边界检查的情况下访问数组和切片元素,从而提高性能。内联汇编:在 Unsafe Rust 中,你可以使用内联汇编( asm!
宏)直接编写处理器指令。这允许你实现一些特定于平台的优化和操作。外部函数接口(FFI):Unsafe Rust 允许你与其他编程语言(如 C/C++)编写的代码进行交互。这通常涉及原生指针操作、类型转换和调用 Unsafe 函数。
需要注意的是,使用 Unsafe Rust 时需要谨慎。在可能的情况下,应优先使用 Safe Rust 来编写代码。虽然它提供了强大的功能,但也可能导致未定义行为和内存安全问题。因此 Rust 官方在标准库的源码实现中,以及官方的 Unsafe Code Guideline[1] 中都包含了 Unsafe Rust 的安全哲学,以此来维护 Unsafe Rust 的安全性。
Unsafe Rust 的安全哲学
Unsafe Rust 的安全哲学是允许开发者在受限制的情况下执行底层操作和性能优化,同时确保整体代码仍然安全。
安全抽象:维护安全不变量和有效性不变量
什么叫整体代码仍然安全呢?Unsafe Rust 专门有个术语叫安全不变量(Safety Invariant)就是用于定义这一点。
Safe Rust 有编译器安全检查来保证内存安全和并发安全,但是对于 Unsafe Rust 的那些专门操作场景,Rust 编译器爱莫能助,所以需要开发者自己来保证代码的内存安全和并发安全。Unsafe Rust , 就是开发者对 Rust 编译器的安全承诺:“安全交给我来守护”!
开发者在编写 Unsafe Rust 代码时为了遵守这个安全承诺,就必须始终维护安全不变量。安全不变量是指在整个程序执行过程中必须始终维持的条件,以确保内存安全。这些条件通常包括指针有效性、数据结构完整性、数据访问同步等。安全不变量主要关注程序的正确执行和避免未定义行为。在使用 Unsafe Rust 时,开发者负责确保这些安全不变量得到满足,以避免出现内存安全问题。
在维护安全不变量的过程中,还有一个概念需要了解,那就是有效性不变量(Validity Invariant)。有效性不变量是指某些数据类型和结构在其生命周期内必须满足的条件。有效性不变量主要关注数据类型和结构的正确性。例如,对于引用类型,有效性不变量包括指针非空、指向的内存有效等。在编写和使用 Unsafe Rust 代码时,开发者需要确保这些有效性不变量得到维护。
安全不变量和有效性不变量之间存在一定程度的关联,因为它们都关注代码的正确性和安全性。维护有效性不变量往往有助于确保安全不变量得到满足。例如,确保引用类型的指针有效性(有效性不变量)可以避免空指针解引用(安全不变量)。尽管它们之间存在关联,但安全不变量和有效性不变量关注的领域是不同的。安全不变量主要关注整个程序的内存安全和避免未定义行为,而有效性不变量主要关注特定数据类型和结构的正确性。
它们之间的关系可以从以下几个方面总结:
目的:安全不变量主要关注内存安全和数据完整性,以防止未定义行为。有效性不变量则关注类型实例在其生命周期内满足的一组条件,以便正确使用。 范围:安全不变量通常涉及整个程序或模块的内存安全,而有效性不变量特定于类型的约束。在某种程度上,安全不变量可以被视为全局性的限制,而有效性不变量则是局部性的限制。 层次:安全不变量和有效性不变量可以在不同的层次上交互。通常,维护有效性不变量是实现安全不变量的基础。换句话说,类型的有效性不变量往往是实现更高层次的安全不变量所必需的。 依赖关系:安全不变量依赖于有效性不变量。当类型的有效性不变量得到满足时,这有助于确保实现安全不变量所需的条件。例如,在 Rust 中,安全的引用访问依赖于底层类型的有效性不变量得到满足。
所以,当 Unsafe Rust 代码出现了违反了安全不变量或有效性不变量的情况时,我们就说代码是不健全(Unsound)的。Unsound 的代码可能导致未定义行为、内存泄漏、数据竞争等问题。Rust 语言试图避免 Unsound 的情况,通过编译器和类型系统来确保大部分代码的内存安全。尤其是要避免未定义行为。
未定义行为(Undefined Behavior)是指程序的执行结果无法预测的情况。这可能是由于内存访问越界、空指针解引用、数据竞争等错误导致的。在遇到未定义行为时,程序可能会崩溃、产生错误结果或表现出其他意外行为。
“但是也有人认为,在 Zig 和 Rust 中使用 “未定义行为”这个术语可能不太精准[2]。
在 Rust 中,要特别注意一些可能导致未定义行为的情况:
数据竞争:当多个线程同时访问相同的内存位置,且至少有一个线程在执行写操作时,就会发生数据竞争。Rust 的所有权系统和借用检查器在编译时防止了大部分数据竞争,但在 Unsafe Rust 中,开发者需要格外小心避免数据竞争。 无效指针解引用:解引用一个无效的指针(如空指针、已释放内存的指针)会导致未定义行为。 整数溢出:在某些情况下,整数溢出(如整数加法、减法、乘法等)可能导致未定义行为。Rust 在 debug 模式下默认启用整数溢出检查,但在 release 模式下,整数溢出会被视为未定义行为。 未初始化内存访问:访问未初始化的内存会导致未定义行为。这包括读取或写入未初始化的内存,或将未初始化的内存传递给外部函数。 错误的类型转换:将一个类型的指针强制转换为另一个类型的指针,然后解引用,可能导致未定义行为。这通常发生在 Unsafe Rust 代码中,需要特别注意确保类型转换是安全的。
总之,在使用 Unsafe Rust 时,开发者需要特别注意维护安全不变量和有效性不变量,以避免出现 Unsound 和未定义行为。我们把那些通过严格遵循这些原则的 Unsafe Rust 称为经过Unsafe 安全抽象的代码,它们可以被认为是 Safe 的,就可以在保持整体安全性的同时提供底层操作和性能优化。