第九章 不安全代码
仅仅提到不安全代码,就会引起 Rust 社区中许多人的强烈反应,也会引起许多从旁观者的反应。有些人坚持认为这 "没什么大不了的",有些人则谴责它是 "Rust 的所有承诺都是谎言的原因"。在这一章中,我希望拉开帷幕,解释什么是不安全,什么不是,以及你应该如何安全使用它。在写这篇文章的时候,也可能是你读到这篇文章的时候,Rust 对不安全代码的精确要求仍在确定之中,即使它们都被钉死了,完整的描述也超出了本书的范围。取而代之的是,我将尽力用你需要的构件、直觉和工具来武装你,让你在大多数不安全代码中游刃有余。
本章的主要收获是:不安全代码是 Rust 为开发者提供的机制,用于利用编译器无法检查的不变量。 我们将研究不安全代码的方式,这些不变量可能是什么,以及我们能用它做什么。
不变性
在这一章中,我将会经常谈论不变式。不变量只是一种花哨的说法,即 "为了你的程序正确,某些东西必须是真的"。例如,在 Rust 中,一个不变式是指使用
&和&mut的引用不会悬空--它们总是指向有效值。你也可以有特定于应用或库的不变式,比如 "头部指针总是在尾部指针之前 "或 "容量总是二的幂"。 最终,不变式代表了你的代码正确所需的所有假设。 然而,你可能并不总是意识到你的代码使用的所有不变式,而这正是错误悄悄出现的地方。
最重要的是,不安全的代码不是规避 Rust 的各种规则的方法,比如借用检查,而是使用编译器以外的推理来执行这些规则的方法。当你写不安全的代码时,你有责任确保产生的代码是安全的。在某种程度上,当 unsafe 被用来允许通过 unsafe {} 进行不安全的操作时,它作为一个关键词是有误导性的;这并不是说包含的代码是不安全的,而是说代码被允许执行其他不安全的操作,因为在这个特定的环境下,这些操作是安全的。
本章的其余部分分为四个部分。我们将首先简要检查关键字本身是如何使用的,然后探讨 unsafe 允许你做什么。接下来,我们将看看为了编写安全的不安全代码而必须遵循的规则。最后,我将给你一些建议,告诉你如何真正安全地编写不安全代码。
unsafe 关键字
在我们讨论 unsafe 赋予你的权力之前,我们需要谈谈它的两种不同的含义。unsafe 关键字在 Rust 中具有双重目的:它将一个特定的函数标记为不安全的调用,并使你能够在一个特定的代码块中调用不安全的功能。例如,清单 9-1 中的方法被标记为不安全,尽管它没有包含不安全的代码。在这里,unsafe 关键字是对调用者的一个警告,即有额外的保证,写调用 decr 的代码的人必须手动检查。
#![allow(unused)] fn main() { impl<T> SomeType<T> { pub unsafe fn decr(&self) { self.some_usize -= 1; } } // 图 9-1:一个只包含安全代码的不安全方法 }
清单 9-2 说明了第二种用法。在这里,方法本身没有被标记为不安全,尽管它包含不安全的代码
#![allow(unused)] fn main() { impl<T> SomeType<T> { pub fn as_ref(&self) -> &T { unsafe { &*self.ptr } } } // 清单 9-2:一个包含不安全代码的安全方法 }
这两个列表在使用 unsafe 方面有所不同,因为它们体现了不同的契约。decr 要求调用者在调用该方法时要小心,而 as_ref 则假定调用者在调用其他不安全的方法(如 decr)时是小心的。要知道为什么,想象一下 SomeType 实际上是一个像 Rc 一样的引用计数的类型。尽管 decr 只是对一个数字进行递减,但这种递减可能反过来通过安全方法 as_ref 触发未定义的行为。如果你调用 decr,然后丢弃一个给定的 T 的倒数第二个 Rc,引用计数就会下降到零,T 就会被丢弃,但是程序仍然可能在最后一个 Rc 上调用 as_ref,最后会出现一个悬空的引用。
注意:未定义行为描述的是程序在运行时违反语言的不变性的后果。一般来说,如果一个程序触发了未定义行为,其结果是完全不可预测的。我们将在本章后面更详细地介绍未定义行为。
相反,只要没有办法用安全的代码破坏 Rc 的引用计数,那么像 as_ref 的代码那样在 Rc 内部解除对指针的引用总是安全的--&self 存在的事实证明了这个指针一定仍然有效。我们可以利用这一点为调用者提供一个安全的 API 来进行其他不安全的操作,这也是如何负责任地使用 unsafe 操作的一个核心部分。
由于历史原因,在今天的 Rust 中,每个 unsafe fn 都包含一个隐含的不安全块。也就是说,如果你声明了一个 unsafe fn,你总是可以在这个 fn 中调用任何不安全的方法或原始操作。 然而,这个决定现在被认为是一个错误,它目前正在通过已经接受和实施的 RFC 2585 来恢复。这个 RFC 警告说,有一个 unsafe fn 执行不安全的操作,而其内部没有明确的不安全块。在未来的 Rust 版本中,这个提示也可能会成为一个硬性错误。这个想法是为了减少 "脚炮半径(footgun radius)"--如果每个 unsafe fn 都是一个巨大的不安全块,那么你可能会意外地执行不安全操作而不自知。例如,在清单 9-1 中的 decr 中,根据目前的规则,你也可以添加 *std::ptr::null() 而不需要任何 unsafe 注释。
区分作为标记的 unsafe 和作为实现不安全操作的机制的不安全块是很重要的,因为你必须以不同的方式来考虑它们。一个unsafe fn 向调用者表明,他们在调用有关的 fn 时必须小心,必须确保该函数所记录的安全不变性成立。
同时,一个不安全的区块意味着写这个区块的人仔细检查了它里面执行的任何不安全操作的安全不变性是否成立。如果你想要一个近似的现实世界的类比,unsafe fn 是一个未签署的合同,要求调用代码的作者 "庄严地发誓 X、Y 和 Z。"同时,unsafe {} 是调用代码的作者在块中包含的所有不安全契约上签字。在我们学习本章的其余部分时,请牢记这一点。
伟大的力量(Great Power)
那么,一旦你与 unsafe {} 签署了不安全的合同,你可以做什么?说实话,没有那么多。或者说,它并没有实现那么多的新功能。在一个不安全块中,你可以解引用原始指针,并调用 unsafe fnS.
就这样了。从技术上讲,还有一些其他的事情你可以做,比如访问可变和外部静态变量以及访问联合体的字段,但这些并没有给讨论带来多大变化。老实说,这就够了。这些能力加在一起,让你可以进行各种破坏,比如用 mem::transmute 把类型变成另一个,解引用指向谁也不知道的原始指针,把 &'a 投给 &'static,或者让类型可以跨越线程边界共享,尽管它们不是线程安全的。
在本节中,我们不会过多地担心这些权力会出什么问题。我们会把这个问题留给后面那个无聊的、负责任的、成熟的部分。相反,我们将看看这些整齐闪亮的新玩具,以及我们能用它们做什么。
杂耍式的原始指针(Juggling Raw Pointers)
使用 unsafe 的最基本原因之一是为了处理 Rust 的原始指针类型:*const T 和 *mut T。你应该认为这些类型或多或少类似于 &T 和 &mut T,只是它们没有生存期,而且不受制于与 & 对应类型相同的有效性规则,我们将在本章后面讨论。这些类型可以互换地称为指针和原始指针,主要是因为许多开发者本能地将引用称为指针,而将它们称为原始指针可以使两者的区别更加清晰。
由于适用于 * 的规则比适用于 & 的规则要少,所以即使在不安全块之外,你也可以将一个引用投给一个指针。只有当你想反其道而行之,从 * 到 &,你才需要 unsafe。你通常会把指针转回引用,以便对指向的数据做一些有用的事情,比如读取或修改其值。出于这个原因,对指针的一个常用操作是 unsafe { &*ptr } (或 &mut *)。这里的 * 可能看起来很奇怪,因为代码只是在构造一个引用,而不是解指针的引用,但是如果你看一下类型的话,它是有意义的;如果你有一个 *mut T,想要一个 &mut T,那么 &mut ptr 就会给你一个 &mut *mut T。你需要 * 来表示你想要 ptr 是一个指针的可变引用。
指针类型
你可能想知道
*mut T和*const T以及std::ptr::NonNull<T>之间有什么区别。好吧,确切的规范仍在制定中,但*mut T和*const T/NonNull<T>之间的主要实际区别是,*mut T在T中是不变的(参见第 1 章中的 "生存期型变"),而其他两个是协变的。 正如名字所暗示的,*const T和NonNull<T>的区别主要在于NonNull<T>不允许是一个空指针,而*const T是。在选择这些类型时,我最好的建议是利用你的直觉,如果你能说出相关的生命周期,你会写
&mut还是&。如果你会写&,而且你知道指针永远不会是空的,那就使用NonNull<T>。它得益于一个很酷的优化,叫做利基优化:基本上,由于编译器知道这个类型永远不可能是空的,它可以使用这个信息来表示像Option<NonNull<T>>这样的类型,而没有任何额外的开销,因为None的情况可以通过设置NonNull为空指针来表示!空指针的值是NonNull<T>类型中的一个利基。如果指针可能是空的,就用*const T。如果你本来要写&mut T,就用*mut T。
无法代表的生存期(Unrepresentable Lifetimes)
由于原始指针没有生存期,它们可以用于被指向的值的有效性无法在 Rust 的生存期系统中静态表达的情况下,比如像我们在第 8 章讨论过的生成器那样的自引用结构中的自指针。一个指向 self 的指针在 self 存在的时间内是有效的(并且不会移动,这就是 Pin 的作用),但这并不是一个你通常可以命名的生存期。虽然整个自引用类型可能是 'static,但自指(self-pointer)却不是--如果它是静态的,那么即使你把这个指针给了别人,他们也可以永远使用它,甚至在 self 消失后也是如此以清单 9-3 中的类型为例;在这里,我们试图将构成一个值的原始字节与它的存储表示一起存储。
#![allow(unused)] fn main() { struct Person<'a> { name: &'a str, age: usize, } struct Parsed { bytes: [u8; 1024], parsed: Person<'???>, } // 清单 9-3:尝试,但失败了,命名一个自指的生存期。 }
Person 里面的引用想要引用存储在 Parsed 的字节中的数据,但是我们没有办法从 Parsed 那里给这个引用分配生存期。它不是 'static 或类似 'self 的东西(它不存在),因为如果 Parsed 被移动,这个引用就不再有效。
由于指针没有生存期,它们规避了这个问题,因为你不需要能够命名生存期。 相反,你只需要确保当你使用指针时,它仍然有效,这就是你在写 unsafe { &*ptr } 时签署的东西。在清单 9-3 的例子中,Person 会存储一个 *const str,然后在适当的时候不安全地把它变成一个 &str,这时它可以保证指针仍然有效。
类似的问题也出现在像 Arc 这样的类型上,它有一个指向某个共享值的指针,但这个期限只有在运行时才知道,即最后一个 Arc 被丢弃时。指针是某种程度上的 'static,但并不是真正的--在自引用(self-referential)的情况下,当最后一个 Arc 引用消失时,指针就不再有效了,所以生存期更像是 'self。在 Arc 的表亲 Weak 中,生存期也是 "当最后一个 Arc 消失时",但由于 Weak 不是 Arc,生存期甚至不与 self 相关。所以,Arc 和 Weak 都在内部使用原始指针。
指针运算(Pointer Arithmetic)
使用原始指针,你可以做任意的指针运算,就像在 C 语言中一样,通过使用.offset()、.add() 和.sub() 将指针移动到任何生活在同一分配区内的字节。这最常用于高度空间优化的数据结构,如哈希表,其中为每个元素存储一个额外的指针会增加太多开销,而使用分片是不可能的。这些都是相当小众的用例,我们不会在本书中讨论更多,但如果你想了解更多,我鼓励你阅读 hashbrown::RawTable 的代码(https://github.com/rust-lang/hashbrown/)!
即使你不想在事后把指针变成引用,调用指针算术方法也是不安全的。这有几个原因,但最主要的原因是,使指针指向超出它最初指向的分配的末端是非法的。这样做会触发未定义的行为,并且允许编译器决定吃掉你的代码,用只有编译器才能理解的任意的废话来取代它。如果你真的使用这些方法,请仔细阅读文档
再回到指针来
通常当你需要使用指针时,是因为你有一些正常的 Rust 类型,如引用、切片或字符串,而你必须转移到指针的世界中去一下,然后再回到原来的正常类型。因此,一些关键的标准库类型为你提供了一种方法,可以把它们变成它们的原始组成部分,比如一个指针和一个片断的长度,以及一种使用这些相同部分把它们变回整体的方法。例如,你可以用 as_ptr 得到一个片断的数据指针,用 []:len 得到它的长度。然后你可以通过向 std::slice::from_raw_parts 提供这些相同的值来重构片断。Vec、Arc 和 String 都有类似的方法来返回底层分配的原始指针,Box 有 Box::into_raw 和 Box::from_raw,它们做同样的事情。
玩弄类型(Playing Fast and Loose with Types)
有时,你有一个类型 T,并想把它当作其他类型 U。无论是因为你需要做闪电般的零拷贝解析,还是因为你需要摆弄一些生存期,Rust 都为你提供了一些(非常不安全的)工具来做到这一点。
其中第一个也是迄今为止使用最广泛的是指针投射:你可以将一个 *const T 投射到任何其他的 *const U(对于 mut 也是如此),你甚至不需要 unsafe 来做这件事。只有当你后来试图将投出的指针作为引用使用时,不安全才会发生作用,因为你必须断言原始指针实际上可以作为它所指向的类型的引用使用。
这种指针类型的转换在使用外来函数接口(FFI)时特别方便--你可以将任何 Rust 指针转换为 *const std::fi::c_void 或 *mut std::fi::c_void,然后将其传递给一个期望有 void 指针的 C 函数。同样地,如果你从 C 语言中得到一个之前传入的 void 指针,你可以很简单地将它铸回它的原始类型。
当你想把一串字节解释成普通的数据类型,如整数、布尔运算、字符和数组,或#[repr(C)] 这些结构时,指针转换也很有用,或者直接把这些类型写成字节流,而不进行序列化。如果你想尝试这样做,有很多安全不变因素需要记住,但我们将把这些留到以后再说。
调用不安全函数
可以说,不安全的最常用功能是它能让你调用不安全的函数。在栈的深处,大多数函数都是不安全的,因为它们在某些基本层面上对原始指针进行操作,但在栈的上方,你往往主要通过函数调用与不安全进行交互。
调用不安全函数可能实现的功能其实没有什么限制,因为这完全取决于你所交互的库。但一般来说,不安全的函数可以分为三个阵营:与非 Rust 接口交互的函数,跳过安全检查的函数,以及有自定义不变式的函数。
外部函数接口(FFI)
Rust 允许你使用 extern 块来声明在 Rust 以外的语言中定义的函数和静态变量(我们将在第 11 章详细讨论)。当你声明这样的块时,你要告诉 Rust,当最终的程序二进制文件被链接时,其中出现的项目将由某个外部源实现,比如你正在集成的 C 库。由于 externS 源存在于 Rust 的控制之外,它们的访问本质上是不安全的。如果你从 Rust 中调用一个 C 函数,所有的赌注都会被取消,它可能会覆盖你的整个内存内容,并将你所有整齐排列的引用变成随机的指针进入内核的某个地方。同样地,一个 extern 静态变量可以在任何时候被外部代码修改,并可能被填入各种坏的字节,完全不能反映其声明的类型。不过在一个不安全块中,你可以随心所欲地访问 externS 变量,只要你愿意保证外部变量的另一端按照 Rust 的规则行事。
我将通过安全检查(I’ll Pass on Safety Checks)
一些不安全的操作可以通过引入额外的运行时检查而变得完全安全。例如,访问一个分片中的项目是不安全的,因为你可能试图访问一个超过分片长度的项目。但是,考虑到这种操作的普遍性,如果对一个片断进行索引是不安全的,那就太不幸了。相反,安全的实现包括边界检查(取决于你使用的方法),如果你提供的索引超出了边界,它就会出现恐慌或返回一个 Option。这样一来,即使你传入的索引超过了分片的长度,也没有办法导致未定义的行为。另一个例子是在散列表中,它对你提供的键进行散列,而不是让你自己提供散列;这确保了你永远不会尝试使用错误的散列来访问一个键。
然而,在对最终性能的无尽追求中,一些开发者可能会发现这些安全检查在他们最紧密的循环中增加了一点过多的开销。为了迎合那些对性能要求很高的情况,而且调用者知道索引是在界内的,许多数据结构提供了没有这些安全检查的特定方法的替代版本。这样的方法通常在名称中包括 unchecked 这个词,以表明他们盲目地相信所提供的参数是安全的,并且他们不做任何讨厌的、缓慢的安全检查。一些例子是 NonNull::new_unchecked,slice::get_unchecked,NonZero::new_unchecked,Arc::get_mut_unchecked,以及 str::from_utf8_unchecked。
在实践中,未经检查的方法在安全和性能方面的权衡是很少值得的。与性能优化一样,先测量,后优化。
自定义不变量(Custom Invariants)
大多数对 unsafe 的使用都在一定程度上依赖于自定义不变性。也就是说,它们依赖于 Rust 本身提供的变量之外的变量,这些变量是特定的应用程序或库所特有的。由于很多函数都属于这一类,所以很难对这一类不安全函数做一个很好的总体总结。取而代之的是,我将列举一些你在实践中可能遇到的、想要使用的带有自定义不变式的不安全函数的例子:
-
MaybeUninit::assume_initMaybeUninit类型是少数几种可以在 Rust 中存储对其类型无效的值的方式之一。你可以把MaybeUninit<T>看作是一个目前可能不合法的T,作为一个T使用。例如,MaybeUninit<NonNull>允许持有一个空指针,MaybeUninit<Box>允许持有一个悬空的堆指针,MaybeUninit<bool>允许持有数字 3 的比特模式(通常它必须是 0 或 1)。如果你正在一点一点地构建一个值,或者正在处理最终会变得有效的归零或未初始化的内存(比如通过调用std::io::Read::read来填充),这就很方便了。assume_init函数断言,MaybeUninit现在持有T类型的有效值,因此可以作为T使用。 -
ManuallyDrop::dropManuallyDrop类型是一个围绕着类型T的包装类型,当ManuallyDrop被丢弃时不会丢弃T。或者,换句话说,它将外部类型(ManuallyDrop)的丢弃与内部类型(T)的丢弃相分离。它通过DerefMut<Target = T>实现了对T的安全访问,但也提供了一个drop方法(与Drop trait的drop方法分开)来丢弃被包裹的T而不丢弃ManuallyDrop。 也就是说,尽管drop函数丢弃了T,但它还是带走了&mut self,因此留下了ManuallyDrop。如果你必须明确地丢弃一个你无法移动的值,例如在Droptrait 的实现中,这就很方便了。一旦该值被丢弃,试图访问T就不再安全了,这就是为什么对Drop的调用是不安全的--它断言T将不再被访问。 -
std::ptr::drop_in_placedrop_in_place允许你通过一个指向该值的指针直接调用该值的析构器。这是不安全的,因为在调用之后,这个指针会被留下,所以如果有的代码试图解除对这个指针的引用,它将会有一段糟糕的时间!当你想重用内存时,这个方法特别有用,比如在一个竞技场分配器中,你需要在不回收周围内存的情况下将一个旧值丢在原地。 -
Waker::from_raw在第 8 章中,我们谈到了
Waker类型,以及它是如何由一个数据指针和一个容纳手工实现的vtable的RawWaker组成的。一旦构建了Waker,vtable中的原始函数指针,如wake和drop,可以从安全代码中调用(分别通过Waker::wake和drop(waker))。Waker::from_raw是异步执行器断定其vtable中的所有指针实际上是有效的函数指针,遵循RawWakerVTable文档中规定的契约。 -
std::hint::unreachable_unchecked提示模块拥有向编译器提示周围代码的函数,但实际上不产生任何机器代码。特别是
unreachable_unchecked函数,它告诉编译器,程序在运行时不可能到达某段代码。这反过来又允许编译器根据这一知识进行优化,例如消除通往该位置的条件性分支。与unreachable!宏不同的是,unreachable_unchecked错误的影响很难预测,因为如果代码确实到达了有关的行,它就会惊慌失措。编译器的优化可能会导致奇特的、难以调试的行为,更不用说你的程序会在它认为是真的东西不存在时继续运行了! -
std::ptr::{read,write}_{unaligned,volatile}ptr模块有一些函数,可以让你处理奇怪的指针,这些指针不符合 Rust 对指针的一般假设。这些函数中的第一个是read_unaligned和write_unaligned,它们让你访问指向一个T的指针,即使这个T没有按照T的对齐方式存储(参见第二章的对齐方式部分)。如果T直接包含在一个字节数组中,或者与其他值打包在一起而没有适当的填充,就可能发生这种情况。第二对值得注意的函数是read_volatile和write_volatile,它们让你对不指向正常内存的指针进行操作。具体来说,这些函数将始终访问给定的指针(例如,它们不会被缓存在寄存器中,即使你连续两次读取相同的指针),并且编译器不会将易失性访问相对于其他易失性访问重新排序。在处理没有正常 DRAM 内存支持的指针时,易失性操作很方便,我们将在第 11 章进一步讨论这个问题。最终,这些方法是不安全的,因为它们取消了对给定指针的定义(而且是对一个自有T的定义),所以你作为调用者需要签署与此相关的所有合同。 -
std::thread::Builder::spawn_unchecked我们所熟悉和喜爱的普通
thread::spawn要求所提供的闭包是'static。这种约束源于这样一个事实:被 spawn 的线程可能会运行不确定的时间;如果我们被允许使用对调用者栈的引用,那么调用者可能会在被 spawn 的线程退出之前返回,从而使引用失效。然而,有时候,你知道调用者中的一些非'static值会比生成的线程长寿。如果你在丢弃相关值之前加入了线程,或者在你知道 spawn 线程将不再使用该值之后才丢弃该值,这种情况就会发生。这就是spawn_unchecked的作用,它没有'static约束,因此可以让你实现这些用例,只要你愿意签署合同,说没有不安全的访问会因此而发生。不过要小心恐慌;如果调用者恐慌,它可能会比你计划的时间更早地丢弃数值,并导致生成的线程出现未定义行为。
请注意,所有这些方法(以及标准库中的所有不安全方法)都为其安全不变性提供了明确的文档,任何不安全方法都应该如此。
实现不安全 trait
不安全 trait 不是使用不安全,而是实现不安全。这是因为不安全的代码被允许依赖于不安全 trait 的实现的正确性(由 trait 的文档定义)。例如,为了实现不安全 trait Send,你需要写 unsafe impl Send for ... 。像不安全函数一样,不安全 trait 通常有自定义的不变式,这些不变式在 trait 的文档中被指定(或者至少应该被指定)。因此,很难将不安全 trait 作为一个整体来介绍,所以在这里我也会给出一些标准库中的常见例子,这些例子值得一看。
Send 和 Sync
Send 和 Sync trait 分别表示一个类型可以安全地跨线程发送或共享。我们将在第 10 章中更多地讨论这些 trait,但现在你需要知道的是,它们是自动 trait,所以它们通常会被编译器为你实现大多数类型。但是,正如自动 trait 的情况一样,如果有关类型的任何成员本身不是 Send 或 Sync,那么 Send 和 Sync 将不会被实现。
在不安全代码的背景下,这个问题的发生主要是由于原始指针,它既不是 Send 也不是 Sync。乍一看,这似乎是合理的:编译器没有办法知道谁可能拥有相同值的原始指针,或者他们目前如何使用它,所以这种类型怎么可能安全地跨线程发送?不过现在我们已经是经验丰富的不安全开发者了,这个论点似乎很弱--毕竟,取消对原始指针的引用已经是不安全的了,那么为什么处理 Send 和 Sync 的不变量会有什么不同呢?
严格来说,原始指针可以同时是 Send 和 Sync。问题是,如果它们是,包含原始指针的类型将自动成为 Send 和 Sync,尽管它们的作者可能没有意识到这一点。然后,开发者可能会不安全地解除对原始指针的引用,而没有考虑到如果这些类型被跨线程发送或共享会发生什么,从而无意中引入了未定义的行为。相反,原始指针类型阻止了这些自动实现,作为对不安全代码的额外保障,使作者明确地签署合同,他们也遵循了 Send 和 Sync 不变性。
注意:在
Send和Sync的不安全实现中,一个常见的错误是忘记给泛型参数添加约束:unsafe impl<T: Send> Send for MyUnsafeType<T> {}。
GlobalAlloc
GlobalAlloc trait 是你在 Rust 中实现自定义内存分配器的方法。在本书中我们不会过多地讨论这个话题,但这个 trait 本身是很有趣的。清单 9-4 给出了 GlobalAlloc trait 的必要方法。
#![allow(unused)] fn main() { pub unsafe trait GlobalAlloc { pub unsafe fn alloc(&self, layout: Layout) -> *mut u8; pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout); } // 清单 9-4:GlobalAlloc trait 及其所需的方法 }
在其核心部分,该 trait 有一个分配新的内存块的方法,alloc,和一个是否分配内存块的方法,dealloc。 Layout 参数描述了该类型的大小和对齐方式,正如我们在第二章讨论的那样。这些方法中的每一个都是不安全的,并带有一些调用者必须坚持的安全不变性。
GlobalAlloc 本身也是不安全的,因为它对 trait 的实现者,而不是其方法的调用者施加了限制。只有 trait 的不安全性才能确保实现者同意维护 Rust 本身对其内存分配器所做的不变性,例如在标准库的 Box 实现中。如果该 trait 不是不安全的,实现者可以安全地实现 GlobalAlloc,产生不对齐的指针或不正确的分配大小,这将在其他安全的代码中触发不安全,这些代码假定分配是正常的。这将打破安全代码不应该在其他安全代码中触发内存不安全的规则,从而导致各种混乱。
出人意料的是,没有 Unpin(Surprisingly Not Unpin)
Unpin trait 不是不安全的,这对许多 Rust 开发者来说是个惊喜。在读完第 8 章后,你甚至可能会感到惊讶。毕竟,这个 trait 应该是为了确保自引用类型在建立了内部指针后(也就是在被放入 Pin 后)被移动时不会失效。那么,Unpin 可以被用来安全地从 Pin 中移除一个类型,这似乎很奇怪。
有两个主要原因说明 Unpin 不是一个不安全的 trait。首先,它是不必要的。为一个你控制的类型实现 Unpin 并不能赋予你安全地钉住或解除钉住一个 !Unpin 类型的能力;这仍然需要以调用 Pin::new_unchecked 或 Pin::get_unchecked_mut 的形式来实现不安全。其次,已经有一个安全的方法让你解除对任何你控制的类型的锁定:Drop trait! 当你为一个类型实现 Drop 的时候,你会发现它是一个安全的方法。当你为一个类型实现 Drop 时,你会被传递给 &mut self,即使你的类型之前被存储在 Pin 中并且是 !Unpin,所有这些都没有任何不安全因素。这种潜在的不安全被 Pin::new_unchecked 的不变性所覆盖,首先必须坚持创建一个这样的 !Unpin 类型的 Pin。
什么时候让一个 trait 不安全
在野外,很少有 trait 是不安全的,但那些 trait 都遵循同样的模式。如果假设 trait 正确实现的安全代码在 trait 没有正确实现的情况下会表现出内存不安全,那么该 trait 应该是不安全的。
Send trait 是一个很好的例子,在这里要记住--安全的代码可以很容易地生成一个线程,并将一个值传递给该生成的线程,但如果 Rc 是 Send 的话,这一系列的操作就会轻易地导致内存不安全。考虑一下如果你克隆了一个 Rc<Box>并将其发送给另一个线程会发生什么:这两个线程可以很容易地同时尝试去分配这个 Box,因为他们没有正确地同步访问 Rc 的引用计数。
Unpin trait 是一个很好的反例。虽然有可能写出不安全的代码,如果 Unpin 的实现不正确,就会触发内存不安全,但没有完全安全的代码会因为 Unpin 的实现而触发内存不安全。要确定一个 trait 是安全的并不容易(事实上,Unpin trait 在整个 RFC 过程中都是不安全的),但你总是可以在使该 trait 不安全方面犯错误,如果你意识到这一点,以后再使其安全。请记住,这是一个向后不兼容的变化。
还要记住,仅仅因为感觉一个不正确的(甚至是恶意的)trait 的实现会造成很大的破坏,这并不一定是让它不安全的好理由。不安全标记应该首先用于强调内存不安全的情况,而不仅仅是能引发业务逻辑错误的东西。例如,Eq、Ord、Deref 和 Hash trait 都是安全的,尽管世界上可能有很多代码在面对 Hash 的恶意实现时都会乱套,比如 Hash 每次调用都会返回不同的随机哈希。这也延伸到了不安全的代码,几乎可以肯定的是,在 Hash 的这种实现面前,不安全的代码会变得不安全,但这并不意味着 Hash 应该是不安全的。对于 Deref 的实现来说也是如此,它每次都向不同的(但有效的)目标解读。这种不安全的代码将依赖于 Hash 或 Deref 的契约,而这种契约实际上并不成立;Hash 从未声称它是确定性的,Deref 也没有。或者说,这些实现的作者从来没有用不安全的关键字来做这个声明。
注意:像
Eq、Hash和Deref这样的 trait 是安全的,其重要含义是不安全的代码只能依赖于安全代码的安全性,而不是其正确性。这不仅适用于 trait,也适用于所有不安全/安全代码的交互。
伟大的责任(Great Responsibility)
到目前为止,我们主要看了允许你用不安全代码做的各种事情。但是不安全的代码只有在安全的情况下才允许做这些事情。即使不安全代码可以,比如说,解除对一个原始指针的引用,它也必须在知道该指针在那一刻作为对其指针的引用是有效的情况下才会这样做,这要符合 Rust 对引用的所有正常要求。换句话说,不安全的代码被赋予了可以用来做不安全事情的工具,但它必须使用这些工具只做安全的事情。
那么,这就提出了一个问题,即安全的含义是什么。什么时候解除指针的定义是安全的?什么时候在两个不同的类型之间进行转换是安全的?在本节中,我们将探讨一些关键的不变因素,在使用 unsafe 的力量时要记住这些因素,看看一些常见的问题,并熟悉一些帮助你编写更安全的不安全代码的工具。
围绕 Rust 代码安全的确切规则仍在研究之中。在写这篇文章的时候,不安全代码指南工作组正在努力工作,以确定所有该做的和不该做的,但许多问题仍未得到解答。本节中的大部分建议或多或少都有定论,但我要确保指出任何不符合要求的地方。如果有的话,我希望这一节能教会你在写不安全代码时要小心做假设,并提示你在宣布你的代码可以生产之前要仔细检查 Rust 参考文献。
为什么会出错
如果不讨论违反这些规则会发生什么,我们就无法真正了解不安全代码必须遵守的规则。假设你可以从多个线程并发地可变地访问一个值,构造一个未对齐的引用,或者解除对悬空指针的引用,现在怎么办?
最终不安全的代码被称为具有未定义行为。未定义行为通常以三种方式之一表现出来:完全没有,通过可见的错误,或通过不可见的损坏。第一种情况是你写了一些真正不安全的代码,但编译器生成了正常的代码,你在上面运行的计算机以正常的方式执行这些代码。不幸的是,这里的幸福是非常脆弱的。如果一个新的、稍微聪明的编译器版本出现了,或者一些周围的代码导致编译器应用另一种优化,那么代码可能不再做一些正常的事情,并翻转到更糟糕的情况之一。即使同样的代码是由同一个编译器编译的,如果它在不同的平台或主机上运行,程序可能会有不同的表现 这就是为什么要避免未定义的行为,即使目前看起来一切正常。不这样做就像玩第二轮俄罗斯轮盘赌,只是因为你在第一轮中幸存下来了。
可见的错误是最容易捕捉的未定义行为。例如,如果你解了对空指针的引用,你的程序就会(很有可能)因错误而崩溃,然后你可以通过调试找到根本原因。这种调试本身可能是困难的,但至少你有一个通知,说明出了问题。可见的错误也可以以不太严重的方式表现出来,比如死锁、乱码输出,或者打印出来但没有触发程序退出的恐慌,所有这些都告诉你,你的代码中有一个错误,你必须去修复。
未定义行为最糟糕的表现是,没有直接可见的影响,但程序状态被无形地破坏。交易额可能与应有的数额略有偏差,备份可能被悄悄破坏,或者内部内存的随机位可能暴露给外部客户。未定义的行为可能会导致持续的损坏,或极其不频繁的中断。未定义行为的部分挑战在于,正如其名称所暗示的,非安全的不安全代码的行为没有被定义--编译器可能完全消除它,极大地改变代码的语义,甚至错误地编译周围的代码。这对你的程序有什么影响,完全取决于相关代码的作用。未定义行为的不可预测的影响是所有未定义行为应该被视为严重错误的原因,不管它目前是如何表现的。
为什么是未定义行为?
在关于未定义行为的对话中经常出现的一个论点是,如果代码表现出未定义行为,编译器应该发出错误,而不是做一些奇怪和不可预测的事情。这样一来,就几乎不可能写出糟糕的不安全代码了。
不幸的是,这是不可能的,因为未定义行为很少是明确或明显的。相反,通常发生的情况是,编译器只是在假设代码遵循规范的情况下应用优化。如果事实并非如此--这在运行时才会清楚--那么就很难预测会有什么影响。也许优化仍然有效,不会发生什么坏事;但也许不是,代码的语义最终会与未优化版本的语义略有不同。
如果我们要告诉编译器开发者,他们不允许对底层代码进行任何假设,那么我们真正要告诉他们的是,他们无法执行今天他们成功实施的各种优化措施。几乎所有复杂的优化都会对相关代码根据语言规范能做什么和不能做什么做出假设。
如果你想很好地说明规范和编译器优化是如何以奇怪的方式相互作用的,在这种情况下很难分配责任,我推荐阅读 Ralf Jung 的博文《我们需要更好的语言规范》(
https://www.ralfj.de/blog/2020/12/14/provenance.html)。
有效性(Validity)
也许在编写不安全代码之前,最重要的概念是有效性,它决定了一个给定类型中的值的规则,或者说,不那么正式,一个类型的值的规则。 这个概念比它听起来要简单,所以让我们深入了解一些具体的例子。
引用类型
Rust 对其引用类型所能持有的值有非常严格的规定。 具体来说,引用决不能悬空,必须始终对齐,并且必须始终指向其目标类型的有效值。此外,一个给定的内存位置的共享和独占引用不能同时存在,一个位置的多个独占引用也不能存在。无论你的代码是否使用这些引用,这些规则都是适用的--你不允许创建一个空的引用,即使你随后立即丢弃它。
共享引用有一个额外的约束,即在引用的有效期内,被指代者不允许改变。也就是说,被指代者包含的任何值必须在其生存期内保持完全相同。这一点是过渡性的,所以如果你有一个包含 *mut T 的类型的 &,你不允许通过 *mut 来改变 T,即使你可以使用 unsafe 编写代码来这样做。这个规则的唯一例外是被 UnsafeCell 类型包裹的值。 所有其他提供内部可变性的类型,如 Cell、RefCell 和 Mutex,都在内部使用 UnsafeCell。
Rust 对引用的严格规定的一个有趣结果是,多年来,不可能安全地获取对使用 repr(Rust) 的打包或部分未初始化结构的字段的引用。由于 repr(Rust) 没有定义一个类型的布局,获得一个字段地址的唯一方法是 &some_struct.field as *const _。然而,如果 some_struct 是打包的,那么 some_struct.field 可能不会被对齐,因此给它创建一个 & 是非法的。此外,如果 some_struct 没有被完全初始化,那么 some_struct 的引用本身就不可能存在!在 Rust 1.51.0 中,ptr::addr_of! 宏被稳定化了,它增加了一种机制,可以直接获得一个字段的引用,而不需要首先创建一个引用,从而解决了这个特殊的问题。在内部,它是用一种叫做原始引用(raw references)的东西来实现的(不要和原始指针(raw pointers)混淆),它直接创建指向其操作数的指针,而不是通过引用。原始引用是在 RFC 2582 中引入的,但在写这篇文章时,它还没有被稳定下来。
原始类型 (Primitive Types)
Rust 的一些原始类型对它们能容纳的值有限制。例如,bool 被定义为 1 字节大,但只允许持有 0x00 或 0x01 的值,char 不允许持有代用值或高于 char::MAX 的值。大多数 Rust 的原始类型,以及事实上大多数 Rust 的类型,也不能从未初始化的内存中构建。这些限制可能看起来很随意,但往往是由于需要实现优化,否则就不可能实现。
这方面的一个很好的例子是利基优化(niche optimization),我们在本章前面谈到指针类型时简要地讨论了这个问题。简而言之,在某些情况下,利基优化将枚举的判别值隐藏在包裹的类型中。例如,由于一个引用不可能是所有的零,一个 Option<&T> 可以使用所有的零来表示无,从而避免花费一个额外的字节(加上填充)来存储判别字节。编译器可以用同样的方式来优化布尔运算,并且有可能更进一步。考虑一下 Option<Option<bool>> 这个类型。由于编译器知道 bool 是 0x00 或 0x01,它可以自由地使用 0x02 来表示 Some(None),使用 0x03 来表示 None。很好,很整洁!但如果有人来搞破坏,那就麻烦了。但是如果有人来把 0x03 这个字节当作一个 bool,然后把这个值放在以这种方式优化的 Option<Option<bool>>中,就会发生坏事。
需要重申的是,Rust 编译器目前是否实现了这种优化并不重要。关键是它被允许这样做,因此你写的任何不安全的代码都必须符合这个契约,否则就有可能在以后的行为改变中遇到错误。
拥有的指针类型(Owned Pointer Types)
指向它们拥有的内存的类型,比如 Box 和 Vec,通常会受到与它们拥有指向内存的独占引用相同的优化,除非它们被明确地通过共享引用访问。具体来说,编译器假设指向的内存没有在其他地方共享或别名,并根据这一假设进行优化。例如,如果你从一个 Box 中提取了指针,然后从同一个指针构造了两个 Boxes,并且用 ManuallyDrop 来包装它们,以防止双重释放,那么你很可能会进入未定义行为领域。即使你只通过共享引用访问内部类型,情况也是如此。(我说 "可能 "是因为这在语言参考中还没有完全解决,但已经产生了一个大致的共识)。
存储无效值 (Storing Invalid Values)
有时你需要存储一个当前对其类型无效的值。最常见的例子是,如果你想为某种类型的 T 分配一大块内存,然后从比如说网络上读入字节。在所有的字节被读入之前,内存不会是一个有效的 T。即使你只是试图将字节读入 u8 的一个片断,你也必须先将这些 u8 清零,因为从未初始化的内存中构造一个 u8 也是未定义的行为。
MaybeUninit<T> 类型是 Rust 的机制,用于处理无效的值。MaybeUninit<T> 精确地存储了一个 T(它是# [repr(transparent)]),但是编译器知道对这个 T 的有效性不做任何假设。它不会假设引用是非空的,Box<T>不是悬空的,或者一个 bool 是 0 或 1。这意味着在 MaybeUninit 中持有一个由未初始化的内存支持的 T 是安全的(正如其名称所暗示的)。MaybeUninit 也是一个非常有用的工具,在其他不安全的代码中,你必须临时存储一个可能是无效的值。也许你需要存储一个别名为 Box<T>的东西,或者暂时存放一个 char 代理,MaybeUninit 是你的朋友。
你通常只会对一个 MaybeUninit 做三件事:用 MaybeUninit::uninit 方法创建它,用 MaybeUninit::as_mut_ptr 写入它的内容,或者用 MaybeUninit::assumed_init 再次取走内部 T 的有效性。顾名思义,uninit 创建了一个新的 MaybeUninit<T>,其大小与最初持有未初始化内存的 T 相同。as_mut_ptr 方法给你一个指向内部 T 的原始指针,然后你可以写到它;没有什么能阻止你从它那里读取,但是从任何未初始化的位读取都是未定义的行为。 最后,不安全的 assume_init 方法消耗了 MaybeUninit<T>,并在断言支持的内存现在构成了一个有效的 T 之后将其内容作为一个 T 返回。
清单 9-5 显示了一个例子,说明我们如何使用 MaybeUninit 来安全地初始化一个字节数组而不明确地将其清零。
#![allow(unused)] fn main() { fn fill(gen: impl FnMut() -> Option<u8>) { let mut buf = [MaybeUninit::<u8>::uninit(); 4096]; let mut last = 0; for (i, g) in std::iter::from_fn(gen).take(4096).enumerate() { buf[i] = MaybeUninit::new(g); last = i + 1; } // Safety: all the u8s up to last are initialized. let init: &[u8] = unsafe { MaybeUninit::slice_assume_init_ref(&buf[..last]) }; // ... do something with init ... } // 清单 9-5:使用 MaybeUninit 安全初始化一个数组 }
虽然我们可以将 buf 声明为 [0;4096],但这将要求函数在执行前首先将所有这些零写入栈,即使之后不久它将再次覆盖这些零。通常情况下,这不会对性能产生明显的影响,但如果这是在一个足够热的循环中,它可能会!在这里,我们允许数组保持函数被调用时堆栈中的任何值,然后只覆盖我们最终需要的值。
注意:丢弃部分初始化的内存时要小心。如果在
MaybeUninit<T>被完全初始化之前,恐慌导致了意外的提前丢弃,你必须注意只丢弃T中现在有效的部分,如果有的话。你可以直接丢弃 MaybeUninit,并让支持的内存被遗忘,但如果它持有,比如说,一个Box,你可能最终会出现内存泄漏。
恐慌
要确保使用不安全操作的代码是安全的,一个重要而又经常被忽视的方面是,代码还必须准备好处理恐慌。特别是,正如我们在第 5 章中简要讨论的那样,Rust 在大多数平台上的默认恐慌处理程序不会在恐慌时崩溃你的程序,而是会解除当前线程的束缚。解除恐慌会有效地丢弃当前作用域中的所有东西,从当前函数中返回,丢弃包围该函数的作用域中的所有东西,以此类推,一直到堆栈中的当前线程的第一个栈帧。如果你在你的不安全代码中没有考虑到解卷,你可能会遇到麻烦。例如,考虑清单 9-6 中的代码,它试图一次有效地将许多值推入一个 Vec。
#![allow(unused)] fn main() { impl<T: Default> Vec<T> { pub fn fill_default(&mut self) { let fill = self.capacity() - self.len(); if fill == 0 { return; } let start = self.len(); unsafe { self.set_len(start + n); for i in 0..fill { *self.get_unchecked_mut(start + i) = T::default(); } } } } // 清单 9-6:用默认值填充向量的一种看似安全的方法 }
考虑一下如果对 T::default 的调用恐慌了会发生了什么,首先,fill_default 会丢弃它所有的本地值(这些值只是整数),然后返回。调用者随后也会这样做。在栈的某个点上,我们到达了 Vec 的所有者那里。当所有者放弃 Vec 时,我们有一个问题:向量的长度现在表明我们拥有的 T 比我们实际产生的要多,因为调用了 set_len。例如,如果第一次调用 T::default 时,我们的目标是填充 8 个元素,这意味着 Vec::drop 会在 8 个 T 上调用 drop,而这些 T 实际上包含了未初始化的内存!
这种情况下的修复方法很简单:代码必须在写完所有元素后更新长度。如果我们不仔细考虑解开恐慌对我们的不安全代码的正确性的影响,我们就不会意识到有这个问题。
当你在梳理你的代码时,你要注意任何可能发生恐慌的语句,并考虑如果它们发生恐慌,你的代码是否安全。或者,检查一下你是否能说服自己,有关的代码永远不会发生恐慌。要特别注意任何调用用户提供的代码的情况--在这些情况下,你无法控制恐慌,应该假设用户代码会恐慌。
当你使用 ? 操作符从一个函数中提前返回时,也会出现类似的情况。如果你这样做,请确保你的代码仍然是安全的,如果它不执行函数中的剩余代码。由于你明确地选择了 ? 操作符,所以比较少会让你措手不及,但还是值得留意。
投掷(Casting)
正如我们在第 2 章中所讨论的,两个都是 #[repr(Rust)] 的不同类型在内存中的表现可能是不同的,即使它们有相同类型和相同顺序的字段。这又意味着在两个不同的类型之间进行转换(cast)并不总是那么明显。事实上,Rust 甚至不能保证两个具有泛型参数的单一类型的实例以相同的方式表示。 例如,在清单 9-7 中,A 和 B 不能保证在内存中具有相同的表示。
#![allow(unused)] fn main() { struct Foo<T> { one: bool, two: PhantomData<T>, } struct Bar; struct Baz; type A = Foo<Bar>; type B = Foo<Baz>; // 清单 9-7:类型布局是不可预测的。 }
当你在不安全的代码中进行类型转换时,repr(Rust) 缺乏保证,这一点很重要,因为两个类型感觉上应该是可以互换的,但事实并不一定如此。在两个具有不同表现形式的类型之间进行转换,是通往未定义行为的捷径。在写这篇文章的时候,Rust 社区正在积极制定关于如何表示类型的确切规则,但现在,很少有保证,所以这就是我们要做的。
即使相同的类型被保证有相同的内存表示,当类型被嵌套时,你仍然会遇到同样的问题。例如,虽然 UnsafeCell<T>、MaybeUninit<T>和 T 实际上都只是持有一个 T,而且你可以在它们之间尽情地转换,但是一旦你有了例如 Option<MaybeUninit<T>>,这就不复存在。尽管 Option<T>可以利用利基优化(使用一些无效的 T 值来代表 Option 的 None),但 MaybeUninit<T> 可以持有任何位模式,所以这种优化并不适用,而且必须为 Option 的区分器保留一个额外的字节。
不仅仅是优化,一旦包装器类型开始发挥作用,就会导致布局的分歧。作为一个例子,请看清单 9-8 中的代码;在这里,Wrapper<PhantomData<u8>>和 Wrapper<PhantomData<i8>>的布局是完全不同的,尽管提供的类型都是空的。
#![allow(unused)] fn main() { struct Wrapper<T: SneakyTrait> { item: T::Sneaky, iter: PhantomData<T>, } trait SneakyTrait { type Sneaky; } impl SneakyTrait for PhantomData<u8> { type Sneaky = (); } impl SneakyTrait for PhantomData<i8> { type Sneaky = [u8; 1024]; } // 清单 9-8: 封装器类型使投掷难以正确。 }
所有这些并不是说你永远不能在 Rust 中投掷(cast)类型,事情会变得容易得多,例如,当你控制所有涉及的类型和它们的 trait 实现时,或者如果类型是 #[repr(C)]。你只需要意识到 Rust 对内存表示法的保证非常少,并据此编写你的代码!
析构检查(The Drop Check)
Rust 的借用检查器本质上是一种复杂的工具,用于确保代码在编译时的可靠性,这反过来又给了 Rust 一种表达代码安全性的方式。借用检查器究竟如何工作超出了本书的范围,但是有一种检查,即 drop 检查,值得详细讨论,因为它对不安全代码有一些直接的暗示。为了理解 drop 检查,让我们站在 Rust 编译器的角度来看一下两个代码片段。首先,看看清单 9-9 中的三行代码,它接受一个变量的可变引用,然后立即对该变量进行突变。
#![allow(unused)] fn main() { let mut x = true; let foo = Foo(&mut x); x = false; 清单 9-9:Foo 的实现决定了这段代码是否应该编译 }
在不知道 Foo 的定义的情况下,你能说这段代码是否应该被编译吗?当我们设置 x = false 时,仍然有一个 foo 挂在那里,它将在作用域的末端被丢弃。我们知道 foo 包含一个 x 的可变借用,这将表明修改 x 所需的可变借是非法的。但是允许它又有什么坏处呢?事实证明,只有在 Foo 实现了 Drop 的情况下,允许 x 的突变才是有问题的--如果 Foo 没有实现 Drop,那么我们知道 Foo 在最后一次使用后不会触及 x 的引用。因为最后一次使用是在我们需要独占引用进行赋值之前,所以我们可以允许这段代码另一方面,如果 Foo 实现了 Drop,我们就不能允许这段代码,因为 Drop 的实现可能会用到 x 的引用。
现在你已经热身了,看一下清单 9-10。在这个不那么直接的代码片段中,可变引用被埋得更深了。
#![allow(unused)] fn main() { fn barify<’a>(_: &’a mut i32) -> Bar<Foo<’a>> { .. } let mut x = true; let foo = barify(&mut x); x = false; // 清单 9-10:Foo 和 Bar 的实现决定了这段代码是否应该被编译 }
同样,在不知道 Foo 和 Bar 的定义的情况下,你能说这段代码是否应该被编译吗?让我们考虑一下如果 Foo 实现了 Drop 而 Bar 没有实现会发生什么,因为这是最有趣的情况。通常情况下,当一个 Bar 超出范围,或者以其他方式被丢弃时,它仍然必须丢弃 Foo,这反过来意味着这段代码应该被拒绝,原因与之前一样。然而,Bar 可能根本不直接包含 Foo,而只是一个 PhantomData<Foo<'a>> 或一个 &'static Foo<'a>,在这种情况下,代码实际上是可以的--即使 Bar 被丢弃,Foo::drop 也从未被调用,对 x 的引用也从未被访问。这就是我们希望编译器接受的代码,因为人类能够识别出它是好的,即使编译器发现很难检测到这种情况。
我们刚刚走过的逻辑是 drop 检查。通常情况下,它不会对不安全的代码产生太大的影响,因为它的默认行为符合用户的期望,但有一个主要的例外:悬空的泛型参数。想象一下,你正在实现你自己的 Box<T> 类型,有人像我们在清单 9-9 中所做的那样把一个 &mut x 放入其中。你的 Box 类型需要实现 Drop 来释放内存,但是除了 Drop 之外,它并没有访问 T。由于丢弃一个 &mut 没有任何作用,所以在最后一次访问 Box 之后但在它被 drop 之前,代码再次访问 &mut x 应该是完全没问题的。为了支持这样的类型,Rust 有一个不稳定的功能,叫做 dropck_eyepatch(因为它使丢弃检查部分失明)。这个功能可能会永远不稳定,在一个合适的机制被设计出来之前,它只是作为一个临时性的逃生舱口。dropck_eyepatch 特性添加了一个 #[may_dangle] 属性,你可以将其作为前缀添加到类型的 Drop 实现中的泛型生存期和类型中,以告诉 Drop 检查机制,你不会在 drop 它之后使用注释的生存期或类型。你可以通过以下方式使用它:
#![allow(unused)] fn main() { unsafe impl<#[may_dangle] T> Drop for .. }
这个避风港允许一个类型声明一个给定的泛型参数不用于 Drop,这使得 Box<&mut T> 这样的用例成为可能。具体来说,*mut T 使得 Rust 的 Drop 检查认为你的 Box<T> 并不拥有一个 T,因此它也不会调用 T::drop。结合 may_dangle 断言,即当 Box<T> 被 drop 时我们不访问 T,drop 检查现在得出结论,有一个 Box<T>,其中 T 在 Box 被丢弃之前不存在,这是很好的(就像清单 9-10 中缩短的 &mut x)。但这不是真的,因为我们确实调用了 T::drop,它本身可能会访问,比如说,对上述 x 的引用。
幸运的是,修复方法很简单:我们添加一个 PhantomData<T> 来告诉 drop 检查,尽管 Box<T> 没有持有任何 T,也不会在丢弃时访问 T,但它仍然拥有一个 T,并且在 Box 被 drop 时将丢弃一个。清单 9-11 显示了我们假设的盒子类型是什么样子的。
#![allow(unused)] fn main() { struct Box<T> { t: NonNull<T>, // NonNull not *mut for covariance (Chapter 1) _owned: PhantomData<T>, // For drop check to realize we drop a T } unsafe impl<#[may_dangle] T> for Box<T> { /* ... */ } // 清单 9-11:Box 的定义,在 drop 检查方面有最大的灵活性。 }
这种交互很微妙,很容易被忽略,但它只在你使用不稳定的 #[may_dangle] 属性时出现。希望这个小节可以作为一个警告,这样当你将来在野外看到 unsafe impl Drop 时,你就会知道也要找一个 PhantomData<T>!
注意:关于
Drop的不安全代码的另一个考虑是,确保你有一个Type<T>,让T在自己被丢弃后继续存在。例如,如果你正在实现延迟的垃圾收集,你需要同时添加T:'static。 否则,如果T = WriteOnDrop<&mut U>,以后对T的访问或丢弃可能会触发未定义的行为。
克服恐惧(Coping with Fear)
随着这一章的结束,你现在可能比开始之前更害怕不安全的代码了。虽然这是可以理解的,但需要强调的是,写出安全的不安全代码不仅是可能的,而且在大多数情况下,这甚至不是那么困难。关键是要确保你小心翼翼地处理不安全的代码;这就是斗争的一半。在使用不安全代码之前,一定要确定没有一个安全的实现可以代替你使用。
在本章的剩余部分,我们将研究一些技术和工具,它们可以帮助你在没有办法的情况下对不安全代码的正确性更有信心。
管理不安全的界线
对不安全进行局部推理是很诱人的;也就是说,考虑你刚刚写的不安全块中的代码是否安全,而不去考虑它与代码库其他部分的交互。不幸的是,这种局部推理往往会让你吃亏。Unpin 特性就是一个很好的例子--你可以为你的类型写一些代码,使用 Pin::new_unchecked 来产生对该类型的一个字段的钉住的引用,当你写这个代码时,它可能是完全安全的。但是在后来的某个时间点,你(或其他人)可能会为上述类型添加一个安全的 Unpin 实现,突然间,不安全的代码就不再安全了,尽管它远没有接近新的植入物!
安全性是一种属性,只能在所有与不安全块有关的代码的隐私边界处进行检查。这里的隐私边界与其说是一个正式的术语,不如说是试图描述你的代码中可以摆弄不安全位的任何部分。例如,如果你在一个标有 pub 或 pub(crate) 的模块栏中声明了一个公共类型 Foo,那么同一模块中的任何其他代码都可以实现 Foo 的方法和特性。因此,如果你的不安全代码的安全性取决于 Foo 不实现特定的特征或具有特定签名的方法,那么当你为 Foo 添加内联时,你需要记住重新检查该不安全块的安全性。另一方面,如果 Foo 对整个 crate 不可见,那么能够添加有问题的实现的作用域就会少得多,因此,意外添加破坏安全变量的实现的风险也会相应降低。如果 Foo 是私有的,那么只有当前模块和任何子模块可以添加这种实现。
同样的规则也适用于对字段的访问:如果一个不安全块的安全性取决于对一个类型的字段的某些不变性,那么任何可以接触这些字段的代码(包括安全代码)都属于不安全块的隐私边界。在这里,最小化隐私边界也是最好的方法--不能接触到字段的代码不能扰乱你的不变式!
因为不安全的代码往往需要这种广泛的推理,所以最好的做法是尽可能地将不安全的东西封装在你的代码中。以单个模块的形式提供不安全因素,并努力给该模块一个完全安全的接口。这样一来,你只需要在该模块的内部审计你的不变量。或者更好的是,把不安全的部分放在他们自己的箱子里,这样你就不会意外地留下任何漏洞了。
然而,将复杂的不安全交互完全封装在一个安全的接口上并不总是可能的。在这种情况下,要尽量缩小公共接口中必须是不安全的部分,以便只有很少的部分,给它们起个名字,清楚地说明需要注意的地方,然后严格地记录它们。
有时,我们很想去掉内部 API 上的不安全标记,这样你就不必在整个代码中贴上 unsafe {}。 毕竟,在你的代码中,你知道如果你之前调用了 bazzify,就永远不要调用 frobnify,对吗?删除 unsafe 注解可以使代码更干净,但从长远来看,这通常是一个糟糕的决定。一年后,当你的代码库扩大了,你已经分页出了一些安全不变式,而你 "只想快速地砍掉这个功能 "时,你有可能会不经意地破坏其中一个不变式。由于你不需要输入不安全因素,你甚至不会想到去检查。另外,即使你从不犯错,那么你的代码的其他贡献者呢?归根结底,更干净的代码并不足以成为删除故意制造噪音的 unsafe 标记的理由。
读写文档
不言而喻,如果你写了一个不安全的函数,你必须记录该函数在什么条件下可以安全调用。在这里,明确性和完整性都很重要。不要遗漏任何不变量,即使你已经在其他地方写了它们。如果你有一个类型或模块需要某些全局不变性--对该类型的所有使用必须始终保持的不变性--那么提醒读者,他们也必须在每个不安全函数的文档中坚持全局不变性。 开发人员经常以临时的、按需的方式阅读文档,所以你可以假设他们可能没有阅读你精心编写的模块级文档,需要给他们一个提示来这样做。
不太明显的是,你还应该记录所有不安全的实现和块--把这看作是提供证据,证明你确实维护了有关操作所要求的契约。例如,slice::get_unchecked 要求所提供的索引在 slice 的边界内;当你调用该方法时,在其上方加一个注释,解释你是如何知道索引事实上被保证在边界内的。在某些情况下,不安全块所要求的不变性很广泛,你的注释可能会变得很长。这是件好事。我曾多次发现错误,因为我试图为不安全块写安全注释,但写到一半时发现其实我并没有维护一个关键不变式。一年后,当你不得不修改这段代码并确保它仍然安全时,你也会感谢自己。你的项目的贡献者也会感谢你,他只是偶然发现了这个不安全的调用,并想了解发生了什么。
在你深入编写不安全代码之前,我还强烈建议你去把《Rustonomicon》(https://doc.rustlang.org/nomicon/)从头到尾读一遍。有很多细节是很容易被忽略的,如果你没有意识到,就会被反过来咬你一口。我们在本章中已经介绍了其中的许多内容,但多加注意总没有坏处。当你有疑问的时候,你也应该充分利用 Rust 的参考资料。它是定期添加的,如果你对你的某些假设是否正确有一点不确定,参考资料会指出来。如果没有,可以考虑开一个问题,让它被添加进来。
检查你的工作
好了,你已经写了一些不安全的代码,你已经反复检查了所有的不变量,你认为它已经准备好了。 在你把它投入生产之前,有一些自动化工具,你应该通过它来运行你的测试套件(你有一个测试套件,对吗?)
其中第一个是 Miri,中级的中间表示法解释器。Miri 不会将你的代码编译成机器代码,而是直接解释 Rust 代码。这为 Miri 提供了更多关于你的程序正在做什么的可见性,这反过来又使它能够检查你的程序没有做任何明显的坏事,比如从未初始化的内存中读取。Miri 可以捕捉到很多非常微妙的、针对 Rust 的 bug,是任何编写不安全代码的人的救星。
不幸的是,由于 Miri 必须解释代码来执行它,在 Miri 下运行的代码往往比其编译后的代码慢几个数量级。由于这个原因,Miri 实际上应该只用来执行你的测试套件。它也只能检查实际运行的代码,因此不会捕捉到你的测试套件没有到达的代码路径中的问题。你应该把 Miri 看作是你的测试套件的延伸,而不是替代它。
还有一些被称为 "净化器 "的工具,它们对机器代码进行检测,以便在运行时发现错误的行为。这些工具的开销和保真度差别很大,但一个广受喜爱的工具是谷歌的 AddressSanitizer。它可以检测到大量的内存错误,例如 "使用后释放"、缓冲区溢出和内存泄漏,所有这些都是不正确的不安全代码的常见症状。与 Miri 不同,这些工具在机器代码上操作,因此往往相当快--通常在同一数量级内。但与 Miri 一样,它们也被限制在分析实际运行的代码上,所以在这里,一个可靠的测试套件也是至关重要的。
有效使用这些工具的关键是通过你的持续集成管道将它们自动化,这样它们就会为每一个变化而运行,并确保你在发现错误时增加回归测试。随着测试套件质量的提高,这些工具能更好地捕捉问题,所以在修复已知错误的同时加入新的测试,可以说是赚取了双倍的积分!
最后,别忘了在不安全的代码中大量撒上断言。恐慌总是比触发未定义的行为要好如果可以的话,用断言检查你所有的假设--甚至像 usize 的大小这样的东西,如果你依靠它来保证安全的话。如果你担心运行时成本,可以使用 debug_assert* 宏和 if cfg!(debug_assertions) || cfg!(test) 结构,只在调试和测试环境下执行。
纸牌屋? 不安全的代码会违反 Rust 的所有安全保证,这一点经常被吹捧为 Rust 的整个安全论证是一个骗局的理由。人们关心的是,只要有一点不正确的不安全代码,整个房子就会倒塌,所有的安全就会丧失。这一论点的支持者有时会争辩说,至少只有不安全的代码才能调用不安全的代码,这样一来,不安全就可以一直看到应用程序的最高层。
这个论点是可以理解的--Rust 代码的安全性确实依赖于它最终调用的所有传递性不安全代码的安全性。然而,这个论点所忽略的是,所有成功的安全语言都为语言扩展提供了便利,这些扩展在(安全)表面语言中是无法表达的,通常是以 C 或汇编的形式编写的代码。就像 Rust 依赖于其不安全代码的正确性一样,这些语言的安全性也依赖于这些扩展的正确性。
Rust 的不同之处在于,它没有单独的扩展语言,而是允许用相当于 Rust 的方言(不安全的 Rust)来编写扩展。这使得安全代码和不安全代码之间的整合更加紧密,这反过来又减少了由于两者之间接口的阻抗不匹配而导致的错误,或者由于开发人员熟悉其中一个而不熟悉另一个而导致的错误。更紧密的整合也使得编写分析不安全代码与安全代码交互的正确性的工具变得更容易,Miri 等工具就是一个例子。由于不安全的 Rust 在任何非明确不安全的操作上都要接受借贷检查器的检查,所以仍有许多安全检查存在,而当开发者必须下降到像 C 语言时,这些检查是不存在的。
总结
在这一章中,我们介绍了 unsafe 关键字所具有的权力,以及我们利用这些权力所承担的责任。我们还谈到了编写不安全的不安全代码的后果,以及你应该把 unsafe 看作是向编译器保证你已经手动检查过所指示的代码仍然安全的一种方式。在下一章中,我们将讨论 Rust 中的并发性,看看如何让你闪亮的新电脑上的所有内核都朝同一个方向拉动!