第一章 基础
当你深入到 Rust 的更高级的角落时,重要的是你要确保你对基础知识有一个坚实的理解。在 Rust 中,就像任何编程语言一样,当你开始以更复杂的方式使用该语言时,各种关键字和概念的确切含义变得非常重要。在本章中,我们将浏览 Rust 的许多基元,并试图更清楚地定义它们的含义,它们是如何工作的,以及为什么它们是这样的。具体来说,我们将看看变量和值有什么不同,它们在内存中是如何表示的,以及一个程序有哪些不同的内存区域。然后,我们将讨论一些所有权、借用和寿命的微妙之处,在你继续阅读本书之前,你需要掌握这些知识。
如果你愿意,你可以从头到尾地阅读这一章,也可以把它作为参考,来复习那些你觉得不太确定的概念。我建议你只有在对本章的内容感到完全满意时才继续阅读,因为对这些基元如何工作的误解会很快妨碍你理解更高级的主题,或者导致你错误地使用它们。
谈谈内存
不是所有的内存都是平等的。在大多数编程环境中,你的程序可以访问栈(stack)、堆(heap)、寄存器(register)、文本段(text segment)、内存映射的寄存器(memory-mapped register)、内存映射的文件(memory-mapped file),也许还有非易失性 RAM(Nonvolatile RAM)。在特定情况下,你选择使用哪一个,对你能在那里存储什么,它能保持多长时间,以及你用什么机制来访问它都有影响。这些内存区域的具体细节因平台而异,超出了本书的范围,但有些内存区域对你如何推理 Rust 代码非常重要,因此值得在此介绍。
内存术语
在我们深入研究内存区域之前,你首先需要了解值、变量和指针之间的区别。Rust 中的值是一个类型和该类型的值域的一个元素的组合。一个值可以使用其类型的表示法变成一串字节,但就其本身而言,你可以认为一个值更像是你这个程序员的意思。例如,u8 类型中的数字 6 是数学整数 6 的一个实例,它在内存中的表示是字节 0x06。同样,字符串 "Hello world" 是所有字符串域中的一个值,其表示方法是 UTF-8 编码。一个值的意义与这些字节存储的位置无关。
一个值被存储在一个地方,这是 Rust 的术语,意思是 "一个可以容纳一个值的位置"。这个位置可以在栈中,也可以在堆上,或者在其他一些位置。最常见的存储值的地方是一个变量,它是栈上的一个命名值槽。
指针是一个持有内存区域地址的数值,所以指针指向一个地方。指针可以被解引用,以访问存储在它所指向的内存位置的值。我们可以在一个以上的变量中存储同一个指针,因此有多个变量间接地指向内存中的同一个位置,从而指向同一个底层值。
考虑清单 1-1 中的代码,它说明了这三个要素。
#![allow(unused)] fn main() { let x = 42; let y = 43; let var1 = &x; let mut var2 = &x; var2 = &y; // (1) // 清单 1-1:值、变量和指针 }
这里,有四个不同的值。42(一个i32),43(一个i32),x 的地址(一个指针),以及 y 的地址(一个指针)。还有四个变量:x、y、var1 和 var2。后两个变量都持有指针类型的值,因为引用是指针。虽然 var1 和 var2 最初存储的是同一个值,但它们分别存储该值的独立副本;当我们改变 var2 (1) 中存储的值时,var1 中的值不会改变。特别是,= 运算符将右侧表达式的值存储在左侧命名的地方。
变量、值和指针之间的区别变得很重要的一个有趣的例子是在一个语句中,比如说:
#![allow(unused)] fn main() { let string = "Hello world"; }
尽管我们给变量 string 分配了一个字符串值,但该变量的实际值是一个指向字符串值 "Hello world "中第一个字符的指针,而不是字符串值本身。在这一点上,你可能会说:"但是等一下,那么字符串的值是在哪里存储的?指针指向哪里?" 如果是这样的话,你的眼光就很敏锐了--我们一会儿就会说到这一点。
深入了解变量
我前面给出的变量定义很宽泛,本身不太可能有什么用。当你遇到更复杂的代码时,你将需要一个更准确的心智模型来帮助你推理出程序的真正作用。我们可以利用许多这样的模型。详细描述它们会占用好几章的篇幅,也超出了本书的范围,但广义上讲,它们可以分为两类:高层模型(High-level)和低层模型(low-level)。高层模型在思考生存期和借用层面的代码时很有用,而低层模型在推理不安全代码和原始指针时很有用。下面两节描述的变量模型对于本书的大部分材料来说已经足够了。
高层模型
在高级模型中,我们不认为变量是存放字节的地方。当你给一个变量赋值的时候,这个值就被这个变量命名了。当一个变量后来被访问时,你可以想象从该变量的前一次访问到新的访问画一条线,这在两次访问之间建立了一种依赖关系。如果一个变量中的值被移动了,就不能再从它那里画线了。
在这个模型中,一个变量只有在它持有一个合法的值时才存在;你不能从一个值未被初始化或已被移动的变量上画线,所以实际上它不存在。使用这个模型,你的整个程序由许多这样的依赖线组成,通常称为流,每个流都追踪一个值的特定实例的生存期。当有分支时,流可以分叉和合并,每一个分叉都追踪该值的一个不同的生存期。编译器可以检查在程序的任何给定点,所有可以相互平行存在的流都是兼容的。例如,不能有两个并行的流对一个值进行可变的访问。也不能有一个流借用一个值,而没有一个流拥有该值。清单 1-2 显示了这两种情况的例子。
#![allow(unused)] fn main() { let mut x; // 这是非法的,没有地方可以获取流 // assert_eq!(x, 42); x = 42; // (1) // 这是好的,可以从上面分配的值中画出一个流程。 let y = &x; // (2) // 这就建立了第二个来自 x 的、可变的流。 x = 43; // (3) // 这样就继续从 y 那里获得流,而 y 又从 x 那里获得流。 // 但这条流与对 x 的分配相冲突! assert_eq!(*y, 42); // (4) // 清单 1-2:借用检查器会发现的非法流 }
首先,在 x 被初始化之前,我们不能使用它,因为我们没有地方可以绘制流。只有当我们给 x 赋值时,我们才能从它那里提取流。这段代码有两个流:一个从 (1) 到 (3) 的独占(&mut)流,一个从 (1) 到 (2) 到 (4) 的共享(&)流。 借阅检查器检查每个流的每个顶点,并检查是否有其他不兼容的流同时存在。在这个例子中,当借用检查器检查 (3) 处的独占流时,它看到了终止于 (4) 处的共享流。由于你不能同时对一个值进行独占和共享使用,借用检查器(正确地)拒绝了该代码。请注意,如果没有 (4),这段代码会编译得很好。共享流将在 (2) 处终止,而当独占流在 (3) 处被检查时,就不会有冲突的流存在。
如果一个新的变量与之前的变量同名,它们仍然被认为是不同的变量。这被称为 "遮蔽"--后一个变量 "遮蔽"了前一个同名的变量。这两个变量共存,尽管随后的代码不再有办法命名先前的变量。这个模型与编译器,特别是借用检查器,对你的程序的推理大致吻合,并且实际上在编译器的内部使用,以产生高效代码。
底层模型
变量命名了可能持有或不持有合法数值的内存位置。你可以把一个变量看作是一个 "值槽"。当你给它赋值时,槽被填满,它的旧值(如果它有一个的话)被丢弃和替换。当你访问它时,编译器会检查该槽是否为空,因为这意味着该变量未被初始化或其值已被移动。一个变量的指针指的是该变量的后备内存,可以被解引用以获得其值。例如,在语句 let x: usize 中,变量 x 是堆栈上一个内存区域的名称,该区域有空间容纳一个 usize 大小的值,尽管它没有一个明确的值(其槽是空的)。如果你给这个变量赋值,比如 x = 6,那么这个内存区域就会容纳代表值 6 的比特。这个模型与 C 和 C++以及其他许多低级语言所使用的内存模型相匹配,当你需要对内存进行明确的推理时,这个模型很有用。
注意:在这个例子中,我们忽略了 CPU 寄存器,并将其视为一种优化。在现实中,如果一个变量不需要内存地址,编译器可能会使用一个寄存器来支持该变量,而不是一个内存区域。
你可能会发现其中一个比另一个更适合你之前的模型,但我建议你试着仔细理解这两个模型。它们都是同样有效的,而且都是简化的,就像任何有用的心智模型一样。如果您能够从这两个角度考虑一段代码,您就会发现处理复杂的代码段并理解它们为什么要或不按照预期进行编译和工作要容易得多。
内存区域
现在你已经掌握了我们对内存的称呼,我们需要谈谈内存到底是什么。内存有许多不同的区域,也许令人惊讶的是,并不是所有的区域都存储在你的计算机的 DRAM 中。你使用哪一部分内存,对你如何编写代码有很大影响。就编写 Rust 代码而言,三个最重要的区域是栈、堆和静态内存。
栈
栈是一段内存,程序使用它作为函数调用的临时空间。每次调用一个函数时,都会在栈的顶部分配一个称为帧的连续内存块。靠近栈底部的是主函数的帧,当函数调用其他函数时,额外的帧被推送到栈中。函数的帧包含该函数中的所有变量,以及该函数接受的任何参数。当函数返回时,它的栈帧被回收。
构成函数局部变量值的字节不会立即被清除,但访问它们是不安全的,因为它们可能被随后的函数调用覆盖,而该函数调用的帧与回收的帧重叠。即使它们没有被覆盖,它们也可能包含非法使用的值,例如在函数返回时被移动的值。
栈帧,以及它们最终消失的关键事实,与 Rust 中的生存期概念密切相关。任何存储在栈上的帧中的变量在该帧消失后都不能被访问,所以任何对它的引用都必须有一个最长与帧生存期一样长的生存期。
堆
堆是一个内存池,它没有绑定到程序的当前调用栈。堆内存中的值会一直存在,直到它们被显式地回收。当您希望一个值存在超过当前函数帧的生存期时,这是很有用的。如果该值是函数的返回值,则调用函数可以在其栈上留下一些空间,以便被调用函数在返回之前将该值写入其中。但是,如果你想,比如说,发送这个值到一个不同的线程,而当前线程可能根本不共享栈帧,你可以把它存储在堆上。
堆允许您显式地分配连续的内存段。当你这样做的时候,你会得到一个指向该内存段开始的指针。这个内存段是为你保留的,直到你以后释放它;这个过程通常被称为释放,以 C 标准库中相应函数的名称命名。由于函数返回时 heap 的分配不会消失,所以您可以在一个位置为一个值分配内存,将指向它的指针传递给另一个线程,并让该线程安全地继续操作该值。或者,换句话说,当你堆分配内存时,结果指针有一个不受约束的生存期——它的生存期是你的程序保持它存活的时间。
在 Rust 中与堆交互的主要机制是 Box 类型。当您写入 Box::new(value) 时,该值被放在堆上,而返回给您的 (Box<T>) 是一个指向堆上该值的指针。当 Box 最终被丢弃时,内存将被释放。
如果你忘记删除堆内存,它将永远存在,你的应用程序最终会吃掉你机器上的所有内存。这被称为泄漏内存,通常是你想要避免的。然而,在有些情况下,你会明确地想要泄漏内存。例如,假设你有一个只读的配置,整个程序都应该能够访问。你可以在堆上分配这个配置,然后用 Box::leak 显式地泄露它,以获得一个 "静态引用"。
静态内存
静态内存实际上是一个包揽一切的术语,指的是程序被编译成的文件中几个密切相关的区域。当程序执行时,这些区域会自动加载到程序的内存中。静态内存中的值在程序执行的整个过程中都是有效的。你的程序的静态内存包含程序的二进制代码,它通常被映射为只读。当您的程序执行时,它将遍历文本段指令中的二进制代码,并在调用函数时跳转。静态内存还保存了使用static 关键字声明的变量的内存,以及代码中的某些常量值,比如字符串。
特殊的生存期 'static,它的名称来自静态内存区域,标志着一个引用在静态内存存在的时间内是有效的,也就是直到程序关闭。因为静态变量的内存是在程序启动时分配的,所以对静态内存中变量的引用定义为 'static,因为在程序关闭之前它不会被释放。反之则不然,可能会有不指向静态内存的 'static 引用,但这个名字仍然是合适的:一旦你创建了一个具有静态寿命的引用,就程序的其他部分而言,它所指向的东西可能就在静态内存中,因为它可以被使用多长时间,你的程序就会使用多长时间。
在使用 Rust 时,您将更经常地遇到 'static生存期,而不是真正的静态内存(例如,通过 static 关键字)。这是因为 static 经常出现在类型参数的 trait 限定中。像 T: 'static 这样的绑定表示类型参数 T 能够存活,我们就保留它多久,包括程序的剩余执行时间。本质上,这个限定要求 T 是拥有(owned)的和自给自足(self-sufficient)的,要么它不借用其他(非静态)值,要么它借用的任何东西也是 'static,因此会一直保留到程序结束。'static 作为限定的一个很好的例子是 std::thread::spawn 函数,它创建了一个新的线程,它要求传递给它的闭包是 'static 。由于新线程的生存期可能比当前线程长,因此新线程不能引用存储在旧线程堆栈上的任何内容。新线程只能引用在其整个生存期内(可能是在程序的剩余时间内)存在的值。
注意:您可能想知道
const与static有何不同。const关键字将下面的项声明为常量。可以在编译时完全计算常数项,任何引用它们的代码都将在编译期间替换为常数的计算值。常量没有与之关联的内存或其他存储(它不是一个位置)。您可以将常量看作是特定值的一个方便名称。
所有权
Rust 的内存模型的核心思想是,所有值都有一个所有者,也就是说,只有一个位置(通常是一个作用域)负责最终回收每个值。这是通过借用检查器强制执行的。如果值被移动,例如将其赋值给一个新变量、将其推入一个向量或将其放在堆上,则值的所有权将从旧位置移动到新位置。在这一点上,您不能再通过来自原始所有者的变量访问值,即使从技术上来说,组成值的位仍然存在。相反,您必须通过引用其新位置的变量来访问被移动的值。
有些类型是反叛者,不遵守这条规则。如果一个值的类型实现了特殊的 Copy 特性,即使它被重新分配到一个新的内存位置,它也不会被认为已经移动了。相反,该值被复制,新旧位置仍然可访问。从本质上说,在移动的目的地构造了另一个相同值的相同实例。Rust 中大多数基本类型(如整数和浮点类型)都是 Copy 类型。要成为 Copy 类型,必须能够简单地通过复制其比特来复制该类型的值。这排除了所有包含非 Copy 类型的类型,以及在值被删除时它必须释放的拥有资源的任何类型。
要知道为什么,请考虑一下如果像 Box 这样的类型被复制会发生什么。如果我们执行 box2 = box1,那么 box1 和 box2 都会认为他们拥有为 box 分配的堆内存,当他们超出范围时,他们都会试图释放它。释放两次内存可能会产生灾难性的后果。
当一个值的所有者不再使用它时,该所有者有责任通过删除该值来对该值进行任何必要的清理。在 Rust 中,当保存值的变量不再在作用域中时,删除将自动发生。类型通常递归地删除它们所包含的值,因此删除复杂类型的变量可能会导致许多值被删除。由于 Rust 的离散所有权要求,我们不能意外地多次丢弃相同的价值。保存对另一个值的引用的变量并不拥有另一个值,因此当变量删除时,该值不会被删除。
清单 1-3 中的代码给出了围绕所有权、移动和复制语义以及放弃的规则的快速总结。
#![allow(unused)] fn main() { let x1 = 42; let y1 = Box::new(84); { // 开始一个新的作用域 let z = (x1, y1); // (1) // z 离开作用域,并被析构; // 它一次析构了 x1 和 y1 中的值 } // (2) // x1 的值是 Copy 语义, 所以它不会移动给 z let x2 = x1; // (3) // y1 的值不是 Copy 语义,所以它会移动给 z // let y2 = y1; // (4) // 清单 1-3: 移动和复制语义 }
我们从两个值开始,数字 42 和包含数字 84 的 Box(一个堆分配的值)。前者是复制,而后者不是。当我们将 x1 和 y1 放入元组 z1 时,x1 被复制到 z 中,而 y1 被移动到 z 中。此时,x1 继续可访问,并可以再次使用 (3) 。另一方面,一旦 y1 的值被移动到 (4),它就变得不可访问,任何访问它的尝试都将导致编译器错误。当 z 超出范围 (2) 时,它所包含的元组值将被删除,这将依次删除从 x1 复制的值和从 y1 移动的值。当 y1 中的 Box 被丢弃时,它还释放用于存储 y1 值的堆内存。
析构顺序
当值超出作用域时,Rust 会自动丢弃它们,比如清单 1-3 中内部作用域的 x1 和 y1。丢弃顺序的规则相当简单:变量(包括函数参数)按相反的顺序丢弃,嵌套值按源代码的顺序丢弃。
这听起来可能很奇怪,为什么会有这样的差异?不过,如果我们仔细观察,就会发现它有很大的意义。假设你写了一个函数,声明了一个字符串,然后将该字符串的引用插入到一个新的哈希表中。当函数返回时,哈希表必须先被删除;如果字符串先被删除,那么哈希表就会持有一个无效的引用 一般来说,后来的变量可能包含对早期值的引用,而由于 Rust 的生存期规则,反之则不能发生。出于这个原因,Rust 以相反的顺序丢弃变量。
现在,我们可以对嵌套的值有同样的行为,比如元组、数组或结构中的值,但这可能会让用户感到惊讶。如果你构建了一个包含两个值的数组,如果数组的最后一个元素先被丢弃,那就显得很奇怪。这同样适用于元组和结构,最直观的行为是第一个元组元素或字段先被丢弃,然后是第二个,以此类推。与变量不同的是,在这种情况下没有必要颠倒丢弃顺序,因为 Rust(目前)不允许在单个值中进行自我引用。所以,Rust 采用了直观的选项。
借用和生存期
Rust 允许一个值的所有者通过引用将该值借给其他人,而不放弃所有权。引用是一个指针,它有一个额外的契约,规定了它们的使用方式,比如引用是否提供了对被引用值的唯一访问,或者被引用值是否也可以有其他引用指向它。
共享引用
顾名思义,是一个可以被共享的指针。对于相同的值,可以存在任意数量的其他引用,并且每个共享引用都是 Copy,因此您可以轻松地创建更多的共享引用。共享引用背后的值不是可变的;您不能修改或重新分配共享引用指向的值,也不能将共享引用强制转换为可变引用。
Rust 编译器允许假设共享引用指向的值在引用存在期间不会改变。例如,如果 Rust 编译器看到共享引用后面的值在函数中被多次读取,那么它有权只读取一次并重用该值。更具体地说,清单 1-4 中的断言应该永远不会失败。
#![allow(unused)] fn main() { fn cache(input: &i32, sum: &mut i32) { *sum = *input + *input; assert_eq!(*sum, 2 * *input); } // 清单 1-4: Rust 假设共享引用是不可变的。 }
编译器是否选择应用给定的优化或多或少是无关的。编译器的启发式会随着时间的推移而改变,所以你通常想要根据编译器被允许做什么来编写代码,而不是根据它在特定的情况下在特定的时间点实际做了什么。
可变引用
共享引用的替代方案是可变引用:&mut T。对于可变引用,Rust 编译器又被允许充分利用引用所带来的契约:编译器假设没有其他线程访问目标值,无论是通过共享引用还是可变引用。换句话说,它假定可变引用是独占的。这使得一些有趣的优化成为可能,这些优化在其他语言中是不容易实现的。以清单 1-5 中的代码为例。
#![allow(unused)] fn main() { fn noalias(input: &i32, output: &mut i32) { if *input == 1 { *output = 2; // (1) } if *input != 1 { // (2) *output = 3; } } // 清单 1-5: Rust 假设可变借用是独占的 }
在 Rust 中,编译器可以假设输入和输出不指向同一内存。因此,(1) 处输出的重新分配不能影响 (2) 处的检查,整个函数可以被编译为一个单一的 if-else 块。如果编译器不能依赖排他性可变性契约,那么这种优化就会失效,因为在 noalias(&x, &mut x) 这样的情况下,(1) 的输入可能导致 (3) 的输出。
一个可改变的引用只允许你改变该引用所指向的内存位置。你是否可以改变直接引用之外的值,取决于位于两者之间的类型所提供的方法。用一个例子可能更容易理解,所以考虑清单 1-6。
#![allow(unused)] fn main() { let x = 42; let mut y = &x; // y &i32 类型 let z = &mut y; // z 是 &mut &i32 类型 // 清单 2-6: 可变性只适用于直接引用的内存 }
在这个例子中,你能够通过使指针 y 引用不同的变量来改变它的值(也就是不同的指针),但你不能改变被指向的值(也就是 x 的值)。同样地,你可以通过 z 来改变 y 的指针值,但你不能改变 z 本身,使其持有不同的引用。
拥有一个值和拥有一个对它的可变引用之间的主要区别是,当不再需要这个值时,所有者要负责丢弃这个值。除此之外,你可以通过一个可改变的引用做任何事情,如果你拥有这个值的话,有一个注意事项:如果你把这个值移到可改变的引用后面,那么你必须在它的位置上留下另一个值。如果你不这样做,所有者仍然会认为它需要放弃这个值,但是没有任何值可以让它放弃。
清单 1-7 给出了将值移动到可变引用后面的方法示例。
#![allow(unused)] fn main() { fn replace_with_84(s: &mut Box<i32>) { // 这是不可能的,因为 *s 会变成空值 : // let was = *s; // (1) // 但是这可以: let was = std::mem::take(s); // (2) // 这也可以: *s = was; // (3) // 可以在 &mut 后面交换值: let mut r = Box::new(84); std::mem::swap(s, &mut r); // (4) assert_ne!(*r, 84); } let mut s = Box::new(42); replace_with_84(&mut s); // (5) // 清单 2-7:可变性仅适用于直接引用的内存。 }
你不能简单地将值移出 1,因为调用者仍然认为他们拥有该值,并会在 5 处再次释放它,导致双重释放。如果你只是想留下一些有效的值,std::mem::take (2) 是一个不错的选择。它相当于 std::mem::replace(&mut value, Default::default());它将值从可变引用后面移出,但为该类型留下一个新的、默认的值。默认值是一个单独的、自有的值,所以当作用域在 5 处结束时,调用者可以安全地析构它。
另外,如果你不需要引用后面的旧值,你可以用一个你已经拥有的值覆盖它 (3),让调用者以后再丢弃这个值。当你这样做的时候,原来在可变引用后面的值会被立即丢弃。
最后,如果你有两个可变的引用,你可以在不拥有任何一个引用的情况下交换它们的值 (4),因为两个引用最后都会有一个合法拥有的值,供它们的主人最终释放。
内部可变性
有些类型提供内部可变性,这意味着它们允许您通过共享引用改变值。这些类型通常依赖于额外的机制(如原子 CPU 指令)或不变量来提供安全的可变性,而不依赖于独占引用的语义。它们通常分为两类:一类允许您通过共享引用获得可变引用,另一类允许您替换仅给定共享引用的值。
第一类包括 Mutex 和 RefCell 这样的类型,它们包含安全机制,以确保对于它们提供的任何可变引用,同一时刻只能存在一个可变引用(没有共享引用)。在本质上,这些类型(以及类似的类型)都依赖于一个名为 UnsafeCell 的类型,该类型的名称会立即让您犹豫是否使用它。我们将在第 9 章中更详细地介绍 UnsafeCell,但现在你应该知道,这是通过共享引用进行变异的唯一正确方法。
提供内部可变性的其他类型是那些不给出内部值的可变引用,而只是提供适当操作该值的方法的类型。std::sync::atomic 和 std::cell::cell 类型中的原子整数就属于这一类。您不能直接获取此类类型后面的 usize 或 i32 的引用,但是可以在给定的时间点读取和替换它的值。
注意:标准库中的 Cell 类型是通过不变量实现安全内部可变性的一个有趣的例子。它不能跨线程共享,并且永远不会给出对 Cell 中包含的值的引用。相反,这些方法要么完全替换该值,要么返回所包含值的副本。由于内部值不能存在任何引用,所以移动它总是可以的。而且,由于 Cell 不能在线程之间共享,因此即使通过共享引用发生突变,内部值也永远不会并发地发生突变。
生存期
如果您正在阅读这本书,您可能已经熟悉了生存期的概念,这可能是由于编译器对生存期规则违反的反复通知。这种程度的理解将为您编写的大多数 Rust 代码提供良好的服务,但是随着我们深入研究 Rust 更复杂的部分,您将需要一个更严格的心智模型来工作。
较新的 Rust 开发人员经常被教导将生存期与作用域相对应:生存期开始于对某个变量的引用,结束于该变量被移动或超出作用域。这通常是正确的,通常也是有用的,但实际情况要复杂一些。生存期实际上是某个引用必须有效的代码区域的名称。虽然生存期经常与作用域相一致,但这并不是必须的,正如我们将在本节后面看到的那样。
生存期和借用检查器
Rust 生存期的核心是借用检查器。每当一个具有某种生存期的引用被使用时,借用检查器就会检查 'a 是否仍然活着。它通过追踪路径回到 'a 开始的地方--引用被取走的地方--从使用点开始,并检查该路径上是否有冲突的使用。这可以确保引用仍然指向一个可以安全访问的值。这类似于我们在本章前面讨论的高级 "数据流 " 心智模型;编译器检查我们正在访问的引用的流不会与任何其他并行流相冲突。
清单 1-8 显示了一个简单的代码例子,其中有对 x 的引用的生存期注释。
#![allow(unused)] fn main() { let mut x = Box::new(42); let r = &x; // (1) // 'a if rand() > 0.5 { *x = 84; // (2) } else { println!("{}", r); // (3) // 'a } // (4) // 清单 1-8:生存期不需要是连续的 }
当我们对 x 进行引用时,生存期从 (1) 开始。在第一个分支 (2) 中,我们立即尝试修改 x,将其值更改为 84,这需要一个 &mut x。借用检查器取出 x 的可变引用并立即检查其使用情况。它发现在获取引用和使用引用之间没有冲突,所以它接受代码。这是个令人惊讶的消息如果你习惯于思考生存期范围,因为 r 仍在范围 (2)(超出范围在 (4))。但是借用检查器足够聪明,它意识到如果这个分支被选中,以后就不会再使用 r,因此 x 在这里被可变访问是没有问题的。或者,换一种说法,在 (1) 处创建的生存期不会扩展到这个分支:没有来自 r 超过 (2) 的流,因此没有冲突流。然后借用检查器在打印语句 (3) 中找到了对 r 的使用。它沿着路径返回到 (1),并发现没有冲突的用途 ((2) 不在该路径上),所以它也接受这种用途。
如果我们在清单 1-8 中在 4 处添加 r 的另一个使用,代码将不再编译。生存期 'a 将从 (1) 一直持续到 (4) (r 的最后一次使用),当借用检查器检查 r 的新的使用时,它会在 (2) 处发现一个冲突的使用。
生存期可以变得相当复杂。在清单 1-9 中,你可以看到一个有漏洞的生存期的例子,它在开始和最终结束的地方间歇性地失效了
#![allow(unused)] fn main() { let mut x = Box::new(42); let mut z = &x; // (1) // 'a for i in 0..100 { println!("{}", z); // (2) // 'a x = Box::new(i); // (3) z = &x; // (4) // 'a } println!("{}", z); // 'a // 清单 1-9: 生存期有漏洞 }
当我们对 x 进行引用时,生存期从 (1) 开始。然后我们在 (3) 处离开 x,这将结束生存期 'a,因为它不再有效。借用检查器通过考虑 'a 结束于 (2),这使得 x 和 (3) 之间没有冲突流”来接受这一移动。然后,通过更新 z (4) 中的引用,重新启动生存期。无论代码现在是循环回到 2 还是继续到最后的 println! 语句,这两个用途现在都有一个有效的值可以流出来,而且没有冲突的流,所以借用检查器接受了这段代码。
同样,这与我们之前讨论的内存的数据流模型完全一致。当 x 被移动时,z 停止存在。当我们稍后重新分配 z 时,我们创建了一个全新的变量,这个变量只从这一点开始存在。碰巧的是,这个新变量也被命名为 z。
注意:借用检查器是,而且必须是,保守的。如果它不确定一个借用是否有效,它就会拒绝它,因为允许一个无效的借用的后果可能是灾难性的。借用检查器越来越聪明,但有时它也需要帮助来理解为什么一个借用是合法的。这就是为什么我们有不安全的 Rust 的部分原因。
泛型生存期
偶尔你需要在自己的类型中存储引用,这些引用需要有一个生存期,这样当它们被用于该类型的各种方法时,借用检查器可以检查它们的有效性。如果你想让你的类型上的一个方法返回一个比对 self 的引用更久远的引用,这一点尤其重要。
Rust 允许您在一个或多个生存期内使类型定义泛型,就像它允许您使类型泛型一样。Steve Klabnik 和 Carol Nichols 合著的《Rust 编程语言》(No Starch Press, 2018) 详细介绍了这个主题,所以我在此不再赘述基本内容。但是,当您编写这种性质的更复杂类型时,您应该注意这些类型和生存期之间的交互有两个微妙之处。
首先,如果你的类型也实现了 Drop,那么丢弃你的类型也算作使用你的类型的任何生存期或类型的泛型。 基本上,当你的类型的一个实例被析构时,借用检查器将检查在析构它之前使用你的类型的任何泛型生存期是否仍然合法。这是必要的,以防你的析构代码确实使用了任何这些引用。如果你的类型没有实现 Drop,析构这个类型就不算是使用,用户只要不再使用你的类型,就可以自由地忽略存储在你的类型中的任何引用,就像我们在清单 1-7 中看到的那样。我们将在第 9 章中更多地讨论这些关于析构的规则。
其次,虽然一个类型可以存在多个泛型生存期,但经常这样做只会使你的类型特征变得不必要的复杂。通常情况下,一个类型只使用一个泛型生存期就可以了,编译器会将插入到你的类型中的任何引用的生存期中较短的一个作为这个生存期。只有当你有一个包含多个引用的类型,并且它的方法返回的引用应该只与其中一个引用的生存期相联系时,你才应该真正使用多个泛型生存期参数。
考虑清单 1-10 中的类型,它为您提供了一个迭代器,迭代器将遍历由特定的其他字符串分隔的字符串部分。
#![allow(unused)] fn main() { struct StrSplit<'s, 'p> { delimiter: &'p str, document: &'s str, } impl<'s, 'p> Iterator for StrSplit<'s, 'p> { type Output = &'s str; fn next(&self) -> Option<Self::Output> { todo!() } } fn str_before(s: &str, c: char) -> Option<&str> { StrSplit { document: s, delimiter: &c.to_string() }.next() } // 清单 1-10: 一个需要多个泛型生存期的类型 }
当你构造这个类型时,你必须给出 delimiter 和要搜索的 document ,这两个都是对字符串值的引用。 当你要求下一个字符串时,你会得到一个对 document 的引用。考虑一下如果你在这个类型中使用一个单一的生存期会发生什么。迭代器产生的值将与 document 的生存期和分隔符相联系。这将使 str_before 无法编写:返回类型将有一个与函数本地变量相关的生存期-- to_string 产生的 String--借用检查器将拒绝该代码。
生存期型变
型变 (Variance) 是程序员经常接触到的一个概念,但很少知道它的名字,因为它大多是看不见的。型变描述了什么类型是其他类型的子类型,以及什么时候可以用子类型代替父类型(反之亦然)。 广义上讲,如果一个类型 A 至少和 B 一样有用,那么它就是另一个类型 B 的子类型。在 Java 中,如果 Turtle 是 Animal 的子类型,你可以把 Turtle 传给接受 Animal 的函数,或者在 Rust 中,你可以把一个 &'static str 传给接受 &'a str 的函数,这就是型变。
虽然型变通常隐藏在视线之外,但它经常出现,我们需要对它有一个工作上的了解。乌龟是动物的一个子类型,因为乌龟比某些未指定的动物更 "有用"--乌龟可以做任何动物能做的事,而且可能更多。同样,'static 是 'a 的一个子类型,因为 'static 的寿命至少与任何 'a 一样长,所以更有用。或者,更一般地说,如果 'b:'a('b 比 'a 长寿),那么 'b 就是 'a 的一个子类型。这显然不是正式的定义,但是它已经足够接近实际用途了。
所有类型都有一个型变,它定义了哪些其他类似的类型可以用于该类型的位置。有三种型变:协变(covariant)、不变(invariant)和逆变(contravariant)。如果你可以只使用一个子类型来代替该类型,那么该类型就是协变的。例如,如果一个变量是 &'a T 类型,你可以给它提供一个 &'static T 类型的值,因为 &'a T 在 'a 上是协变的。&'a T 在 T 上也是协变的,所以你可以把一个 &Vec<&'static str> 传递给一个接受 &Vec<&'a str> 的函数。
有些类型是不变的,这意味着你必须准确提供给定的类型。&mut T 就是一个例子--如果一个函数接受一个 &mut Vec<&'a str>,你不能把一个 &mut Vec<&'static str> 传给它。也就是说,&mut T 在 T 上是不变的。如果你可以,函数可以在 Vec 中放入一个短暂的字符串,然后调用者会继续使用它,认为它是一个 Vec<&'static str>,从而认为包含的字符串是 'static !任何提供可变性的类型一般都是不变的,原因也是如此--例如,Cell<T> 在 T 上是不变的。
最后一类,逆变,出现在函数参数上。如果函数类型可以接受其参数不那么有用,那么它们就会更有用。如果你将参数类型本身的型变与它们作为函数参数时的型变进行对比,这一点就更清楚了:
#![allow(unused)] fn main() { let x: &'static str; // 更有用,活的更长 let x: &'a str; // 不太有用,活得更短 fn take_func1(&'static str) // 更严格,所以不那么有用 fn take_func2(&'a str) // 不太严格,所以更有用 }
这种翻转的关系表明,Fn(T) 在 T上是逆变的。
那么,当涉及到生存期时,为什么需要学习型变呢?当您考虑通用生存期参数如何与借用检查器交互时,型变就变得很重要了。考虑清单 1-11 所示的类型,它在一个字段中使用多个生存期。
#![allow(unused)] fn main() { struct MutStr<'a, 'b> { s: &'a mut &'b str } let mut s = "hello"; *MutStr { s: &mut s }.s = "world"; // (1) println!("{}", s); // 清单 1-11: 需要多个泛型生存期的类型 }
乍一看,在这里使用两个生存期似乎是不必要的--我们没有需要区分结构中不同部分的借用的方法,就像我们在清单 1-10 中的 StrSplit 那样。 但是如果你把这里的两个生存期换成一个 'a,代码就不再能被编译了!这就是为什么我们在这里使用了两个生命周期。而这一切都是因为型变。
注意:(1) 处的语法可能看起来很奇怪。它相当于定义了一个持有
MutStr的变量x,然后写*x.s = "world",只是没有变量,所以MutStr被立即删除了。
在 (1) 处,编译器必须确定生存期参数应该被设置为什么生存期。如果有两个生命期,'a 被设置为有待确定的 s 的借用生存期,'b 被设置为 'static,因为那是提供的字符串 "hello" 的生命期。如果只有一个生命周期 'a ,编译器推断该生命周期必须是 'static。
当我们后来试图通过共享引用访问字符串引用 s 来打印它时,编译器试图缩短 MutStr 使用的 s 的可变借用,以允许 s 的共享借用。
在双生存期的情况下,'a 只是在 println! 之前结束,'b 保持不变。另一方面,在单生存期的情况下,我们遇到了问题。编译器想缩短 s 的借用,但要做到这一点,它也必须缩短 str 的借用。虽然 &'static str 一般来说可以缩短为任何 &'a str ( &'a T 在 'a 中是协变的),但这里它在 &mut T 后面,而 &mut T 在 T 中是不变量的。不变要求相关类型永远不会被子类型或父类型取代,所以编译器缩短借用的尝试失败了,它报告说该清单仍然是可变的借用。哎哟!
由于型变带来的灵活性的降低,你想确保你的类型在尽可能多的泛型参数上保持协变(或在适当情况下保持逆变)。如果这需要引入额外的生命期参数,你需要仔细权衡增加一个参数的认知成本和型变的人机工程成本。
总结
本章的目的是建立一个坚实的、共享的基础,我们可以在接下来的章节中建立这个基础。到现在,我希望你觉得你已经牢牢掌握了 Rust 的内存和所有权模型,那些你可能从借用检查器中得到的错误似乎不那么神秘了。你可能已经知道了我们在这里所涉及的零星内容,但希望这一章能给你一个更全面的印象,让你知道这一切是如何结合起来的。在下一章中,我们将为类型做一些类似的事情。我们将讨论类型是如何在内存中表示的,看看泛型和特质(trait)是如何产生运行代码的,并看看 Rust 为更高级的用例提供的一些特殊类型和特质结构。
第二章 类型
现在基础知识已经讲完了,我们来看看 Rust 的类型系统。我们将跳过《Rust 编程语言》中涉及的基础知识,转而深入研究不同类型在内存中的布局、特质和特质约束的来龙去脉、存在性类型以及跨箱体边界使用类型的规则。
内存中的类型
每个 Rust 值都有一个类型。在 Rust 中,类型有很多作用,我们将在本章中看到,但它们最基本的作用之一是告诉你如何解释内存的位。例如,0b10111101(用十六进制符号写成 0xBD)的比特序列本身并不意味着什么,直到你给它指定一个类型。 当解释为 u8 类型时,这个比特序列就是数字 189。当在 i8 类型下解释时,它是-67。当你定义自己的类型时,编译器的工作是确定定义类型的每个部分在该类型的内存表示中的位置。你的结构体的每个字段在比特序列中出现在哪里?你的枚举的判别式存储在哪里?当你开始编写更高级的 Rust 代码时,了解这个过程是很重要的,因为这些细节会影响你的代码的正确性和性能。
对齐
在我们讨论如何确定一个类型的内存表示之前,我们首先需要讨论对齐的概念,它决定了一个类型的字节可以存储在哪里。一旦一个类型的表示被确定,你可能会认为你可以在任何一个任意的内存位置,把存储在那里的字节解释为该类型。虽然在理论上是这样的,但在实践中,硬件也限制了一个特定类型的位置。这方面最明显的例子是,指针指向字节 (bytes),而不是比特 (bits)。如果你把一个 T 类型的值放在计算机内存的第 4 位开始,你将没有办法参考它的位置;你可以创建一个指针,只指向字节 0 或字节 1(第 8 位)。由于这个原因,所有的值,无论其类型如何,都必须从一个字节边界开始。我们说,所有的值都必须至少是字节对齐的--它们必须被放在一个 8 位 (bits) 的倍数的地址上。
有些数值有更严格的对齐规则,而不仅仅是按字节对齐。在 CPU 和内存系统中,内存经常以大于一个字节的块进行访问。例如,在一个 64 位的 CPU 上,大多数数值都是以 8 个字节(64 位)为单位进行访问的,每个操作都是从一个 8 字节对齐的地址开始。这被称为 CPU 的字大小。然后,CPU 使用一些聪明的方法来处理较小的值,或跨越这些块边界的值的读写。
在可能的情况下,你要确保硬件能够以其 "原生" 对齐方式运行。要知道为什么,考虑一下如果你试图读取一个从 8 字节块中间开始的 i64(也就是说,它的指针不是 8 字节对齐的)会发生什么。硬件将不得不进行两次读取--一次是从第一块的后半部分读取 i64 的开始,另一次是从第二块的前半部分读取 i64 的其余部分,然后将结果拼接在一起。这不是很有效率。由于该操作分散在对底层内存的多次访问中,如果你正在读取的内存被不同的线程同时写入,你也可能会得到奇怪的结果。你可能会在其他线程的写操作发生之前读到前 4 个字节,而在之后读到后 4 个字节,从而导致一个损坏的值。
对未对齐的数据进行的操作被称为错位访问,会导致糟糕的性能和糟糕的并发性问题。出于这个原因,许多 CPU 操作要求或强烈希望其参数是自然对齐的。一个自然对齐的值是指其对齐方式与它的大小相匹配。因此,例如,对于一个 8 字节的负载,提供的地址需要 8 字节对齐。
由于对齐的访问通常更快,并提供更强的一致性语义,编译器试图尽可能地利用它们。它通过给每个类型一个基于它所包含的类型计算出来的对齐方式来实现这一点。内置值通常是根据其大小来对齐的,所以 u8 是字节对齐的,u16 是 2 字节对齐的,u32 是 4 字节对齐的,u64 是 8 字节对齐的。复杂类型--包含其他类型的类型--通常被分配为它们所包含的任何类型的最大对齐方式。例如,一个包含 u8、u16 和 u32 的类型会因为 u32 而被 4 字节对齐。
布局
既然您了解了对齐,我们就可以研究编译器如何决定类型的内存表示,即布局。默认情况下,您很快就会看到,Rust 编译器对如何布局类型提供了很少的保证,这是理解基本原理的一个很差的起点。幸运的是,Rust 提供了一个 repr 属性,您可以将其添加到类型定义中,以请求该类型在内存中的特定表示。最常见的是 repr(C)。顾名思义,它布局类型的方式与 C 或 C++ 编译器布局相同类型的方式兼容。这对于编写使用外部函数接口(我们将在第 11 章中讨论)与其他语言进行接口的 Rust 代码很有帮助,因为 Rust 将生成与其他语言编译器的期望相匹配的布局。由于 C 布局是可预测的,不受更改的影响,因此 repr(C) 在不安全的上下文中也很有用,如果您正在使用到类型的原始指针,或者您需要在两个具有相同字段的不同类型之间进行强制转换。当然,这对于我们开始布局算法的第一步来说是完美的。
注意:另一个有用的表示是 repr(transparent),它只能用于具有单个字段的类型,它保证了外部类型的布局与内部类型的布局完全相同。这在与 newtype 模式结合使用时很方便,在 newtype 模式中,您可能想对某个结构 A 和结构 NewA(A) 的内存表示进行操作,就好像它们是相同的一样。如果没有 repr(transparent),Rust 编译器就不能保证它们拥有相同的布局。
那么,让我们看看编译器会如何用 repr(C) 来布局一个特定的类型:清单 2-1 中的 Foo 类型。你认为编译器会如何在内存中布局这个类型?
#![allow(unused)] fn main() { #[repr(C)] struct Foo { tiny: bool, normal: u32, small: u8, long: u64, short: u16, } // 清单 2-1: 对齐影响布局。 }
首先,编译器会看到 tiny 字段,它的逻辑大小是 1 位 (true 或 false)。但由于 CPU 和内存以字节为单位进行操作,因此在内存表示中,tiny 是 1 个字节。接下来,normal 是一个 4 字节类型,所以我们希望它是 4 字节对齐的。但是即使 Foo 是对齐的,我们分配给 tiny 的 1 个字节将会使 normal 错过它的对齐。为了纠正这个问题,编译器插入 3 个字节的填充字节,这些字节的不确定值在用户代码中被忽略,并插入到内存中 tiny 和 normal 之间的表示中。没有值进入填充,但它确实占用空间。
对于下一个字段 small,对齐方式很简单:它是一个 1 字节的值,当前在结构中的字节偏移量是 1+3+4=8。 这已经是字节对齐的,所以 small 可以紧随普通字段。但对于 long,我们又遇到了一个问题。我们现在是 1+3+4+1=9 字节进入 Foo。如果 Foo 是对齐的,那么 long 就不是我们想要的 8 字节对齐,所以我们必须再插入 7 字节的填充来使 long 再次对齐。这也方便了我们确保最后一个字段 short 所需的 2 字节对齐,使总数达到 26 字节。现在我们已经浏览了所有的字段,我们还需要确定 Foo 本身的对齐方式。这里的规则是使用 Foo 任何一个字段的最大对齐方式,由于 long 的原因,它将是 8 字节。因此,为了确保 Foo 在放入数组时保持对齐,编译器添加了最后 6 个字节的填充,使 Foo 的大小是其 32 字节对齐的倍数。
现在我们准备摆脱 C 语言的传统,考虑一下如果我们不使用清单 2-1 中的 repr(C),布局会发生什么变化。C 表示法的主要限制之一是它要求我们以原始结构定义中的相同顺序放置所有字段。默认的 Rust 表示法 repr(Rust) 消除了这一限制,以及其他一些较小的限制,例如对碰巧有相同字段的类型进行确定的字段排序。也就是说,在使用默认的 Rust 布局时,即使两个不同的类型共享所有相同的字段、相同的类型、相同的顺序,也不能保证它们的布局是一样的。
由于我们现在可以对字段进行重新排序,我们可以按照大小递减的顺序来放置它们。这意味着我们不再需要 Foo 字段之间的填充;字段本身被用来实现必要的对齐!Foo 现在只是其字段的大小:只有 16 个字节。这就是为什么 Rust 默认情况下不对一个类型在内存中的布局做很多保证的原因之一:通过给编译器更多的余地来重新排列,我们可以产生更有效的代码。
事实证明,还有第三种方法来布局一个类型,那就是告诉编译器,我们不希望在字段之间有任何填充。这样做,我们就表示我们愿意接受使用错位访问的性能冲击。最常见的使用情况是,当每一个额外的字节的内存的影响是可以感觉到的,比如你有很多类型的实例,如果你有非常有限的内存,或者如果你通过一个低带宽的媒介(如网络连接)发送内存中的表示。为了选择这种行为,你可以用 #[repr(packed)] 来注释你的类型。请记住,这可能会导致更慢的代码,在极端情况下,如果你试图执行 CPU 只支持对齐参数的操作,这可能会导致你的程序崩溃。
有时,你想给一个特定的字段或类型一个比它技术上要求的更大的对齐方式。你可以使用属性 #[repr(align(n))] 来做到这一点。这方面的一个常见的用例是确保在内存中连续存储的不同数值(比如在一个数组中)最终出现在 CPU 的不同缓存行中。这样,你就可以避免错误的共享,因为错误的共享会导致并发程序的巨大性能下降。当两个不同的 CPU 访问不同的值,而这些值恰好共享一个缓存线时,就会发生虚假共享;虽然理论上它们可以并行操作,但它们最终都会争相更新缓存中的同一个条目。我们将在第 10 章中更详细地讨论并发性问题。
复合类型
你可能对编译器如何在内存中表示其他 Rust 类型感到好奇。这里有一个快速参考:
元组 表示为与元组值相同类型的字段,顺序相同。 数组 表示为所含类型的连续序列,元素之间没有填充物。 联合 布局是为每个变体独立选择的。 对齐是所有变体的最大值。 枚举 与联合相同,但有一个额外的隐藏共享字段,存储枚举变体判别符。判别值是代码用来确定一个给定值持有哪一个枚举变体的值。判别字段的大小取决于变体的数量。
动态大小的类型和宽指针
你可能在 Rust 文档的各种奇怪的角落和错误信息中遇到过 Sized 这个标记性特质。通常情况下,它的出现是因为编译器希望你提供一个 Sized 的类型,但你(显然)没有。Rust 中的大多数类型都自动实现了 Sized,也就是说,它们有一个在编译时就知道的大小,但有两种常见的类型却没有:Trait 对象和切片 (slices)。例如,如果你有一个 dyn Iterator 或者一个 [u8],这些都没有一个明确的大小。它们的大小取决于一些只有在程序运行时才知道的信息,而不是在编译时,这就是为什么它们被称为动态大小的类型(DSTs)。没有人提前知道你的函数收到的 dyn Iterator 是这个 200 字节的结构还是那个 8 字节的结构。这就出现了一个问题:编译器往往必须知道某样东西的大小,以便产生有效的代码,例如为一个类型为(i32,dyn Iterator, [u8], i32)的元组分配多少空间,或者如果你的代码试图访问第四个字段,应该使用什么偏移。但是如果类型不是 Sized,这些信息就无法使用。
编译器几乎在任何地方都要求类型是大小适中的。结构字段、函数参数、返回值、变量类型和数组类型都必须是 Sized 的。这个限制是如此的普遍,以至于你写的每一个类型绑定都包括T:Sized,除非你明确地用 T:?Sized(? 的意思是 "可能不是")来选择不使用它。但如果你有一个 DST 并想用它做一些事情,比如你真的想让你的函数接受一个 trait 对象或一个 slice 作为参数,这就很无助了。
弥合非固定大小类型和固定大小类型之间的差距的方法是将非固定大小类型放在宽指针(也被称为胖指针)后面。一个宽指针就像一个正常的指针,但它包括一个额外的字大小的字段,它提供了编译器需要的关于该指针的额外信息,以生成合理的代码来处理该指针。当你获取一个对 DST 的引用时,编译器会自动为你构造一个宽指针。对于一个切片,额外的信息只是切片的长度。对于 trait 对象来说,我们稍后会讨论这个问题。最重要的是,这个宽指针是有尺寸的。具体来说,它是 usize(目标平台上一个字的大小)的两倍:一个 usize 用于保存指针,一个 usize 用于保存 "完成 "类型所需的额外信息。
注:Box 和 Arc 也支持存储宽指针,这就是为什么它们都支持
T:?Sized。
Trait 和 Trait 约束
特质是 Rust 类型系统的一个关键部分--它们是一种胶水,允许类型之间相互操作,尽管它们在定义时并不了解对方的情况。Rust 编程语言对如何定义和使用特质做了很好的介绍,所以我在这里就不做介绍了。取而代之的是,我们要看看 traits 的一些技术方面:它们是如何实现的,你必须遵守的限制,以及 traits 的一些更深奥的用途。
编译和分发
到目前为止,你可能已经在 Rust 中写了相当数量的泛型代码。你已经在类型和方法上使用了泛型类型参数,甚至可能在这里和那里使用了一些 trait 约束。但是你有没有想过,当你编译泛型代码时,它究竟会发生什么,或者当你在 dyn Trait 上调用一个 trait 方法时,会发生什么?
当你写一个在 T 上泛型的类型或函数时,你实际上是在告诉编译器为每个类型 T 制作一个该类型或函数的副本。当你构造一个 Vec<i32> 或 HashMap<String, bool> 时,编译器基本上是复制粘贴泛型类型和它的所有实现块,并将每个泛型参数的所有实例替换为你提供的具体类型。它制作了一个 Vec 类型的完整副本,每个 T 都被替换为 i32,而 HashMap 类型的完整副本,每个 K 都被替换为 String,每个 V 都被替换为 bool。
注意:在现实中,编译器实际上并没有进行完全的复制。它只复制你使用的部分代码,所以如果你从未在
Vec<i32>上调用find,find的代码就不会被复制和编译。
这一点也适用于泛型函数。考虑清单 2-2 中的代码,它显示了一个泛型方法。
#![allow(unused)] fn main() { impl String { pub fn contains(&self, p: impl Pattern) -> bool { p.is_contained_in(self) } } // 清单 2-2:使用静态分发的泛型方法 }
这个方法的副本是为每个不同的模式类型制作的(记得 impl Trait 是 <T: Trait> 的简写)。我们需要为每个 impl Pattern 类型提供一个不同的函数体副本,因为我们需要知道 is_contained_in 函数的地址来调用它。CPU 需要被告知跳转到哪里并继续执行。对于任何给定的模式,编译器知道该地址是该模式类型实现该 trait 方法的地方的地址。但是没有一个地址可以用于任何类型,所以我们需要为每个类型准备一个副本,每个副本都有自己的地址可以跳转。 这被称为静态分发,因为对于任何给定的方法副本,我们 "分发到" 的地址是静态已知的。
注意:你可能已经注意到,"静态" 这个词在这里有点超载。静态通常是指任何在编译时已知的东西,或者可以被当作已知的东西来对待,因为它可以被写入静态内存,正如我们在第 1 章中讨论的。
这个从一个泛型到许多非泛型的过程被称为单态化,这也是泛型 Rust 代码通常和非泛型代码表现一样好的部分原因。 当编译器开始优化你的代码时,就好像根本没有泛型的存在一样!每个实例都被单独优化,并使用所有已知的类型。每个实例都是单独优化的,并且所有的类型都是已知的。因此,代码的效率就像直接调用传入的模式的 is_contained_in 方法一样,没有任何特质存在。编译器对所涉及的类型有充分的了解,如果愿意,甚至可以内联 is_contained_in 的实现。
单态化也是有代价的:所有这些类型的实例化都需要单独编译,如果编译器不能将它们优化掉,就会增加编译时间。每个单态化的函数也会产生自己的机器代码块,这可能会使你的程序变得更大。而且,由于泛型方法的不同实例之间不共享指令,CPU 的指令缓存也是无效的,因为它现在需要保存有效相同指令的多个副本。
非泛型内部函数
通常情况下,泛型方法中的大部分代码是不依赖于类型的。例如,考虑一下
HashMap::insert的实现。计算所提供键的哈希值的代码取决于映射的键类型,但是遍历映射的桶以找到插入点的代码可能不需要。在这种情况下,跨单一化共享方法的非通用部分生成的机器码会更有效,并且只在实际需要的地方生成不同的副本。在这种情况下,你可以使用一种模式,即在执行共享操作的泛型方法中声明一个非泛型的辅助函数。这样,编译器就只留下与类型相关的代码来为你复制粘贴,同时允许辅助函数被共享。
把函数变成内部函数还有一个好处,就是你不会用一个单一目的的函数来污染你的模块。你可以在方法之外声明这样一个辅助函数;只是要注意不要让它成为泛型植入块下的方法,因为那样它仍然会被单态化。
静态分发的替代方法是动态分发,它使代码能够在不知道是什么类型的情况下调用一个泛型类型的 trait 方法。我在前面说过,我们需要清单 2-2 中方法的多个实例的原因是,否则你的程序就不知道要跳转到什么地址,以便在给定的模式上调用特质方法 is_contained_in。那么,通过动态分发,调用者会简单地告诉你。如果你用 &dyn Pattern 代替 impl Pattern,你就告诉调用者他们必须为这个参数提供两个信息:模式的地址和 is_contained_in 方法的地址。实际上,调用者给了我们一个指针,指向一个叫做虚拟方法表(vtable)的内存块,这个虚拟方法表保存了有关类型的所有 trait 方法的实现地址,其中一个就是 is_contained_in。当方法中的代码想要调用所提供的模式的特质方法时,它会在 vtable 中查找该模式的 is_contained_in 的实现地址,然后调用该地址的函数。这使得我们可以使用相同的函数体,无论调用者想使用什么类型。
注意:每个
vtable都包含了关于具体类型的布局和对齐方式的信息,因为这些信息在使用一个类型时总是需要的。如果你想看一个显式vtable的例子,看看std::task::RawWakerVTable类型。
你会注意到,当我们选择使用 dyn 关键字进行动态分发时,我们必须在它的前面放一个 &。原因是我们在编译时不再知道调用者传入的模式类型的大小,所以我们不知道要为它预留多少空间。换句话说,dyn Trait 是 !Sized,其中的 ! 表示不。为了使它有大小,以便我们可以把它作为一个参数,我们把它放在一个指针(我们知道它的大小)后面。由于我们还需要传递方法地址表,这个指针变成了一个广义的指针,其中额外的字是指向 vtable 的指针。你可以使用任何能够容纳宽指针的类型进行动态分发,比如 &mut、 Box 和 Arc。清单 2-3 显示了清单 2-2 的动态分发等价物。
#![allow(unused)] fn main() { impl String { pub fn contains(&self, p: &dyn Pattern) -> bool { p.is_contained_in(&*self) } } // 清单 2-3: 使用动态分发的泛型方法 }
实现特质的类型和其 vtable 的组合被称为特质对象。大多数特质可以变成特质对象,但不是全部。例如,Clone trait,其 clone 方法返回 自身,不能被转化为特质对象。如果我们接受一个 dyn Clone trait 对象,然后对它调用 clone,编译器将不知道要返回什么类型。或者,考虑一下标准库中的 Extend trait,它有一个方法 extend,在所提供的迭代器的类型上是通用的(所以它可能有很多实例)。如果你要调用一个接受动态 Extend 的方法,那么就没有一个单一的地址可以放在 trait 对象的 vtable 中;对于 extend 可能被调用的每种类型,都必须有一个条目。这些都是特性的例子,它们不是对象安全的,因此不能被变成特性对象。为了实现对象安全,特质的任何方法都不能是泛型的或使用Self 类型。此外,特质不能有任何静态方法(也就是说,其第一个参数不解引用到 Self),因为不可能知道要调用哪个方法的实例。例如,不清楚 FromIterator::from_iter(&[0]) 应该执行什么代码。
在阅读关于特质对象的文章时,你可能会看到提到特质绑定 Self: Sized。这样的约束意味着 Self 不会通过 trait 对象被使用(因为它将是 !Sized)。你可以把这个约束放在特质上,要求特质永远不使用动态分发,或者你可以把它放在一个特定的方法上,使该方法在通过特质对象访问特质时不可用。当检查一个特质是否是对象安全的时候,具有 where Self: Sized 绑定的方法被豁免。
动态分发可以缩短编译时间,因为它不再需要编译多个类型和方法的副本,而且可以提高 CPU 指令缓存的效率。然而,它也阻止了编译器对所使用的特定类型进行优化。有了动态分发,编译器对清单 2-2 中的 find 所能做的就是通过 vtable 插入对函数的调用--它不能再执行任何额外的优化,因为它不知道在这个函数调用的另一边会有什么代码。 此外,对 trait 对象的每个方法调用都需要在 vtable 中进行查找,这比直接调用方法增加了少量的开销。
当你要在静态分发和动态分发之间做出选择时,很少有明确的正确答案。不过,广义上讲,你想在你的库中使用静态分发,在你的二进制文件中使用动态分发。在库中,你想让你的用户来决定哪种分发最适合他们,因为你不知道他们的需求是什么。如果你使用动态分发,他们也会被迫这样做,而如果你使用静态分发,他们可以选择是否使用动态分发。另一方面,在二进制文件中,你正在编写最终的代码,所以除了你正在编写的代码的需求外,没有其他需求需要考虑。 动态分发通常允许你编写更干净的代码,省去泛型参数,并能更快地编译,所有这些都是以(通常)边际性能为代价,所以它通常是二进制文件的更好选择。
泛型 Traits
Rust 特质可以通过两种方式之一称为泛型:使用泛型类型参数,如 trait Foo<T>,或者使用关联类型,如 trait Foo { type Bar; }。这两者之间的区别并不明显,但幸运的是,经验法则非常简单:如果你希望一个给定的类型只有一个特质的实现,那么就使用关联类型,否则就使用通用类型参数。
这样做的理由是,关联类型通常更容易操作,但不允许多种实现。所以,更简单地说,我们的建议是,只要你能使用关联类型,就使用关联类型。
有了泛型特质,用户必须始终指定所有的泛型参数,并重复这些参数的任何约束。这很快就会变得混乱和难以维护。如果你给一个特质增加了一个泛型参数,该特质的所有用户也必须更新以反映这一变化。而且,由于一个特质的多个实现可能存在于一个给定的类型中,编译器可能很难确定你想使用特质的哪个实例,从而导致像 FromIterator::<u32>::from_iter 这样可怕的歧义函数调用。 但好处是,你可以为同一类型多次实现特质--例如,你可以针对你的类型的多个右手边类型实现 PartialEq,或者你可以同时实现 FromIterator<T>和 FromIterator<&T> where T: Clone,正是因为通用特质所提供的灵活性。
另一方面,对于关联类型,编译器只需要知道实现特质的类型,而所有的关联类型都是如此(因为只有一个实现)。这意味着约束可以全部存在于特质本身,不需要在使用时重复。反过来,这允许特质在不影响用户的情况下增加更多的关联类型。因为类型决定了特质的所有关联类型,所以你永远不需要用上一段中显示的统一函数调用语法来消除歧义。然而,你不能针对多个目标类型实现 Deref,也不能用多个不同的 Item 类型实现 Iterator。
连贯性和孤儿规则
Rust 有一些相当严格的规则,规定你可以在哪里实现特性,以及你可以在哪些类型上实现它们。这些规则的存在是为了保护一致性属性:对于任何给定的类型和方法,只有一个正确的选择,那就是对该类型使用该方法的实现。为了理解这一点的重要性,考虑一下如果我可以为标准库中的 bool 类型编写自己的 Display 特性的实现会发生什么。现在,对于任何试图打印一个 bool 值并包括我的 crate 的代码,编译器将不知道是选择我写的实现还是标准库的实现。这两种选择都不正确,也不比其他选择好,而且编译器显然不能随机选择。如果完全不涉及标准库,但我们有两个相互依赖的包 (crate),而且它们都实现了某个共享类型的特质,也会出现同样的问题。连贯性属性保证了编译器永远不会在这些情况下结束,也永远不需要做出这些选择:总会有一个明显的选择。
维护一致性的一个简单方法是确保只有定义特质的包 (crate) 可以编写该特质的实现;如果没有其他人可以实现该特质,那么其他地方就不能有冲突的实现。然而,这在实践中限制性太强,而且基本上会使特质失去作用,因为没有办法为你自己的类型实现诸如 std::fmt::Debug 和 serde::Serialize 这样的特质,除非你把自己的类型包含在定义框中。相反的极端做法是,你可以只为你自己的类型实现特性,这解决了这个问题,但却带来了另一个问题:一个定义了特性的包现在不能为标准库或其他流行的包中的类型提供该特性的实现 理想情况下,我们希望找到一些规则来平衡下游包为自己的类型实现上游特质的愿望和上游包能够在不破坏下游代码的情况下增加自己的特质实现的愿望。
注意:上游指的是你的代码所依赖的东西,下游指的是依赖你的代码的东西。 通常,这些术语是在 crate 依赖关系的直接意义上使用的,但它们也可以用来指代码的权威分叉--如果你做一个 Rust 编译器的分叉,官方 Rust 编译器就是你的 "上游"。
在 Rust 中,建立这种平衡的规则是孤儿规则。 简单地说,孤儿规则说你可以为一个类型实现一个特质,但该特质或类型必须是你的包的本地的。所以,你可以为你自己的类型实现 Debug,也可以为 bool 实现 MyNeatTrait,但你不能为 bool 实现 Debug。如果你尝试,你的代码将无法编译,而且编译器会告诉你有冲突的实现。
这让你走得很远;它允许你为第三方类型实现你自己的特质,并为你自己的类型实现第三方特质。然而,孤儿规则并不是故事的结束。它还有一些额外的影响、注意事项和例外情况,你应该注意一下。
覆盖实现(Blanket Implementations)
孤规则允许你在一系列类型上实现特质,代码如 impl<T> MyTrait for T where T:,等等。这是一个覆盖的实现,它不局限于一个特定的类型,而是适用于广泛的类型。只有定义了一个 trait 的包被允许编写一个覆盖实现,并且添加一个覆盖实现到一个已经存在的 trait 被认为是一个破坏性的改变。如果不是的话,下游包含 impl MyTrait for Foo 的包可能会突然停止编译,因为你更新了定义 MyTrait 的包,出现了一个关于冲突实现的错误。
基本类型
有些类型是如此重要,以至于有必要允许任何人在其上实现特性,即使这似乎违反了孤儿规则。这些类型被标记为 #[fundamental] 属性,目前包括 &, &mut, 和 Box。为了孤儿规则的目的,基本类型可能不存在--它们在孤儿规则被检查之前就被有效地删除了,以便允许你,例如,为 &MyType 实现 IntoIterator。如果只有孤儿规则,这个实现将不被允许,因为它为一个外来类型实现了一个外来特性-- IntoIterator 和 & 都来自标准库。在一个基本类型上添加一个覆盖的实现也被认为是一个破坏性的改变。
覆盖实现 (Covered Implementations)
在一些有限的情况下,我们想允许为一个外来类型实现一个外来特性,而孤儿规则通常不允许这样做。最简单的例子是当你想编写类似 impl From<MyType> for Vec<i32> 的东西。在这里,From 特质是外来的,Vec 类型也是,但没有违反一致性的危险。这是因为冲突的实现只能通过标准库中的覆盖实现来添加(标准库不能以其他方式命名 MyType),这无论如何是一个破坏性的改变。
为了允许这些类型的实现,孤规则包含了一个很小的豁免,允许在非常特定的一组环境下为外部类型实现外部特质。具体来说,给定 impl<P1..=Pn> ForeignTrait<T1..=Tn> for T0 是允许的,只有当至少一个 Ti 是本地类型,并且在第一个这样的 Ti 之前没有 T 是泛型类型 P1..=Pn。泛型类型参数 (Ps) 允许出现在 T0..Ti 只要它们被某种中间类型所覆盖。如果 T 作为其他类型的类型参数出现(比如 Vec<T>),则会覆盖它,但如果它独立存在(只是 T),或者只是出现在基本类型(比如 &T ) 后面,则不会覆盖它。因此,清单 2-4 中的所有实现都是有效的。
#![allow(unused)] fn main() { impl<T> From<T> for MyType impl<T> From<T> for MyType<T> impl<T> From<MyType> for Vec<T> impl<T> ForeignTrait<MyType, T> for Vec<T> // 清单 2-4: 外部类型的外部特质的有效实现 }
但是,清单 2-5 中的实现是无效的。
#![allow(unused)] fn main() { impl<T> ForeignTrait for T impl<T> From<T> for T impl<T> From<Vec<T>> for T impl<T> From<MyType<T>> for T impl<T> From<T> for Vec<T> impl<T> ForeignTrait<T, MyType> for Vec<T> // 清单 2-5: 外部类型的外部特质的无效实现 }
对孤儿规则的这种放宽使得当你为现有特质添加新的实现时构成破坏性改变的规则变得复杂。特别是,为现有特质添加新的实现,只有当它至少包含一个新的本地类型,并且这个新的本地类型满足前面描述的豁免规则时,才是非破坏性的。添加任何其他新的实现都是一种破坏性的改变。
注意:
impl<T> ForeignTrait<LocalType, T> for ForeignType是有效的,但是impl<T> ForeignTrait<T, LocalType> for ForeignType是无效的!这看起来很随意,但是如果没有这个规则,你可以为写impl<T> ForeignTrait<T, LocalType> for ForeignType,而另一个包可以写impl<T> ForeignTrait<TheirType, T> for ForeignType,只有当这两个包被放在一起时才会产生冲突。 孤儿规则没有完全禁止这种模式,而是要求你的本地类型在类型参数之前,这打破了联系,确保如果两个包在孤立情况下坚持一致性,它们在合并时也坚持一致性。
Trait 约束
标准库中有很多特质约束,无论是 HashMap 中的键必须实现 Hash + Eq,还是 Thread::Spawn 的函数必须是 FnOnce + Send + 'static。当你自己写泛型代码时,几乎肯定会包括特质约束,因为否则你的代码就不能对它所泛型的类型做什么。当你写出更复杂的泛型实现时,你会发现你也需要从你的特质约束中获得更多的保真度,所以让我们看看实现这一目的的一些方法。
首先,特质约束不一定是 T: Trait 的形式,其中 T 是你的实现或类型的泛型的一些类型。约束可以是任意的类型限制,甚至不需要包括泛型参数、参数类型或本地类型。你可以写一个 trait 约束,比如说 where String: Clone,尽管 String: Clone 总是真的,并且不包含本地类型。你也可以写 where io::Error: From<MyError<T>>;你的泛型类型参数不需要只出现在左手边。这不仅允许你表达更复杂的约束,而且可以使你避免不必要地重复约束。例如,如果你的方法要构造一个 HashMap<K, V, S>,它的键是一些泛型 T,它的值是一个 usize,与其把约束写成 where T: Hash + Eq, S: BuildHasher + Default,你可以写成 where HashMap<T, usize, S>: FromIterator。这样就省去了查找你最终使用的方法的确切约束要求,并且更清楚地传达了你的代码的真正要求。正如你所看到的,如果你想调用的底层特质方法的约束很复杂,它也能大大降低你的约束的复杂性。
DERIVE TRAIT 虽然
#[derive(Trait)]非常方便,但在 trait 约束的上下文中,你应该注意到它经常被实现的一个微妙之处。许多#[derive(Trait)]的扩展被分解为impl Trait for Foo<T> where T: Trait。这通常是你想要的,但并不总是如此。例如,考虑一下如果我们试图以这种方式为Foo<T>派生Clone,而Foo包含一个Arc<T>,会发生什么。不管T是否实现了Clone,Arc都实现了Clone,但是由于派生的约束,Foo只有在T实现了Clone时才会实现!这通常不是一个太大的问题,但是它确实在不需要的地方增加了一个约束。如果我们把这个类型重命名为Shared,问题可能会变得更清楚一些。想象一下,当编译器告诉他们不能克隆Shared<NotClone>时,拥有Shared<NotClone>的用户将是多么的困惑啊!在写这篇文章的时候,这就是标准库提供的#[derive(Clone)]的工作方式,尽管这在将来可能会改变。
有时候,你需要对泛型类型的关联类型进行约束。例如,考虑迭代器方法 flatten,它接受一个迭代器,该迭代器生成的项依次实现 iterator,并生成这些内部迭代器的项的迭代器。它产生的类型 Flatten 是 I 上的泛型,这是外部迭代器的类型。Flatten 实现 Iterator,如果我实现 Iterator,我自己产生的项目实现 IntoIterator。为了使您能够像这样编写约束,Rust 允许您使用 type::AssocType 语法引用类型的关联类型。例如,我们可以使用 I::Item 来引用 I 的 Item 类型。如果一个类型有多个同名的关联类型,比如提供关联类型的 trait 本身就是泛型(因此有很多实现),你可以用语法 <Type as Trait>::AssocType 来消除歧义。使用这个方法,你不仅可以为外层迭代器的类型写边界,还可以为该外层迭代器的项目类型写边界。
在广泛使用泛型的代码中,您可能会发现需要编写一个讨论对类型的引用的约束。这通常没有问题,因为您可能还会有一个泛型的生存期参数,可以将其用作这些引用的生存期。然而,在某些情况下,您希望约束说明此引用在任何生命周期实现此 trait。这种类型的约束被称为高阶生存期的 trait 约束,它在与 Fn 特质的联系中特别有用。例如,假设你想成为一个泛型函数,它接受一个对 T 的引用,并返回一个对 T 内部的引用,如果你写 F: Fn(&T) -> &U,您需要为这些引用提供一个生存期,但是您真正想说的是任何生存期,只要输出与输入相同。使用高阶的生存期,你可以写 F: for<'a> Fn(&'a T) -> &'a U 表示在任何生存期中,这个约束都必须成立。Rust 编译器足够聪明,当你用这样的引用编写 Fn 约束时,它会自动添加 for,这涵盖了此特性的大部分用例。在编写本文时,这种显式形式很少被需要,因此标准库只在三个地方使用它,但它确实存在,因此值得了解。
为了把所有这些结合起来,考虑清单 2-6 中的代码,它可以用来为任何可以被迭代的类型实现 Debug,并且其元素是 Debug。
#![allow(unused)] fn main() { impl Debug for AnyIterable where for<'a> &'a Self: IntoIterator, for<'a> <&'a Self as IntoIterator>::Item: Debug { fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { f.debug_list().entries(self).finish() } } // 清单 2-6:对任何可迭代的集合来说,Debug 的实现都过于通用。 }
你可以把这个实现复制粘贴到几乎所有的集合类型上,它就会 "正常工作"。当然,你可能想要一个更聪明的调试实现,但这很好地说明了特质约束的力量。
标记 Traits
通常,我们使用特质来表示多种类型可以支持的功能;Hash 类型可以通过调用 hash 进行哈希处理,Clone 类型可以通过调用 clone 进行克隆,Debug 类型可以通过调用 fmt 进行格式化。但并不是所有的特质都是这样起作用的。有些特质(称为标记特质)指示的是实现类型的属性。标记特质没有方法或相关的类型,只是用来告诉你某一特定类型可以或不能以某种方式使用。例如,如果一个类型实现了 Send 标记特质,那么跨线程边界发送是安全的。如果它没有实现这个标记符特质,发送它就不安全。没有与此行为相关的方法;这只是一个关于类型的事实。在 std::marker 模块中,标准库有许多这样的标记,包括 Send, Sync, Copy, Sized, 和 Unpin。其中大多数(除了Copy)也是自动特质;编译器会自动为类型实现它们,除非类型包含没有实现标记特质的内容。
标记特质在 Rust 中有一个重要的作用:它允许你写出捕获语义要求的约束,而这些要求在代码中没有直接表达。在代码中没有调用 send,要求一个类型是 Send。相反,代码假设给定的类型可以在一个单独的线程中使用,如果没有标记特质,编译器将没有办法检查这个假设。这就需要程序员记住这个假设并仔细阅读代码,我们都知道这不是我们想要依赖的东西。这条路充满了数据竞争、分离故障和其他运行时问题。
与标记特质类似的是标记类型。这些是单元类型(如 struct MyMarker;),不持有数据,也没有方法。标记类型对于,嗯,标记一个类型处于一个特定的状态很有用。当你想让用户不可能滥用一个 API 时,它们就会派上用场。例如,考虑一个像 SshConnection 这样的类型,它可能已经被验证,也可能还没有被验证。你可以给 SshConnection 添加一个通用类型参数,然后创建两个标记类型。未认证的和已认证的。当用户第一次连接时,他们得到 SshConnection<Unauthenticated>。在其 impl 块中,你只提供了一个方法:connect。connect 方法返回一个 SshConnection<Authenticated>,只有在这个 impl 块中,你才提供其余的方法来运行命令等。 我们将在第 3 章进一步研究这个模式。
存在类型 (Existential Types)
在 Rust 中,你很少需要指定你在函数主体中声明的变量的类型,或者你调用的方法的泛型参数的类型。这是因为类型推断的存在,编译器根据类型出现在代码中的评估结果决定使用什么类型。编译器通常只对变量和闭包的参数(和返回类型)进行类型推断;像函数、类型、特质和特质实现块这样的顶层定义都需要你明确命名所有的类型。 这有几个原因,但主要的原因是,当你至少有一些已知的点来开始推断时,类型推断就容易多了。然而,要完全命名一个类型并不总是容易的,甚至是不可能的。例如,如果你从一个函数中返回一个闭包,或者从一个特质方法中返回一个异步块,它的类型并没有一个你可以在代码中输入的名字。
为了处理这样的情况,Rust 支持存在性(existential)类型。 你可能已经看到了存在性类型的作用。所有标记为 async fn 或者返回类型为 impl Trait 的函数都有一个存在性的返回类型:签名中没有给出返回值的真实类型,只是提示函数返回的某个类型实现了调用者可以依赖的一组特性。更重要的是,调用者只能依赖实现这些特质的返回类型,而不能依赖其他。
注意:从技术上讲,调用者只依赖返回类型而不依赖其他,这并不是严格意义上的。编译器也会通过返回位置的
impl Trait来传播Send和Sync等自动特性。我们将在下一章中进一步研究这个问题。
这种行为就是存在性类型的名称:我们断言存在某种与签名相匹配的具体类型,而我们让编译器去寻找这种类型。编译器通常会通过在函数主体上应用类型推理来找出这个类型。
并非所有 impl Trait 的实例都使用存在性类型。如果你在一个函数的参数位置使用 impl Trait,它实际上只是该函数的一个未命名的泛型参数的缩写。例如,fn foo(s: impl ToString) 大多只是 fn foo<S: ToString>(s: S)。当你实现有相关类型的特质时,存在性类型就特别有用了。例如,设想你正在实现 IntoIterator trait。它有一个相关的类型 IntoIter,持有相关类型可以变成的迭代器的类型。对于存在性类型,你不需要定义一个单独的迭代器类型来用于 IntoIter。相反,你可以给出关联类型为 impl Iterator<Item = Self::Item>,并且只需在 fn into_iter(self) 里面写一个表达式,评估为一个 Iterator,比如通过在一些现有的迭代器类型上使用 maps 和 filters。
存在性类型还提供了一个不仅仅是方便的功能:它们允许你进行零成本的类型清除。你可以使用存在类型来隐藏底层的具体类型,而不是仅仅因为它们出现在某个公共签名中就导出辅助类型--迭代器和 future 就是这种常见的例子。 你的接口的用户只能看到相关类型所实现的特质,而具体类型则作为一个实现细节留下。这不仅简化了接口,而且还使你能够随心所欲地改变实现,而不会在将来破坏下游的代码。
总结
本章对 Rust 的类型系统进行了全面的回顾。我们既看了编译器如何在内存中表现类型,又看了它如何对类型本身进行推理。这是编写不安全代码、复杂应用接口和异步代码的重要背景材料。 你还会发现,本章中的许多类型推理在你如何设计 Rust 代码接口方面发挥了作用,我们将在下一章介绍。
第三章 设计接口
每个项目,无论大小,都有一个 API。事实上,它通常有几个。其中有些是面向用户的,比如 HTTP 端点或命令行接口,有些是面向开发者的,比如一个库的公共接口。在这些之上,Rust crates 还有一些内部接口:每个类型、特质和模块边界都有自己的微型 API,你的其他代码与之接口。随着你的代码库的规模和复杂性的增加,你会发现值得在如何设计内部 API 上投入一些心思和精力,以使使用和维护代码的经验尽可能的愉快。
在这一章中,我们将探讨在 Rust 中编写惯用接口的一些最重要的考虑因素,无论这些接口的用户是你自己的代码还是其他使用你的库的开发者。这基本上可以归结为四个原则:你的接口应该是意料中的 (unsurprising),灵活的 (flexible),明显的 (obvious),以及受约束的 (constrained)。我将依次讨论这些原则,以便为编写可靠和可用的接口提供一些指导。
我强烈建议你在读完本章后看看 Rust API 指南(https://rust-lang.github.io/api-guidelines/)。那里有一个很好的检查表,你可以按照它来做,并对每个建议进行了详细的梳理。本章中的许多建议也是由 cargo clippy 工具检查的,如果你还没有开始在你的代码上运行,你应该开始运行这个工具。我还鼓励你阅读 RFC 1105(https://rust-lang.github.io/rfcs/1105-api-evolution.html)和 The Cargo Book 中关于 SemVer 兼容性的章节(https://doc.rust-lang.org/cargo/reference/semver.html),这些章节涵盖了 Rust 中什么是和什么不是破坏性变化。
意料中的(Unsurprising)
最小惊奇原则,又称最小惊奇法则,在软件工程中经常出现,它对 Rust 接口也是如此。在可能的情况下,你的接口应该足够直观,如果用户需要猜测,他们通常会猜对。当然,并不是所有关于你的应用程序的东西都会立即变得直观,但任何不令人惊讶的东西都应该是直观的。这里的核心思想是紧贴用户可能已经知道的东西,这样他们就不必以不同于他们习惯的方式重新学习概念。这样一来,你就可以把他们的脑力节省下来,去弄清楚那些真正与你的接口有关的东西。
有很多方法可以让你的接口变得可预测。在这里,我们将看看你如何利用命名、共同特质和人体工程学特质的技巧来帮助用户解决问题。
命名惯例
用户在使用你的接口时,首先会通过它的名字来了解它;他们会立即开始从他们遇到的类型、方法、变量、字段和库的名称中推断出一些东西。如果你的接口重用了其他(也许是常见的)接口的名称--比如说,方法和类型,用户就会知道他们可以对你的方法和类型做出某些假设。一个叫做 iter 的方法可能需要 &self,并且可能给你一个迭代器。一个叫做 into_inner 的方法可能会接收 self,并且可能会返回某种包装好的类型。一个叫做 SomethingError 的类型可能实现了 std::error::Error,并出现在各种 Result 中。通过重复使用相同目的的通用名称,你让用户更容易猜到事情的作用,并让他们更容易理解你的接口的不同之处。
这方面的一个推论是,共用一个名字的东西实际上应该以同样的方式工作。否则--例如,如果你的 iter 方法接受 &self,或者如果你的 SomethingError 类型没有实现 Error--用户很可能会根据他们期望的接口工作方式写出错误的代码。他们会感到惊讶和沮丧,并不得不花时间去研究你的接口与他们的期望有什么不同。当我们可以为用户节省这种摩擦时,我们应该做。
类型的共同 traits
Rust 中的用户也会做出一个主要的假设,即接口中的一切都 "只是在工作"。他们期望能够用 {:?} 打印任何类型,并将任何东西发送到另一个线程,他们还期望每个类型都是 Clone。在可能的情况下,我们应该再次避免让用户感到惊讶,并急切地实现大多数标准特性,即使我们并不立即需要它们。
由于第二章中讨论的一致性规则,编译器将不允许用户在需要时实现这些特质。用户不允许为一个外来的类型(如 Clone)实现一个外来的特性,如你的接口中的一个。他们需要将你的接口类型包裹在他们自己的类型中,如果不接触该类型的内部结构,要写出一个合理的实现可能会相当困难。
在这些标准特性中,首先是 Debug 特性。几乎每个类型都可以,而且应该实现 Debug,即使它只打印类型的名称。使用#[derive(Debug)] 通常是在你的接口中实现 Debug trait 的最好方法,但请记住,所有派生 trait 都会自动为任何泛型参数添加相同的绑定。 你也可以简单地通过利用 fmt::Formatter 上的各种 debug_ 助手来编写自己的实现。
并列第二的是 Rust 的自动特性 Send 和 Sync(以及在较小的程度上,Unpin)。如果一个类型没有实现这些特性之一,应该是有很好的理由的。不是 Send 的类型不能被放在 Mutex 中,甚至不能在包含线程池的应用程序中过渡使用。不是同步的类型不能通过 Arc 共享或放在静态变量中。用户已经开始期望类型能在这些情况下工作,特别是在几乎所有东西都在线程池上运行的异步世界中,如果你不确保你的类型实现这些特性,他们会感到沮丧。如果你的类型不能实现这些特性,请确保这个事实和原因都被很好地记录下来。
你应该实现的下一组几乎通用的特性是 Clone 和 Default。这些特性可以很容易地被派生或实现,对大多数类型来说,实现这些特性是有意义的。如果你的类型不能实现这些特性,请确保在你的文档中指出来,因为用户通常期望能够轻松地创建更多(和新)类型的实例,因为他们认为合适。如果他们不能,他们会感到惊讶。
在预期特质的层次结构中再往下一步就是比较特质。PartialEq, PartialOrd, Hash, Eq, 和 Ord。PartialEq 特质是特别可取的,因为用户在某些时候不可避免地会有两个你的类型的实例,他们希望用 == 或 assert_eq 来比较!。即使你的类型只对同一类型的实例进行等价比较,也值得实现 PartialEq 以使你的用户能够使用 assert_eq!
PartialOrd 和 Hash 更为专业,可能适用范围不那么广,但在可能的情况下,你也要实现它们。 这对于用户可能用作地图中的键的类型,或者他们可能使用任何 std::collection 集合类型来重复的类型尤其如此,因为它们往往需要这些边界。除了 PartialEq 和 PartialOrd 之外,Eq 和 Ord 还对实现类型的比较操作有额外的语义要求。这些在这些特性的文档中都有很好的记录,只有当你确定这些语义确实适用于你的类型时,你才应该实现它们。
最后,对于大多数类型来说,实现 serde 包的 Serialize 和 Deserialize 特性是有意义的。这些都可以很容易地派生出来,而且 serde_derive 包甚至有机制可以重写一个字段或枚举变体的序列化。由于 serde 是一个第三方板块,你可能不希望添加对它的必要依赖。因此,大多数库选择提供一个 serde 特性,只有在用户选择时才增加对 serde 的支持。
你可能想知道为什么我没有把可派生特质 Copy 列入本节。有两件事使 Copy 与其他提到的特质不同。第一件事是,用户一般不期望类型是 Copy;恰恰相反,他们倾向于期望,如果他们想要某个东西的两个副本,他们必须调用 clone。复制改变了移动给定类型的值的语义,这可能会让用户感到惊讶。这与第二个观察相联系:一个类型很容易不再是 Copy,因为 Copy 类型受到高度限制。一个开始很简单的类型很容易最终不得不容纳一个字符串,或者其他一些非拷贝类型。如果发生这种情况,你不得不删除 Copy 的实现,这就是一个向后不兼容的变化。相比之下,你很少需要删除 Clone 的实现,所以这是个不太沉重的承诺。
人体工程学特质的实现 (Ergonomic Trait Implementations)
Rust 不会自动为对实现特质的类型的引用实现特质。换个说法,你一般不能用 &Bar 调用 fn foo<T: Trait>(t: T),即使 Bar:Trait。这是因为 Trait 可能包含了采取 &mut self 或 self 的方法,这显然不能在 &Bar 上调用。尽管如此,这种行为可能会让看到 Trait 只有 &self 方法的用户感到非常惊讶。
出于这个原因,当你定义一个新的特性时,你通常会想为该特性提供适当的覆盖实现,如 &T where T: Trait, &mut T where T: Trait,以及 &T where T: Trait, &mut T where T: Trait。你可能只能实现其中的一部分,这取决于 Trait 的方法有哪些接收者。标准库中的许多特质都有类似的实现,正是因为这样可以减少用户的意外。
迭代器是另一种情况,在这种情况下,你经常想在对一个类型的引用上特别添加特质实现。对于任何可以被迭代的类型,考虑为 &MyType 和 &mut MyType 实现 IntoIterator。这使得循环在你的类型的借用实例上也能开箱即用,就像用户所期望的那样。
包装类型
Rust 没有经典意义上的对象继承。 然而,Deref trait 和它的表亲 AsRef 都提供了类似于继承的东西。这些特质允许你拥有一个 T 类型的值,并通过直接在 T 类型的值上调用方法,如果 T: Deref<Target = U> 的话。这对用户来说就像魔法一样,而且一般来说是很好的。
如果你提供了一个相对透明的包装类型(如 Arc),你很有可能想要实现 Deref,这样用户就可以通过使用 . 操作符来调用内部类型上的方法。如果访问内部类型不需要任何复杂或潜在的缓慢逻辑,你也应该考虑实现 AsRef,它允许用户轻松地将 &WrapperType 作为 &InnerType 使用。对于大多数包装类型,你还想尽可能地实现 From<InnerType> 和 Into<InnerType>,这样你的用户就可以轻松地添加或删除你的包装。
你可能也遇到过 Borrow 特质,它感觉与 Deref 和 AsRef 非常相似,但实际上有点不同。具体来说,Borrow 是为一个更狭窄的用例而定制的:允许调用者提供同一类型的多个本质上相同的变体中的任何一个。也许,它可以被称为等价 (Equivalent)。例如,对于一个 HashSet<String>,Borrow 允许调用者提供一个 &str 或者一个 &String。虽然同样的情况可以用 AsRef 来实现,但如果没有 Borrow 的额外要求,即目标类型对 Hash、Eq 和 Ord 的实现与实现类型完全相同,这就不安全了。Borrow 还为 T、&T 和 &mut T 提供了一个 Borrow<T>的覆盖实现,这使得它在特质边界中的使用非常方便,可以接受一个给定类型的自有值或引用值。一般来说,Borrow 只用于你的类型本质上等同于另一个类型,而 Deref 和 AsRef 则是为了更广泛地实现你的类型可以 "作为 "的任何东西。
DEREF 和固有方法 当
T上有以self的方法时,围绕点运算符和Deref的魔法会变得混乱和令人惊讶。例如,给定一个值t。T,不清楚t.frobnicate()是对T还是对底层的U进行frobnicate!由于这个原因,那些允许你透明地调用一些事先不知道的内部类型的方法的类型应该避免使用固有方法。Vec可以有一个push方法,即使它解除对 slice 的引用,因为你知道 slice 不会很快得到一个push方法。但是,如果您的类型取消对用户控制的类型的引用,那么您添加的任何固有方法也可能存在于该用户控制的类型上,从而导致问题。在这些情况下,倾向于fn frobnicate(t: t)形式的静态方法。这样,t.frobnicate()总是调用U::frobnicate,而t::frobnicate(t)可以用来T本身。
灵活的
你写的每一段代码都隐含地或明确地包括一个契约。契约由一组要求和一组承诺组成。要求是对代码如何被使用的限制,而承诺是对代码如何被使用的保证。当设计一个新的接口时,你要仔细考虑这个契约。一个好的经验法则是避免强加不必要的限制,只做出你能遵守的承诺。 增加限制或删除承诺通常需要一个重大的语义版本变化,并可能破坏其他地方的代码。 另一方面,放松限制或给出额外的承诺,通常是向后兼容的。
在 Rust 中,限制通常以特质约束和参数类型的形式出现,而承诺则以特质实现和返回类型的形式出现。例如,比较清单 3-1 中的三个函数签名。
#![allow(unused)] fn main() { fn frobnicate1(s: String) -> String fn frobnicate2(s: &str) -> Cow<'_, str> fn frobnicate3(s: impl AsRef<str>) -> impl AsRef<str> // 清单 3-1:具有不同契约的类似函数签名 }
这三个函数签名都接收一个字符串并返回一个字符串,但它们是在非常不同的契约下进行的。
第一个函数要求调用者以 String 类型的形式拥有字符串,它承诺将返回一个拥有(所有权)的 String。 由于契约要求调用者分配,并要求我们返回一个拥有的字符串,我们以后不能以向后兼容的方式使这个函数免分配。
第二个函数放宽了契约:调用者可以提供任何对字符串的引用,所以用户不再需要分配或放弃对一个字符串的所有权。它还承诺返回一个 std::borrow::Cow,这意味着它可以返回一个字符串引用或者一个拥有的字符串,这取决于它是否需要拥有该字符串。这里的承诺是,该函数将始终返回一个 Cow,这意味着我们不能,比如说,以后改变它以使用其他优化的字符串表示。调用者也必须特别提供一个&str,所以如果他们有,比如说,他们自己的一个预先存在的 String,他们必须将其解除引用为一个 &str 来调用我们的函数。
第三个函数取消了这些限制。它只要求用户传入可以生成字符串引用的类型,并且只承诺返回值可以生成字符串引用。
这些函数签名中没有一个比其他的更好。如果你在函数中需要一个字符串的所有权,你可以使用第一个参数类型来避免额外的字符串拷贝。如果你想让调用者利用拥有的字符串被分配和返回的情况,第二个返回类型为 Cow 的函数可能是一个好选择。相反,我想让你从中得到的是,你应该仔细考虑你的接口对你的约束是什么契约,因为事后改变它可能是破坏性的。
在本节的其余部分,我将举例说明经常出现的接口设计决定,以及它们对你的接口契约的影响。
泛型参数
你的接口必须对用户提出的一个明显的要求是他们必须向你的代码提供什么类型。如果你的函数明确地接受一个 Foo,用户必须拥有并给你一个 Foo。这是没办法的事。在大多数情况下,使用泛型而不是具体类型是值得的,这样可以让调用者传递任何符合你的函数实际需要的类型,而不是只传递一种特定的类型。将清单 3-1 中的 &str 改为 AsRef<str> 是这种放松的一个例子。以这种方式放宽要求的一个方法是,从参数的完全泛型开始,不设约束,然后根据编译器的错误来发现你需要添加什么约束。
然而,如果走到极端,这种方法将使每个函数的每个参数都成为自己的泛型,这将是既难读又难理解的。对于何时应该或不应该使一个给定的参数成为泛型,并没有硬性规定,所以请使用你的最佳判断。一个好的经验法则是,如果你能想到用户可能合理地、经常地想要使用的其他类型,而不是你开始使用的具体类型,就把参数变成泛型。
你可能还记得第 2 章,泛型代码通过单态化,对曾经使用过的每一种类型的组合都是重复的。考虑到这一点,使大量参数泛化的想法可能会让你担心过度扩大你的二进制文件。在第 2 章中,我们也讨论了如何使用动态调度来缓解这种情况,其性能代价(通常)可以忽略不计,这在这里也适用。对于那些你无论如何都要通过引用来获取的参数(记得 dyn Trait 不是 Sized,你需要一个宽指针来使用它们),你可以很容易地用一个使用动态调度的参数来替换你的通用参数。例如,你可以用 &dyn AsRef<str> 来代替 impl AsRef<str>。
不过,在你跑去做这件事之前,有几件事情你应该考虑。首先,你是代表你的用户做出这个选择的,他们不能选择不使用动态调度。如果你知道你要应用动态调度的代码永远不会对性能敏感,这可能是好的。但如果有用户想在他们的高性能应用中使用你的库,那么在热循环中调用的函数中的动态调度可能会成为一个问题。其次,在写这篇文章的时候,只有当你有一个简单的特质约束时,使用动态调度才能发挥作用,比如 T:AsRef<str> 或 impl AsRef<str>。对于更复杂的约束,Rust 不知道如何构造动态调度 vtable,所以你不能采取,比如说,&dyn Hash + Eq。最后,请记住,对于泛型,调用者总是可以通过传入一个 trait 对象来选择动态调度。反之则不然:如果你带了一个特质对象,那就是调用者必须提供的。
我们可能很想从具体的类型开始你的接口,然后随着时间的推移把它们变成泛型。要知道为什么,想象一下你把一个函数从 fn foo(v: &Vec<usize>) 改为 fn foo(v: impl AsRef<[usize]>) 。虽然每个 &Vec<usize> 都实现了 AsRef<[usize]>,但类型推理仍然会给用户带来问题。考虑一下如果调用者用 foo(&iter.collect()) 来调用 foo 会发生什么。在最初的版本中,编译器可以确定它应该收集到一个 Vec,但现在它只知道它需要收集到某个实现 AsRef<[usize]> 的类型。而且可能有多个这样的类型,所以有了这个改变,调用者的代码就不会再被编译了!
对象安全
当你定义一个新的 trait 时,该 trait 是否是对象安全的(见第 2 章编译和调度的结尾)是 trait 契约的一个不成文的部分。如果特质是对象安全的,用户可以使用 dyn Trait 将实现你的特质的不同类型视为单一的公共类型。如果不是这样,编译器将不允许该 trait 的 dyn Trait。你应该倾向于你的 trait 是对象安全的,即使这对使用它们的人机工程学来说有一点代价(比如在 &str 上使用 impl AsRef<str>),因为对象安全可以使你的 trait 有新的使用方式。如果你的 trait 必须有一个泛型方法,考虑它的泛型参数是否可以在 trait 本身上,或者它的泛型参数是否也可以使用动态调度来保持 trait 的对象安全。另外,你可以添加一个与该方法绑定的 where Self: Sized trait,这样就可以只用该 trait 的具体实例来调用该方法(而不是通过 dyn Trait)。你可以在 Iterator 和 Read 特质中看到这种模式的例子,它们是对象安全的,但在具体实例上提供了一些额外的便利方法。
你应该愿意做出多少牺牲来保护对象的安全,这个问题没有唯一的答案。我的建议是,你要考虑你的特质将如何被使用,以及用户想把它作为一个特质对象使用是否有意义。如果你认为用户可能会想一起使用你的特质的许多不同的实例,你应该更努力地提供对象安全,而不是认为这种使用情况没有什么意义。例如,动态调度对于 FromIterator trait 来说是没有用的,因为它的一个方法不接受 self,所以你首先就不能构造一个 trait 对象。 同样,std::io::Seek 作为一个 trait 对象本身是相当无用的,因为你能用这样一个 trait 对象做的唯一事情就是寻找,而不能读或写。
DROP trait 对象 你可能认为 Drop 特质作为一个特质对象也是无用的,因为作为一个特质对象,你能用 Drop 做的就是析构它。但事实证明,有一些库特别希望能够丢弃任意类型。例如,一个提供延迟丢弃值的库,例如用于并发垃圾收集或只是延迟清理,只关心值是否可以被丢弃,而不关心其他。有趣的是,Drop 的故事并没有结束;因为 Rust 也需要能够丢弃 trait 对象,每个 vtable 都包含 drop 方法。实际上,每个
dyn Trait也是一个dyn Drop。
请记住,对象安全是你的公共接口的一部分如果你以一种向后兼容的方式修改了一个特质,比如增加了一个带有默认实现的方法,但这使得该特质不是对象安全的,你需要提升你的主要语义版本号。
借用 vs 所有权
对于你在 Rust 中定义的几乎每一个函数、特质和类型,你必须决定它是否应该拥有,或者只是持有对其数据的引用。幸运的是,这些决定往往是自己做出的。
如果你写的代码需要数据的所有权,比如调用带有 self 的方法或将数据转移到另一个线程,它必须存储所有权数据。当你的代码必须拥有数据时,一般来说,它也应该让调用者提供拥有的数据,而不是通过引用和克隆来获取价值。这使得调用者可以控制分配,并且可以预先了解使用相关接口的成本。
另一方面,如果你的代码不需要拥有这些数据,它应该在引用上操作。这个规则的一个常见例外是像 i32、bool 或 f64 这样的小类型,它们直接存储和复制和通过引用存储一样便宜。不过要注意假设这对所有的 Copy 类型都是正确的;[u8; 8192] 是 Copy,但如果到处存储和复制它,就会很昂贵。
当然,在现实世界中,事情往往不那么明确。 有时,你并不事先知道你的代码是否需要拥有数据。例如,String::from_utf8_lossy 需要拥有传递给它的字节序列的所有权,只有当它包含无效的 UTF-8 序列时。在这种情况下,Cow 类型是你的朋友:如果数据允许,它可以让你对引用进行操作,如果需要,它可以让你产生一个拥有的值。
其他时候,引用生存期使接口变得非常复杂,以至于使用起来很麻烦。如果你的用户在让代码在你的接口上面编译时很费劲,那就说明你可能要(甚至不必要地)对某些数据片断采取所有权。如果你这样做,在你决定对可能是一大块字节的数据进行堆分配之前,先从那些克隆起来很便宜或者不属于任何对性能敏感的数据开始。
易出错的和阻塞的析构函数
以 I/O 为中心的类型在析构时往往需要进行清理。这可能包括刷新写入磁盘的数据,关闭文件,或优雅地终止与远程主机的连接。执行这种清理的自然地方是在类型的 Drop 实现中。 不幸的是,一旦一个值被丢弃,除了惊慌失措之外,我们不再有办法向用户传达错误。一个类似的问题出现在异步代码中,我们希望在有工作未完成时完成。当 drop 被调用时,执行器可能已经关闭了,而我们没有办法做更多的工作。我们可以尝试启动另一个执行器,但这也会带来一系列的问题,比如异步代码中的阻塞,我们将在第 8 章看到。
这些问题没有完美的解决方案,无论我们做什么,一些应用程序将不可避免地回落到我们的 Drop 实现。出于这个原因,我们需要通过 Drop 提供尽力的清理。如果清理出错,至少我们试过了--我们吞下错误并继续前进。如果一个执行器仍然可用,我们可能会产生一个 future 来进行清理,但如果它永远不会运行,我们就做了我们能做的。
然而,我们应该为那些希望不留下松散线程的用户提供一个更好的选择。我们可以通过提供一个显式的析构器来做到这一点。这通常以一个方法的形式出现,该方法拥有 self 的所有权,并暴露任何错误(使用-> Result<_, _>)或异步(使用 async fn),这些都是销毁过程中固有的。一个谨慎的用户可以使用该方法来优雅地销毁任何相关的资源。
注意:一定要在文档中突出显示析构函数!
像往常一样,有一个权衡的问题。当你添加一个显式的析构器时,你会遇到两个问题。首先,由于你的类型实现了 Drop,你不能再在析构函数中移出该类型的任何字段。这是因为 Drop::drop 在你的显式析构器运行后仍然会被调用,而且它需要&mut self,这要求 self 的任何部分都没有被移动。其次,drop 接收的是 &mut self,而不是 self,所以你的 Drop 实现不能简单地调用你的显式析构器并忽略其结果(因为它并不拥有 self)。 有几个方法可以解决这些问题,但都不完美。
第一个方法是使你的顶层类型成为一个包裹着 Option 的新类型,而这个新类型又持有一些持有该类型所有字段的内部类型。然后你可以在两个析构函数中使用 Option::take,并且只在内部类型还没有被占用时才调用内部类型的显式析构函数。因为内层类型没有实现 Drop,所以你可以拥有那里的所有字段的所有权。这种方法的缺点是,你希望在顶层类型上提供的所有方法现在必须包括通过 Option(你知道它总是 Some,因为 Drop 还没有被调用)到内部类型上的字段的代码。
第二个解决方法是使你的每个字段都能被取走。 你可以通过用 None 替换 Option 来 "取走"它(这就是 Option::take 的作用),但你也可以对许多其他类型的字段这样做。 例如,你可以通过简单地用它们廉价的构造默认值替换 Vec 或 HashMap 来取走它们--std::mem::take 是你的朋友。如果你的类型有合理的 "空"值,这种方法就很好用,但如果你必须用 Option 包裹几乎所有的字段,然后用一个匹配的 unwrap 来修改这些字段的每一次访问,就会变得很乏味。
第三种选择是在 ManuallyDrop 类型中保存数据,它可以解除对内部类型的引用,所以不需要解包。你也可以在 drop 中使用 ManuallyDrop::take 来在销毁时取得所有权。这种方法的主要缺点是,ManuallyDrop::take 是不安全的。没有任何安全机制来确保你在调用 take 后不会尝试使用 ManuallyDrop 中的值,或者不会多次调用 take。如果你这样做了,你的程序就会默默地表现出未定义的行为,而且会有坏事发生。
最终,你应该选择这些方法中最适合你的应用的一种。我倾向于选择第二种方法,只有当你发现自己处于 OptionS 的海洋中时才会切换到其他方法。如果代码足够简单,你可以很容易地检查你的代码的安全性,而且你对自己的能力有信心,那么ManuallyDrop 解决方案是非常好的。
明显的
虽然有些用户可能熟悉支撑你的接口的实现的某些方面,但他们不可能理解所有的规则和限制。他们不会知道在调用 bar 之后再调用 foo 是绝对不行的,也不会知道只有在月亮呈 47 度角且过去 18 秒内没有人打喷嚏的情况下,调用不安全方法 baz 才是安全的。只有当接口清楚地表明有什么奇怪的事情发生时,他们才会伸手去拿文件或仔细阅读类型签名。因此,对你来说,让用户尽可能容易地理解你的接口,并让他们尽可能难以错误地使用你的接口是至关重要的。 在这方面,你所掌握的两个主要技术是你的文档和类型系统,所以让我们依次看一下这两个技术。
注意:你也可以利用命名来向用户暗示,一个接口的内容不只是看起来那么简单。如果用户看到一个名为
dangerous的方法,他们很有可能会阅读其文档。
文档
让你的接口透明化的第一步是写好文档。我可以写一整本书来介绍如何编写文档,但在这里我们还是专注于针对 Rust 的建议。
首先,清楚地记录你的代码可能会做一些意想不到的事情,或者它依赖于用户做一些超出类型签名规定的事情。恐慌是这两种情况的一个很好的例子:如果你的代码可能会恐慌,请记录这一事实,以及它可能恐慌的情况。同样地,如果你的代码可能会返回一个错误,请记录它在哪些情况下会返回。对于不安全的函数,记录调用者必须保证什么才能使调用安全。
第二,在包和模块层面上为你的代码提供端到端的使用范例。这些例子比特定类型或方法的例子更重要,因为它们让用户感觉到所有东西是如何结合在一起的。有了对接口结构的高层次理解,开发者可能很快就会意识到特定的方法和类型的作用,以及它们应该用在什么地方。 端到端的例子也给用户一个自定义使用的起点,他们可以,而且经常会,复制粘贴这个例子,然后根据他们的需要进行修改。这种 "边做边学 "的方式往往比让他们尝试从组件中拼凑出一些东西更有效。
注意:非常针对方法的例子表明,是的,
len方法确实返回了长度,但不可能告诉用户关于你的代码的任何新情况。
第三,组织你的文档。把所有的类型、特质和函数放在一个顶层的模块中,会使用户难以了解从哪里开始。利用模块的优势,将语义相关的项目组合在一起。然后,使用文档内的链接来相互连接项目。如果 A 类型的文档谈到了 B 特质,那么就应该在这里链接到该特质。 如果你让用户很容易地探索你的接口,他们就不太可能错过重要的联系或依赖关系。也可以考虑用#[doc(hidden)] 来标记你的接口中那些不打算公开但由于遗留原因需要的部分,这样它们就不会使你的文档变得杂乱无章。
最后,尽可能地丰富你的文档。链接到解释概念、数据结构、算法或接口的其他方面的外部资源,这些资源可能在其他地方有很好的解释。RFCs、博客文章和白皮书都是很好的选择,如果有相关的话。使用#[doc(cfg(..))] 来强调只有在某些配置下才可用的项目,这样用户就能很快意识到为什么文档中列出的某些方法是不可用的。使用#[doc(alias = "...")] 使类型和方法可以在其他名称下被发现,以便用户可以搜索到它们。 在顶层文档中,指出用户常用的模块、特性、类型、特质和方法。
类型系统指导
类型系统是一个很好的工具,可以确保你的接口是明显的、自动文档化的,并且是抗误用的。你有几种技术可以使你的接口很难被滥用,从而使它们更有可能被正确使用。
其中第一种是语义类型,在这种类型中,你添加类型来表示一个值的含义,而不仅仅是其原始类型。这里的典型例子是布尔运算:如果你的函数需要三个布尔参数,那么很有可能一些用户会弄乱这些值的顺序,并在出了大错之后才意识到这一点。 另一方面,如果它需要三个不同的双变量枚举类型的参数,那么用户就不能在没有编译器吼叫的情况下弄错顺序:如果他们试图将 DryRun::Yes 传递给 overwrite 参数,这将根本不起作用,将 overwrite::No 作为 dry_run 参数也不行。你也可以在布尔运算之外应用语义类型。例如,围绕数字类型的 newtype 可以为所包含的值提供一个单位,或者它可以将原始指针参数限制在仅由另一个方法返回的参数上。
一个密切相关的技术是使用零大小的类型来表示一个特定的事实对于一个类型的实例是正确的。例如,考虑一个叫做 Rocket 的类型,它代表一个真正的火箭的状态。无论火箭处于什么状态,火箭上的一些操作(方法)都应该是可用的,但有些操作只有在特殊情况下才有意义。例如,如果火箭已经被发射了,就不可能再发射。同样的,如果火箭还没有发射,也不可能分离燃料箱。我们可以将这些建模为枚举变体,但是这样一来,所有的方法在每个阶段都是可用的,我们就需要引入可能的恐慌了。
相反,如清单 3-2 所示,我们可以在 Rocket 上引入一个通用参数 Stage,并使用它来限制什么时候可以使用什么方法。
#![allow(unused)] fn main() { struct Grounded; // (1) struct Launched; // and so on struct Rocket<Stage = Grounded> { stage: std::marker::PhantomData<Stage>, // (2) } impl Default for Rocket<Grounded> {} // (3) impl Rocket<Grounded> { pub fn launch(self) -> Rocket<Launched> { } } impl Rocket<Launched> { // (4) pub fn accelerate(&mut self) { } pub fn decelerate(&mut self) { } } impl<Stage> Rocket<Stage> { // (5) pub fn color(&self) -> Color { } pub fn weight(&self) -> Kilograms { } } // 第 3-2 项:使用标记类型来限制实现的方法 }
我们引入单元类型来表示火箭的每个阶段 (1)。我们实际上不需要存储阶段--只需要存储它提供的元信息--所以我们把它存储在 PhantomData (2) 后面,以保证它在编译时被消除。然后,我们只在 Rocket 持有特定类型的参数时为其编写实现块。你只能在地面上建造一个火箭(目前),而且你只能从地面上发射它 (3)。只有当火箭发射后,你才能控制它的速度 (4)。无论火箭处于什么状态,你都可以对它做一些事情,这些事情我们放在一个通用的实现块中 (5)。 你会注意到,以这种方式设计的接口,用户根本不可能在错误的时间调用一个方法,我们已经将使用规则编码在类型本身中,并使非法状态无法表示。
这个概念也延伸到许多其他领域;如果你的函数忽略了一个指针参数,除非给定的布尔参数为真,那么最好把这两个参数结合起来。 有了一个枚举类型,其中一个变体代表 false(没有指针),一个变体代表 true,持有一个指针,无论是调用者还是实现者都不会误解这两者之间的关系。 这是一个强大的想法,我强烈鼓励你利用它。
另一个使接口明显的小工具是 #[must_use] 注解。把它添加到任何类型、特质或函数中,如果用户的代码接收到该类型或特质的元素,或调用该函数,而没有明确地处理它,编译器就会发出警告。你可能已经在 Result 的上下文中看到了这一点:如果一个函数返回一个 Result,而你没有把它的返回值分配到某个地方,你会得到一个编译器警告。不过要注意不要过度使用这个注解--只有在用户不使用返回值时很可能会犯错的情况下才会添加它。
受约束的
随着时间的推移,一些用户会依赖你的接口的每一个属性,无论是错误还是功能。这对于公开的库来说尤其如此,因为你无法控制你的用户。因此,在进行用户可见的改变之前,你应该仔细考虑。无论你是添加一个新的类型、字段、方法或特质实现,还是改变一个现有的,你都要确保这个改变不会破坏现有用户的代码,而且你打算将这个改变保留一段时间。频繁的向后不兼容的变更(语义版本学中的主要版本增加)肯定会引起用户的愤怒。
许多向后不兼容的变化是显而易见的,比如重命名一个公共类型或删除一个公共方法,但有些是比较微妙的,与 Rust 的工作方式有很大关系。在这里,我们将介绍一些比较棘手的微妙变化,以及如何为它们制定计划。你会发现,你需要在其中一些变化与你希望你的接口有多大的灵活性之间取得平衡--有时候,有些东西必须要让步。
类型修改
删除或重命名一个公共类型几乎肯定会破坏一些用户的代码。为了解决这个问题,你要尽可能地利用 Rust 的可见性修改器,比如 pub(crate) 和 pub(in path)。你拥有的公有类型越少,你就有更多的自由去改变事情而不破坏现有的代码。
不过,用户代码可以在更多的方面依赖你的类型,而不仅仅是名称。考虑一下清单 3-3 中的公共类型和该代码的给定用途。
#![allow(unused)] fn main() { // 在你的接口 pub struct Unit; // 在用户的代码 let u = lib::Unit; // 清单 3-3:一个看起来无辜的公共类型 }
现在考虑一下如果你给 Unit 添加一个私有字段会发生什么。尽管你添加的字段是私有的,但这个改变仍然会破坏用户的代码,因为他们所依赖的构造函数已经消失了。 类似地,考虑清单 3-4 中的代码和用法
#![allow(unused)] fn main() { // 你的接口 pub struct Unit { pub field: bool }; // 用户代码 fn is_true(u: lib::Unit) -> bool { matches!(u, Unit { field: true }) } // 清单 3-4:访问单个公共字段的用户代码 }
在这里,给 Unit 添加一个私有字段也会破坏用户代码,这次是因为 Rust 的详尽模式匹配检查逻辑能够看到用户看不到的接口部分。它认识到有更多的字段,尽管用户代码无法访问它们,并拒绝用户的模式为不完整。如果我们把元组结构变成带有命名字段的普通结构,也会出现类似的问题:即使字段本身完全相同,任何旧的模式对新的类型定义也不再有效。
Rust 提供了 #[non_exhaustive] 属性来帮助缓解这些问题。你可以把它添加到任何类型的定义中,编译器将不允许在该类型上使用隐式构造函数(如 lib::Unit { field1: true })和非穷举模式匹配(即没有尾巴的模式,..)。如果你怀疑自己将来可能会修改某个特定的类型,这是一个很好的属性。但它确实限制了用户的代码,例如剥夺了用户依赖穷举模式匹配的能力,所以如果你认为一个给定的类型可能会保持稳定,就不要添加它。
Trait 实现
正如你在第 2 章中所回忆的,Rust 的一致性规则不允许一个给定类型的多个特性的实现。由于我们不知道下游代码可能添加了哪些实现,所以添加一个现有特质的覆盖实现通常是一种破坏性的改变。同样的道理也适用于为一个现有类型实现一个外来特质,或者为一个外来类型实现一个现有特质--在这两种情况下,外来特性或类型的所有者可能同时添加一个冲突的实现,所以这一定是一个破坏性的改变。
删除一个特质的实现是一个破坏性的改变,但是为一个新的类型实现特质从来都不是问题,因为没有一个包可以有与该类型冲突的实现。
也许是反直觉的,你也要小心为现有的类型实现任何特质。要知道为什么,请看清单 3-5 中的代码。
// crate1 1.0 pub struct Unit; pub trait Foo1 { fn foo(&self) } // note that Foo1 is not implemented for Unit // crate2; depends on crate1 1.0 use crate1::{Unit, Foo1}; trait Foo2 { fn foo(&self) } impl Foo2 for Unit { .. } fn main() { Unit.foo(); } // 清单 3-5: 为一个现有的类型实现一个特质可能会引起问题。
如果你在 crate1 中添加了 impl Foo1 for Unit,而没有将其标记为突破性变化,那么下游的代码会突然停止编译,因为现在对 foo 的调用是不明确的。这甚至可以适用于新的公共特质的实现,如果下游的包使用通配符导入(使用 cate1::*)。如果你提供了一个 prelude 模块,并指示用户使用通配符导入,你将特别需要记住这一点。
对现有特质的大多数改变也是破坏性的改变,例如改变方法签名或添加新方法。改变一个方法的签名会破坏该特质的所有实现,可能还有很多用途,而添加一个新的方法"只是"破坏所有的实现。添加一个带有默认实现的新方法是可以的,因为现有的实现将继续适用。
我在这里说 "一般 "和 "大多数",是因为作为接口作者,我们有一个工具可以让我们绕过其中的一些规则:密封的特质。一个密封的特质是一个只能由其他包使用,而不能实现的特质。这立即使一些破坏性的变化变得不那么破坏。例如,你可以为一个密封的特质添加一个新的方法,因为你知道在当前的包之外没有任何实现需要考虑。同样地,你可以为新的外部类型实现一个密封的特质,因为你知道定义该类型的外部包不可能添加一个冲突的实现。
Sealed 特性最常用于派生特性--为实现特定其他特性的类型提供覆盖实现的特性。只有当外部的包实现你的特性没有意义时,你才应该封闭一个特性;这严重限制了该特性的实用性,因为下游的包将不再能够为他们自己的类型实现该特性。你也可以使用密封的特性来限制哪些类型可以被用作类型参数,比如在清单 3-2 中的火箭例子中,将 Stage 类型限制为只有 Grounded 和 Launched 的类型。
清单 3-6 显示了如何封存一个特质,以及如何在定义箱中为它添加实现。
#![allow(unused)] fn main() { pub trait CanUseCannotImplement: sealed::Sealed /* (1) */ { .. } mod sealed { pub trait Sealed {} impl<T> Sealed for T where T: TraitBounds {} // (2) } impl<T> CanUseCannotImplement for T where T: TraitBounds {} // 清单 3-6:如何密封一个特质并为其添加实现 }
诀窍是添加一个私有的、空的特质,作为你希望封 (1) 的特质的一个超级特质。由于这个父 trait 在一个私有模块中,其他的板块无法接触到它,因此也无法实现它。封闭的特质要求底层类型实现 Sealed,所以只有我们明确允许的类型 (2) 才能最终实现该特质。
注意:如果你确实以这种方式封印了一个特性,请确保你记录了这一事实,这样用户在试图自己实现特性时就不会感到沮丧了。
隐性契约
有时,你对代码的某一部分所做的修改会以微妙的方式影响到接口中其他地方的契约。发生这种情况的两种主要方式是通过再导出和自动特质(auto-traits)。
重导出
如果你的接口的任何部分导出了外部类型,那么对这些外部类型之一的任何改变也是对你接口的改变。例如,考虑一下如果你转到一个新的依赖关系的主要版本,并将该依赖关系中的一个类型作为,例如,你的接口中的一个迭代器类型公开,会发生什么。依赖于你的接口的用户可能也会直接依赖该依赖关系,并期望你的接口提供的类型与该依赖关系中的同名类型相同。但是如果你改变了你的依赖关系的主要版本,即使类型的名称是一样的,这也不再是真的了。清单 3-7 显示了一个这样的例子。
#![allow(unused)] fn main() { // your crate: bestiter pub fn iter<T>() -> itercrate::Empty<T> { .. } // their crate struct EmptyIterator { it: itercrate::Empty<()> } EmptyIterator { it: bestiter::iter() } // 清单 3-7:重新导出使外部的包成为接口契约的一部分。 }
如果你的 crate 从 itercrate 1.0 移到 itercrate 2.0,但其他方面没有变化,这个列表中的代码将不再被编译。 尽管类型没有变化,编译器认为(正确地)itercrate1.0::Empty 和 itercrate2.0::Empty 是不同的类型。因此,你不能将后者赋值给前者,这使得你的接口发生了破坏性的变化。
为了减少这样的问题,通常最好使用 newtype 模式来包装外部类型,然后只公开外部类型中你认为有用的部分。在很多情况下,你可以通过使用 impl Trait 来避免 newtype 包装器,只向调用者提供非常小的契约。通过少许承诺,你可以减少改变的次数。
SEMVER 的诀窍
itercrate的例子可能让你感到不快。如果Empty类型没有改变,那么为什么编译器不允许任何使用它的东西继续工作,而不管代码是使用它的 1.0 还是 2.0 版本?答案是。..... 复杂的。它归结为这样一个事实:Rust 编译器并不认为仅仅因为两个类型有相同的字段,它们就是相同的。举个简单的例子,想象一下itercrate 2.0为Empty增加了一个#[derive(Copy)]。现在,这个类型突然有了不同的移动语义,这取决于你使用的是 1.0 还是 2.0!而用其中一个类型编写的代码在另一个类型中就无法使用。这个问题往往会出现在大型的、广泛使用的库中,随着时间的推移,破坏性的改变很可能会发生在箱体的某个地方。不幸的是,语义上的版本控制是在包层面上进行的,而不是在类型层面上进行的,因此,任何地方的破坏性改变都是一种破坏性改变。
但是,一切并没有失去。几年前,David Tolnay(
serde的作者,还有其他大量的 Rust 贡献)想出了一个巧妙的技巧来处理这种情况。他称其为 "semver 技巧"。这个想法很简单:如果某个类型的T在突破性变化中保持不变(比如从 1.0 到 2.0),那么在发布 2.0 之后,你可以发布一个新的 1.0 次要版本,该版本依赖于 2.0,并且用 2.0 中的T的重导出来替换T。通过这样做,你可以确保两个主要版本中都只有一个单一的 T 类型。这反过来又意味着任何依赖于 1.0 的板块都可以使用 2.0 的 T,反之亦然。因为这只发生在你明确选择的类型上,所以那些事实上会破坏的变化将继续存在。
自动 traits(Auto-Traits)
Rust 有一些特质,这些特质会根据每个类型所包含的内容而自动实现。其中与本讨论最相关的是 Send 和 Sync,尽管 Unpin、Sized 和 UnwindSafe traits 也有类似的问题。就其本质而言,这些特质为你接口中的几乎所有类型添加了一个隐藏的承诺。 这些特质甚至可以通过其他类型消除的类型传播,比如 impl Trait。
这些特性的实现(通常)是由编译器自动添加的,但这也意味着,如果它们不再适用,就不会自动添加。所以,如果你有一个包含私有类型 B 的公共类型 A,而你改变了 B,使其不再是 Send,那么 A 现在也不再是 Send 了。这就是一个破坏性的变化!
这些变化可能很难被跟踪,而且往往直到你的接口的用户抱怨他们的代码不再工作时才被发现。为了在这些情况发生之前抓住它们,在你的测试套件中加入一些简单的测试,以检查你的所有类型是否以你期望的方式实现了这些特性,这是一个很好的做法。 清单 3-8 给出了这样一个测试的例子。
#![allow(unused)] fn main() { fn is_normal<T: Sized + Send + Sync + Unpin>() {} #[test] fn normal_types() { is_normal::<MyType>(); } // 清单 3-8:测试一个类型是否实现了一组特征 }
注意,这个测试没有运行任何代码,只是测试代码的编译情况。如果 MyType 不再实现 Sync,测试代码将不能编译,你将知道你刚才的改变破坏了自动跟踪的实现。
从文件中隐藏项目
#[doc(hidden)]属性可以让你在文档中隐藏一个公共项目,而不至于让碰巧知道它存在的代码无法访问。这通常被用来暴露那些被宏所需要的方法和类型,但不是被用户代码所需要的。这种隐藏的项目如何与你的接口契约互动是一个有争议的问题。一般来说,标记为#[doc(hidden)]的项目只被认为是你的契约的一部分,就其公共效果而言;例如,如果用户代码最终可能包含一个隐藏的类型,那么该类型是Send是契约的一部分,而其名称则不是。隐藏的固有方法和隐藏在密封特质上的特质方法通常不是你的接口契约的一部分,尽管你应该确保在这些方法的文档中明确说明这一点。是的,隐藏的项目仍然应该被记录下来!
总结
在这一章中,我们探讨了设计 Rust 接口的许多方面,无论它是为了供外部使用,还是仅仅作为你的 crate 中不同模块之间的一个抽象边界。我们介绍了很多具体的陷阱和技巧,但最终,高层次的原则应该指导你的思考:你的接口应该是不令人惊讶的、灵活的、明显的和受约束的。在下一章中,我们将深入探讨如何表示和处理 Rust 代码中的错误。
第四章 错误处理
除了最简单的程序外,你都会有可能失败的方法。 在本章中,我们将研究表示、处理和传播这些失败的不同方法,以及每种方法的优点和缺点。我们将首先探讨表示错误的不同方法,包括枚举和擦除,然后研究一些需要不同表示技术的特殊错误情况。接下来,我们将研究处理错误的各种方法和错误处理的未来。
值得注意的是,Rust 中错误处理的最佳实践仍然是一个活跃的话题,在写这篇文章的时候,这个生态系统还没有确定一个统一的方法。因此,本章将重点讨论基本原则和技术,而不是推荐具体的包或模式。
代表错误(Representing Errors)
当你编写可能失败的代码时,最重要的问题是你的用户将如何与任何返回的错误互动。用户是否需要确切地知道哪个错误发生了,以及关于什么地方出错的细节,还是他们只需要记录一个错误的发生,然后尽可能地继续前进?为了理解这一点,我们必须看一下错误的性质是否可能影响到调用者在收到错误后的行为。这反过来又决定了我们如何表示不同的错误。
你有两个主要的选择来表示错误:枚举和清除。也就是说,你可以让你的错误类型枚举可能的错误条件,以便调用者能够区分它们,或者你可以只向调用者提供一个单一的、不透明的错误。让我们依次讨论这两种选择。
枚举
在我们的例子中,我们将使用一个库函数,将字节从某个输入流复制到某个输出流中,很像 std::io::copy。 用户为你提供了两个流,一个要读,一个要写,你将字节从一个复制到另一个。在这个过程中,任何一个流都有可能失败,这时拷贝必须停止,并向用户返回一个错误。在这里,用户可能想知道是输入流还是输出流失败。例如,在一个网络服务器中,如果输入流在向客户端传输文件时发生错误,可能是因为磁盘被弹出,而如果输出流发生错误,可能是客户端刚刚断开连接。后者可能是一个服务器应该忽略的错误,因为复制到新的连接仍然可以完成,而前者可能需要关闭整个服务器。
这是一个我们想要列举错误的情况。用户需要能够区分不同的错误情况,以便他们能够做出适当的反应,所以我们使用一个名为 CopyError 的枚举,每个变量代表一个单独的错误的根本原因,就像清单 4-1 中那样。
#![allow(unused)] fn main() { pub enum CopyError { In(std::io::Error), Out(std::io::Error) } // 清单 4-1: 枚举错误类型 }
每个变量还包括所遇到的错误,以便为调用者提供尽可能多的出错信息。
当制作你自己的错误类型时,你需要采取一些步骤,使错误类型与 Rust 生态系统的其他部分很好地配合。首先,你的错误类型应该实现 std::error::Error 特质,它为调用者提供了内省错误类型的常用方法。主要的方法是 Error::source,它提供了一种机制来寻找错误的根本原因。这最常用于打印一个回溯,显示一路追溯到错误的根本原因。对于我们的 CopyError 类型,source 的实现是直接的:我们在 self 上进行匹配,提取并返回内部的 std::io::Error。
第二,你的类型应该同时实现 Display 和 Debug ,以便调用者可以有意义地打印你的错误。如果你实现了 Error 特性,这就是必须的。一般来说,你对 Display 的实现应该给出一个关于出错原因的单行描述,可以很容易地被折叠到其他错误信息中。显示的格式应该是小写的,没有尾部的标点符号,这样可以很好地融入其他更大的错误报告。Debug 应该提供一个更具描述性的错误,包括在追踪错误原因时可能有用的辅助信息,如端口号、请求标识符、文件路径等,#[derive(Debug)] 通常足以满足这些要求。
注意:在旧的 Rust 代码中,你可能会看到对
Error::description方法的引用,但这已经被弃用了,转而使用Display。
第三,如果可能的话,你的类型应该同时实现 Send 和 Sync,这样用户就能够跨线程共享错误。如果你的错误类型不是线程安全的,你会发现几乎不可能在多线程环境下使用你的包。实现 Send 和 Sync 的错误类型也更容易使用,非常常见的 std::io::Error 类型能够包裹实现 Error、Send 和 Sync 的错误。当然,并不是所有的错误类型都能合理地实现 Send 和 Sync,比如它们与特定的线程本地资源相联系,这也没关系。你可能也不会跨越线程边界发送这些错误。然而,在你把 Rc<String>和 RefCell<bool>类型放在你的错误中之前,这是要注意的事情。
最后,如果可能的话,你的错误类型应该是 'static。这样做最直接的好处是,它允许调用者更容易地在调用堆栈中传播你的错误,而不会遇到生存期问题。它还可以使你的错误类型更容易被用于类型消除的错误类型,我们很快就会看到。
不透明的错误
现在让我们考虑一个不同的例子:一个图像解码库。你给这个库一串字节去解码,它让你访问各种图像操作方法。如果解码失败,用户需要能够弄清楚如何解决这个问题,因此必须了解其原因。但是,原因是图像头中的大小字段无效,还是压缩算法未能解压一个块,这很重要吗?也许不重要,即使知道确切的原因,应用程序也无法从这两种情况下有意义地恢复。在这样的情况下,作为库的作者,你可能想提供一个单一的、不透明的错误类型。这也会使你的库更容易使用,因为只有一个错误类型在任何地方使用。这个错误类型应该实现 Send 、Debug、Display和 Error(包括适当的 source 方法),但除此之外,调用者不需要知道更多的东西。你可以在内部表示更精细的错误状态,但没有必要将这些暴露给库的用户。这样做只会不必要地增加你的 API 的大小和复杂性。
你的不透明的错误类型到底应该是什么,主要取决于你。它可以是一个具有所有私有字段的类型,只公开有限的方法来显示和反省错误,或者它可以是一个严重的类型消除的错误类型,如 Box<dyn Error + Send + Sync + 'static>,它只透露了它是一个错误的事实,一般不会让用户反省。决定让你的错误类型有多不透明,主要是看错误除了描述之外是否有什么有趣的地方。使用 Box<dyn Error>,你让你的用户没有什么选择,只能把你的错误冒出来。如果它确实没有任何有价值的信息呈现给用户,例如,如果它只是一个动态的错误信息,或者是来自你程序深处的大量不相关的错误之一,这可能是好的。但是如果这个错误有一些有趣的方面,例如行号或状态代码,你可能想通过一个具体但不透明的类型来暴露它。
注意:一般来说,社区的共识是,错误应该是罕见的,因此不应该给 "快乐路径 "增加很多成本。出于这个原因,错误通常被放置在一个指针类型的后面,比如
Box或Arc。这样一来,它们就不可能给它们所包含的整个结果类型的大小增加很多。
使用类型消除的错误的一个好处是,它允许你轻松地结合来自不同来源的错误,而不必引入额外的错误类型。也就是说,基于类型的错误通常可以很好地组合,并允许你表达一个开放式的错误集。如果你写了一个返回类型为 Box<dyn Error + ...> 的函数,那么你可以在该函数内的不同错误类型中使用 ? ,在各种不同的错误上使用,它们都会被转化为那个共同的错误类型。
在擦除的背景下,Box<dyn Error + Send + Sync + 'static>上的 'static 约束值得多花一点时间来研究。我在上一节中提到,它的作用是让调用者传播错误,而不用担心失败的方法的生存期约束,但它有一个更大的目的:访问降级。下转换是指将一种类型的项目转换为一种更具体的类型。这是 Rust 在运行时让你访问类型信息的少数情况之一;它是动态语言经常提供的更普遍的类型反射的一个有限情况。在错误的上下文中,当 dyn Error 原本是一个具体的底层错误类型时,下转换允许用户将该错误转为该类型。例如,如果用户收到的错误是 std::io::Error 的类型 std::io::ErrorKind::WouldBlock,用户可能想采取一个特定的行动,但在其他情况下他们不会采取同样的行动。如果用户得到一个 dyn Error,他们可以使用 Error::downcast_ref 来尝试将这个错误下移到 std::io::Error``。downcast_ref 方法返回一个 Option,它告诉用户下转换是否成功。这里有一个关键的观察点:downcast_ref 只有在参数是 'static 时才起作用。如果我们返回一个不透明的、非 'static 的 Error,我们就剥夺了用户进行这种错误反省的能力。
在生态系统中,对于一个库的类型消除的错误(或者更广泛地说,它的类型消除的类型)是否是其公共和稳定 API 的一部分,存在一些分歧。也就是说,如果你的库中的方法 foo 将 lib::MyError 作为 Box<dyn Error> 返回,将 foo 改为返回不同的错误类型是否是一种破坏性的改变?类型签名并没有改变,但是用户可能写了一些代码,认为他们可以使用降频来把这个错误转回 lib::MyError。我对此事的看法是,你选择返回 Box<dyn Error> (而不是 lib::MyError) 是有原因的,除非有明确的文档说明,否则这并不能保证有什么特别的下转换。
注意:虽然
Box<dyn Error + ...>是一个有吸引力的类型擦除的错误类型,但它本身并没有实现 Error,这与直觉相反。 因此,考虑在实现 Error 的库中添加你自己的 BoxError 类型以实现类型擦除。
你可能想知道 Error::downcast_ref 如何做到安全。也就是说,它如何知道提供的 dyn Error 参数是否确实属于给定的类型 T?标准库中甚至有一个名为 Any 的特性,它是为任何类型实现的,它为 dyn Any 实现了 downcast_ref,这怎么能行?答案在于编译器支持的类型 std::any::TypeId,它允许你为任何类型获得一个唯一的标识符。Error trait 有一个隐藏提供的方法,叫做 type_id,它的默认实现是返回 TypeId::of::<Self>()。类似地,Any 对于 T 有一个 impl Any 的覆盖实现,在该实现中,其 type_id 返回相同的内容。在这些 impl 块的上下文中,Self 的具体类型是已知的,所以这个 type_id 是真实类型的类型标识符。downcast_ref 调用 self.type_id,它通过动态大小类型的 vtable(见第 2 章)转发到底层类型的实现,并将其与提供的 downcast 类型的类型标识符进行比较。如果它们匹配,那么 dyn Error 或 dyn Any 背后的类型就真的是 T,并且从一个类型的引用到另一个类型的引用是安全的。
特殊错误案例
有些函数是易错的,但如果失败了也不能返回任何有意义的错误。从概念上讲,这些函数的返回类型是Result<T, ()>。在一些代码库中,你可能会看到它被表示为 Option<T>。虽然这两个函数的返回类型都是合法的选择,但它们表达了不同的语义,你通常应该避免将 Result<T, ()> "简化"为 Option<T>。 Err(()) 表示一个操作失败了,应该重试、报告或以其他方式例外处理。另一方面,None 只表达了函数没有任何东西可以返回;它通常不被认为是一个特殊情况或应该被处理的东西。你可以在结果类型的 #[must_use] 注解中看到这一点--当你得到一个结果时,语言期望处理这两种情况是很重要的,而对于一个选项,两种情况实际上都不需要处理。
注意:你还应该记住,
()并没有实现Error特性。这意味着它不能被类型化为Box<dyn Error>,并且在使用时可能会有点麻烦。出于这个原因,在这些情况下,定义你自己的单元结构类型,为其实现Error,并将其作为错误,而不是()。
有些函数,比如那些启动持续运行的服务器循环的函数,只返回错误;除非发生错误,否则它们永远运行。其他函数永远不会出错,但需要返回一个结果,例如,为了匹配一个特征签名。对于这样的函数,Rust 提供了 never 类型,用 ! 的语法编写。never 类型表示一个永远不能生成的值。你不能自己构造这种类型的实例,唯一的方法是进入一个无限循环或恐慌,或通过其他一些编译器知道永远不会返回的特殊操作。对于 Result,当你有一个你知道永远不会被使用的 Ok 或 Err 时,你可以把它设置为 ! 类型。如果你写了一个返回 Result<T, !> 的函数,你将永远无法返回 Err,因为唯一的办法就是输入永远不会返回的代码。因为编译器知道任何带有 ! 的变体都不会被产生,所以它也可以考虑到这一点来优化你的代码,比如不生成 Result<T, !> 上 unwrap 的恐慌代码。而当你进行模式匹配时,编译器知道任何包含 ! 的变量甚至不需要被列出。相当不错!
最后一个奇怪的错误情况是错误类型 std::thread::Result。 这里是它的定义。
#![allow(unused)] fn main() { type Result<T> = Result<T, Box<dyn Any + Send + 'static>>; }
错误类型是类型清除的,但是它并没有像我们到目前为止所看到的那样被清除成一个 dyn Error。相反,它是一个动态 Any,它只保证错误是某种类型,仅此而已。..... 这根本算不上是一种保证。之所以出现这种奇怪的错误类型,是因为 std::thread::Result 的错误变量只在响应恐慌时产生;具体来说,如果你试图加入一个已经恐慌的线程。 在这种情况下,除了忽略错误或使用 unwrap 使自己恐慌外,加入的线程能做的事情并不多。从本质上讲,错误类型是 "恐慌",其值是传递给 panic! 的任何参数,它确实可以是任何类型(尽管它通常是一个格式化的字符串)。
传播错误
Rust 的 ? 操作符是解包或提前返回的速记工具,用于轻松处理错误。但它也有一些值得了解的其他技巧。首先,? 通过 From 特性执行类型转换。在一个返回 Result<T, E> 的函数中,你可以在任何 Result<T, X> 上使用 ? ,其中 E: From<X> 。这就是通过 Box<dyn Error>消除错误的特点;你可以在任何地方使用 ? 而不用担心特定的错误类型,而且通常会 "正常工作"。
FROM 和 INTO
标准库有许多转换特性,但其中两个核心特性是 From 和 Into。你可能会觉得有两个很奇怪:如果我们有 From,为什么还需要 Into,反之亦然?有几个原因,但让我们从历史原因开始:在 Rust 的早期,由于第二章中讨论的一致性规则,不可能只有一个。或者,更确切地说,一致性规则曾经是什么。
假设你想在你的板块中定义的某个本地类型和标准库中的某个类型之间实现双向转换。你可以为写
impl<T> From<Vec<T>> for MyType<T>和impl<T> Into<Vec<T>> for MyType<T>,但是如果你只有From或Into,你必须写impl<T> From<MyType<T>> for Vec<T>或impl<T> Into<MyType<T>> for Vec<T>。然而,编译器曾经拒绝了这些实现!只有从 Rust 1.41.0 开始,当覆盖类型的例外被添加到一致性规则中时,它们才合法。在那之前,有必要同时拥有这两个特性。由于很多 Rust 代码是在 Rust 1.41.0 之前写的,所以现在这两个特征都不能被删除。然而,除了这一历史事实之外,即使我们今天可以从头开始,也有很好的人体工程学理由来拥有这两个特征。在不同的情况下,使用一个或另一个往往会明显地更容易。例如,如果你正在写一个方法,该方法接收一个可以变成
Foo的类型,你是想写fn(implot Into<Foo>)还是fn<T>(T) where Foo: From<T>?反过来说,要把一个字符串变成一个语法标识符,你是愿意写Ident::from("foo")还是<_ as Into<Ident>>::into("foo")?这两个特性都有其用途,我们最好同时拥有它们。鉴于我们确实有这两样东西,你可能想知道你今天应该在你的代码中使用哪一个。 答案是非常简单的:实现
From,并在约束使用Into。原因是Into对任何实现了From的T都有一个覆盖实现,所以不管一个类型是明确地实现了From还是Into,它都实现了Into!当然,正如简单的事情经常发生一样,故事并没有完全结束。因为当 Into 被用作绑定时,编译器经常要 "通过" 覆盖实现,所以一个类型是否实现了
Into的推理比它是否实现了From更复杂。而且在某些情况下,编译器还没有聪明到可以解决这个难题。由于这个原因,在编写本文时,?操作符使用From,而不是Into。大多数时候,这并没有什么区别,因为大多数类型都实现了 From,但这确实意味着来自旧库的错误类型实现了Into,但可能无法与?随着编译器越来越聪明,?可能会被 "升级" 为使用Into,到那时这个问题就会消失,但这是我们现在的情况。
需要注意的第二个方面是,? 这个操作符实际上只是一个暂称为 Try 的特性的语法糖。在写这篇文章的时候,Try 特性还没有稳定下来,但是当你读到这篇文章的时候,它或者类似的东西很可能已经被确定下来。由于细节还没有全部弄清楚,我将只给你一个 Try 工作原理的大纲,而不是完整的方法特征。在其核心部分,Try 定义了一个封装类型,其状态要么是进一步计算是有用的(快乐路径),要么是没有用的。你们中的一些人会正确地想到单体,尽管我们不会在这里探讨这种联系。例如,在 Result<T, E> 的情况下,如果你有一个 Ok(t),你可以通过解开 t 来继续进行快乐的路径。另一方面,如果你有一个 Err(e),你想停止执行并立即产生错误值,因为你没有 t,所以不可能进一步计算。
Try 的有趣之处在于它适用于更多的类型,而不仅仅是结果。例如,一个 Option<T> 遵循同样的模式--如果你有一个 Some(t),你可以在快乐路径上继续下去,而如果你有一个 None,你想产生 None 而不是继续。这种模式延伸到了更复杂的类型,比如 Poll<Result<T, E>>,它的快乐路径类型是 Poll<T>,这使得 ? 适用的情况远比你想象的多。当 Try 稳定下来后,我们可能会看到 ? 开始与各种类型一起工作,使我们的快乐路径代码更漂亮。
? 操作符已经可以在易错函数、doctests 和 fn main 中使用了。不过,为了充分发挥它的潜力,我们还需要一种方法来对这种错误进行范围处理。例如,考虑清单 4-2 中的函数。
#![allow(unused)] fn main() { fn do_the_thing() -> Result<(), Error> { let thing = Thing::setup()?; // .. code that uses thing and ? .. thing.cleanup(); Ok(()) } // 清单 4-2:使用"? "运算符的多步骤易错函数。 }
这并不完全像预期的那样工作。在 setup 和 cleanup 之间的任何问题都会导致整个函数的提前返回,从而跳过 cleanup 的代码!这就是 try 块要解决的问题。一个尝试块的行为就像一个单次迭代的循环,其中 ? 使用 break 而不是 return,并且该块的最后表达式有一个隐含的 break。我们现在可以修改清单 4-2 中的代码,使其总是进行清理,如清单 4-3 所示
#![allow(unused)] fn main() { fn do_the_thing() -> Result<(), Error> { let thing = Thing::setup()?; let r = try { // .. code that uses thing and ? .. }; thing.cleanup(); r } // 清单 4-3:一个多步骤的易错函数,总是自己清理。 }
在写这篇文章时,"尝试块 "也不稳定,但对其有用性有足够的共识,它们很可能以类似于这里描述的形式出现。
总结
本章介绍了在 Rust 中构造错误类型的两种主要方式:枚举和擦除。我们研究了什么时候你可能想要使用这两种方式,以及这两种方式的优点和缺点。我们还看了一些 ? 操作符的幕后内容,并考虑了 ? 如何在未来变得更加有用。在下一章中,我们将从代码中抽身出来,看看你是如何构建 Rust 项目的。我们将研究特征标志、依赖管理和版本管理,以及如何使用工作区和子板块管理更复杂的板块。下一页见!
第五章 项目结构
本章提供了一些结构化 Rust 项目的想法。对于简单的项目来说,由 cargo new 设置的结构可能是你很少考虑的。你可能会添加一些模块来分割代码,并添加一些额外功能的依赖关系,但仅此而已。然而,随着项目规模和复杂性的增加,你会发现你需要超越这些。也许你的 crate 的编译时间已经失控了,或者你需要条件依赖,或者你需要一个更好的持续集成策略。在这一章中,我们将看看 Rust 语言,特别是 Cargo 提供的一些工具,使我们更容易管理这些东西。
特点(Features)
features 是 Rust 定制项目的主要工具。在其核心部分,feature 只是一个构建标志,crate 可以传递给他们的依赖,以增加可选功能。features 本身并没有语义,相反,你可以选择一个 feature 对你的 crate 意味着什么。
一般来说,我们以三种方式使用 features:启用可选的依赖关系,有条件地包含 crate 的额外组件,以及增强代码的行为。请注意,所有这些用途都是附加的;features 可以增加 crate 的功能,但它们通常不应该做诸如删除模块或替换类型或函数签名的事情。这源于这样一个原则:如果开发者对他们的 Cargo.toml 做了一个简单的修改,比如添加一个新的依赖关系或启用一个 feature,这不应该使他们的 crate 停止编译。如果一个 crate 有相互排斥的 features,那么这个原则很快就会失效,如果 crate A 依赖于 crate C 的一个 features,而 crate B 依赖于 C 的另一个相互排斥的 features,那么增加对 crate B 的依赖就会破坏 crate A! 出于这个原因,我们通常遵循这样的原则:如果 crate A 能够针对 crate C 的某些 features 进行编译,那么在 crate C 上启用所有 features 时,它也应该能够编译。
Cargo 很难偏向这一原则。例如,如果两个 crate (A 和 B) 都依赖于 crate C,但它们各自在 C 上启用不同的功能,Cargo 将只编译 crate C 一次,并带有 A 或 B 所需要的所有功能。也就是说,它需要将 C 所要求的 features 通过 A 和 b 结合起来。因此,通常很难向 Rust crate 中添加互排斥的 features;可能的情况是,两个依赖项将依赖于具有不同功能的 crate,如果这些功能是相互排斥的,下游 crate 将无法构建。
注意:我强烈建议您配置您的持续集成基础设施,以检查您的机箱编译的任何组合的功能。一个帮助你做到这一点的工具是 cargo-hack,你可以在
https://github.com/taiki-e/cargo-hack上找到它
定义和包含 features(Defining and Including Features)
features 在 Cargo.toml 中定义。清单 5-1 展示了一个名为 foo 的 crate 示例,它有一个简单的 feature,启用可选依赖项 syn。
#![allow(unused)] fn main() { [package] name = "foo" ... [features] derive = ["syn"] [dependencies] syn = { version = "1", optional = true } // 清单 5-1:启用可选依赖项的 features }
当 Cargo 编译这个 crate 时,默认情况下它不会编译 syn crate,这减少了编译时间(通常是显著的)。只有当下游的组件需要使用 derive features 所启用的 api 并显式选择使用时,才会编译 syn 组件。清单 5-2 显示了这样一个下游框条如何启用派生 features,从而包括 syn 依赖。
#![allow(unused)] fn main() { [package] name = "bar" ... [dependencies] foo = { version = "1", features = ["derive"] } // 清单 5-2:启用依赖项的 features }
有些功能使用得非常频繁,所以让一个 crate 选择不使用这些功能比选择使用这些功能更有意义。为了支持这一点,Cargo 允许你为一个 crate 定义一组默认的 features。同样,它也允许你选择不使用依赖关系的默认 features。清单 5-3 显示了 foo 如何使其 derive features 在默认情况下被启用,同时选择退出 syn 的一些默认 features,而只启用 derive features 所需的 features。
#![allow(unused)] fn main() { [package] name = "foo" ... [features] derive = ["syn"] default = ["derive"] [dependencies.syn] version = "1" default-features = false features = ["derive", "parsing", "printing"] optional = true // 清单 5-3:添加和选择默认 features,以及可选的依赖项 }
在这里,如果一个 crate 依赖于 foo,并且没有明确选择退出默认 features,它也会编译 foo 的 syn 依赖关系。这样选择不使用默认 features,只选择自己需要的 features,是减少编译时间的一个好方法。
作为 features 的可选依赖项
当你定义一个 feature 时,等号后面的列表本身就是一个 features 列表。 这听起来可能有点奇怪,在清单 5-3
中,syn是一个依赖项,而不是一个 features。事实证明,Cargo 将每个可选的依赖关系都变成了与该依赖关系同名的 features。如果你试图添加一个与可选依赖关系同名的 feature,你会发现这一点;Cargo 不允许这样做。Cargo 正在为 features 和依赖关系提供不同的命名空间支持,但在撰写本文时还没有稳定下来。同时,如果你想让一个 features 以依赖关系命名,你可以用package = ""来重命名依赖关系,以避免名称冲突。一个 feature 启用的 features 列表也可以包括依赖关系的 features。例如,你可以写derive = ["syn/derive"]来让你的derivefeature 启用syn依赖关系的derivefeature。
在你的 crate 中使用 features
当使用 features 时,你需要确保你的代码只在依赖性可用时才使用它。如果你的 feature 启用了一个特定的组件,你需要确保如果该 feature 没有被启用,该组件就不会被包括在内。
你可以使用条件编译来实现这一点,它允许你使用注释来给出某段代码应该或不应该被编译的条件。条件性编译主要通过#[cfg] 属性来表达。还有一个密切相关的 cfg!宏,它可以让你根据类似的条件改变运行时行为。你可以用条件编译做各种各样的事情,我们在本章后面会看到,但最基本的形式是 #[cfg(feature = "some-feature")] ,它使源代码中的下一个 "东西" 只有在启用了 some-feature feature 时才会被编译。类似地,if cfg!(feature = "some-feature") 等同于 if true 只有在启用 derive feature 时才会被编译(否则为 false)。
#[cfg] 属性比 cfg! 宏更常被使用,因为宏会根据 feature 修改运行时行为,这就很难保证 features 是相加的。你可以把 #[cfg] 放在某些 Rust 项目的前面--比如函数和类型定义、impl 块、模块和 use 语句--也可以放在某些其他结构上,比如结构域、函数参数和语句。但是 #[cfg] 属性不能随便出现;它可以出现的地方是由 Rust 语言团队仔细限制的,这样就可以避免条件编译造成过于奇怪和难以调试的情况。
请记住,修改你的 API 的某些公共部分可能会无意中使一个 feature 变得不具附加性,这反过来会使一些用户无法编译你的 crate。你通常可以使用向后兼容的修改规则作为这里的经验法则--例如,如果你使一个枚举变量或一个公共结构字段成为一个 feature 的条件,那么该类型也必须用 #[non_exhaustive] 来注释。否则,如果由于依赖关系树中的第二个 crate 添加了该 feature,那么没有启用该 feature 的依赖 crate 可能就无法再进行编译。
注意
如果你正在编写一个大的 crate,你期望你的用户只需要其中的一个子集的功能,你应该考虑使较大的组件(通常是模块)被功能所保护。这样,用户可以选择加入,并支付编译成本,只有他们真正需要的部分。
工作区(Workspaces)
Crates 在 Rust 中扮演着许多角色--它们是依赖关系图中的顶点,是 trait 一致性的边界,也是编译 features 的范围。正因为如此,每个 crate 都被当作一个单独的编译单元来管理;Rust 编译器或多或少地把 crate 当作一个大的源文件来编译,最终变成一个单一的二进制输出(无论是二进制还是库)。
虽然这简化了编译器的许多方面,但它也意味着大的 crate 在工作时可能会很痛苦。如果你在应用程序的某个部分改变了一个单元测试、一个注释或一个类型,编译器必须重新评估整个 crate,以确定有什么变化,如果有的话。在内部,编译器实现了一些机制来加速这一过程,如增量重新编译和并行代码生成,但最终你的编译器的大小是你的项目需要多长时间来编译的一个重要因素。
由于这个原因,随着项目的发展,你可能想把它分成多个内部相互依赖的 crate。Cargo 有一个方便的功能:工作区(workspace)。 工作区是一个 crate(通常称为子 crate)的集合,由一个顶级的 Cargo.toml 文件连接起来,如清单 5-4 所示。
#![allow(unused)] fn main() { [workspace] members = [ "foo", "bar/one", "bar/two", ] // 清单 5-4:工作空间 }
members 数组是一个目录列表,每个目录都包含工作区中的一个 crate。这些 crate 在自己的子目录中都有自己的 Cargo.toml 文件,但它们共享一个 Cargo.lock 文件和一个输出目录。crate 的名字不需要与 member 中的条目相匹配。一个工作区中的 crate 共享一个名称前缀是很常见的,但不是必须的,通常选择 "主" crate 的名称。例如,在 tokio crate 中,成员被称为 tokio、tokio-test、tokio-macros,等等。
也许工作区最重要的 feature 是,你可以通过在工作区的根部调用 cargo 来与工作区的所有成员进行交互。想检查它们是否都能编译,cargo check 会检查它们。想运行所有的测试? cargo test 会对它们进行测试。这并不像把所有东西都放在一个 crate 里那么方便,所以不要把所有东西都拆成小 crate,但这是一个相当好的近似值。
注意:货物命令通常会在工作区做 "正确的事情"。如果你需要消除歧义,比如两个工作区的 crate 都有一个同名的二进制文件,请使用
-p标志(代表软件包)。如果你在一个特定工作区的子目录中,你可以通过--工作区来执行整个工作区的命令。
一旦你有了工作区级的 Cargo.toml 和工作区成员的数组,你就可以使用路径依赖来设置你的 crate 相互依赖,如清单 5-5 所示。
#![allow(unused)] fn main() { bar/two/Cargo.toml [dependencies] one = { path = "../one" } bar/one/Cargo.toml [dependencies] foo = { path = "../../foo" } // 清单 5-5:工作区 crate 之间的 crate 依赖关系 }
现在,如果你对 bar/two 中的 crate 做了改变,那么只有这个 crate 被重新编译,因为 foo 和 bar/one 并没有改变。从头开始编译你的项目甚至可能更快,因为编译器不需要评估你的整个项目源以寻找优化机会。
指定工作区内部的依赖关系
最明显的方法是使用路径指定器来指定工作空间中的一个 crate 依赖于另一个 crate,如清单 5-5 所示。然而,如果你的单个子 crate 是用于公共消费的,你可能想使用版本指定器来代替。
假设你有一个依赖于清单 5-5 中 bar 工作区的一个 crate 的 Git 版本,其中
one = { git = ". . ." .},以及 foo 的发布版本(也来自 bar),foo = "1.0.0"。Cargo 会尽职尽责地获取存放整个 bar 工作区的 Git 仓库,并看到该仓库又依赖于工作区中位于.../../foo的 foo。但 Cargo 不知道发布的版本foo = "1.0.0"和 Git 仓库中的 foo 是同一个 crate!它认为它们是两个独立的依赖关系。它认为它们是两个独立的依赖项,只是碰巧名字相同而已。你可能已经看到了这是怎么回事。如果你试图用一个接受
foo类型的 API 来使用foo(1.0.0)中的任何类型,编译器会拒绝该代码。即使这些类型有相同的名字,编译器也不能知道它们是同一个底层类型。而用户会被彻底搞糊涂,因为编译器会说 "预期foo::Type,得到foo::Type"这样的话。缓解这个问题的最好方法是,只有在子系统之间依赖未发布的变化时才使用路径依赖。只要一个人与 foo 1.0.0 一起工作,它就应该在其依赖关系中列出 foo = "1.0.0"。只有当你对 foo 做了一个人们需要的改变时,你才应该改变一个人,以使用路径依赖。一旦你发布了一个可以依赖的新版本的 foo,你应该再次删除路径依赖。
这种方法也有其不足之处。现在,如果你改变了 foo,然后运行一个的测试,你会看到一个将使用旧的 foo 进行测试,这可能不是你预期的。你可能想配置你的持续集成基础设施,以测试每个子程序,同时使用其他子程序的最新发布版本,并将所有子程序配置为使用路径依赖。
项目配置
运行 cargo new 后,你会得到一个最小的 Cargo.toml,其中有 crate 的名称、版本号、一些作者信息和一个空的依赖列表。这可以让你走得更远,但随着项目的成熟,你可能想在 Cargo.toml 中添加一些有用的东西。
包元数据(Crate Metadata)
首先要添加到 Cargo.toml 中的是所有 Cargo 支持的元数据指令,也是最明显的。除了描述和主页等显而易见的字段外,还可以包括一些信息,如 crate 的 README 路径(readme)、与 cargo run 一起运行的默认二进制文件(default-run),以及帮助 cates.io 对 crate 进行分类的额外关键词和类别。
对于具有更复杂的项目布局的 crate,设置包括和排除元数据字段也很有用。这决定了哪些文件应该包括在你的包中并发布。默认情况下,Cargo 会包含一个 crate 目录下的所有文件,除了你的 .gitignore 文件中列出的文件,但如果你在同一目录下还有大型测试装置、不相关的脚本或其他辅助数据,而你又希望它们处于版本控制之下,这可能不是你想要的。正如它们的名字所示,include 和 exclude 分别允许你只包括一组特定的文件或排除与一组给定模式相匹配的文件。
注意:如果你有一个永远不应该被发布的 crate,或者只应该被发布到某些替代的注册中心(也就是说,不应该被发布到 crates.io),你可以将
publish指令设置为false或者允许的注册中心列表。
你可以使用的元数据指令的列表在继续增加,所以请确保定期查看 Cargo 参考资料中的清单格式页面(https://doc.rustlang.org/cargo/reference/manifest.html)。
构建配置(Build Configuration)
Cargo.toml 还可以让你控制 Cargo 如何构建你的 crate。最明显的工具是 build 参数,它允许你为你的 crate 编写一个完全自定义的构建程序(我们将在第 11 章重新讨论这个问题)。然而,Cargo 还提供了两个较小的,但非常有用的机制,我们将在这里探讨:补丁和配置文件。
[patch]
Cargo.toml 的 [patch] 部分允许你为一个依赖指定一个不同的来源,你可以暂时使用,无论这个被修补的依赖出现在你的依赖中的哪个位置。当你需要针对某个横向依赖的修改版本来编译你的 crate,以测试错误修复、性能改进或即将发布的新的次要版本时,这一点非常宝贵。清单 5-6 显示了一个关于如何临时使用一组依赖关系的变体的例子。
#![allow(unused)] fn main() { [patch.crates-io] use a local (presumably modified) source regex = { path = "/home/jon/regex" } use a modification on a git branch serde = { git = "https://github.com/serde-rs/serde.git", branch = "faster" } patch a git dependency [patch.'https://github.com/jonhoo/project.git'] project = { path = "/home/jon/project" } // 清单 5-6:使用 `[patch]` 重写 Cargo.toml 中的依赖源 }
即使你打了一个依赖关系的补丁,Cargo 也会注意检查 crate 的版本,这样你就不会意外地打错 crate 的主要版本了。如果你出于某种原因,过渡性地依赖同一个 crate 的多个主要版本,你可以给每个 crate 打上不同的标识符,如清单 5-7 中所示。
#![allow(unused)] fn main() { [patch.crates-io] nom4 = { path = "/home/jon/nom4", package = "nom" } nom5 = { path = "/home/jon/nom5", package = "nom" } // 清单 5-7: 使用 `[patch]` 在 Cargo.toml 中重写同一 crate 的多个版本 }
Cargo 会查看每个路径中的 Cargo.toml,意识到 /nom4 包含主版本 4,/nom5 包含主版本 5,并对这两个版本进行适当的修补。package 关键字告诉 Cargo 在这两种情况下以 nom 的名字来寻找一个 crate,而不是像默认情况下那样使用依赖标识符(左边的部分)。你也可以在你的常规依赖中这样使用 package 来重命名一个依赖!
请记住,当你发布一个 crate 时,补丁不会被考虑到上传的软件包中。依赖于你的 crate 的 crate 将只使用它自己的 [patch] 部分(可能是空的),而不是你的 crate 的部分。
crates VS. packages
你可能想知道包(package)和箱(crate)的区别是什么。这两个词在非正式场合经常互换使用,但它们也有具体的定义,这取决于你是在谈论 Rust 编译器、Cargo、crates.io,还是其他什么东西。我个人认为 crate 是一个 Rust 模块的层次结构,从一个根 .rs 文件开始(在这个文件中你可以使用 crate 级别的属性,如
#![feature])--通常是 lib.rs 或 main.rs 这样的文件。相比之下,包是一个 crate 和元数据的集合,所以基本上所有的东西都由 Cargo.toml 文件描述。这可能包括一个库 crate、多个二进制 crate、一些集成测试 crate,甚至可能包括多个工作区成员,这些成员本身也有 Cargo.toml 文件。
[profile]
[profile] 部分允许你向 Rust 编译器传递额外的选项,以改变它编译 crate 的方式。这些选项主要分为三类:性能选项、调试选项,以及以用户定义的方式改变代码行为的选项。它们都有不同的默认值,取决于你是在调试模式下还是在发布模式下编译(其他模式也存在)。
三个主要的性能选项是 opt-level、codegen-units 和 lto。opt-level 选项通过告诉编译器如何积极地优化你的程序来调整运行时的性能(0 代表 "完全没有",3 代表 "尽可能多")。设置越高,你的代码就越优化,这可能使它运行得更快。不过,额外的优化是以更高的编译时间为代价的,这就是为什么优化通常只在发布版本中启用。
注意:你也可以将 opt-level 设置为 "s",以优化二进制大小,这在嵌入式平台上可能很重要。
codegen-units 选项是关于编译时的性能。它告诉编译器,它允许将一个 crate 的编译分成多少个独立的编译任务(代码生成单元)。一个大 crate 的编译被分割成越多的部分,它的编译速度就越快,因为更多的线程可以帮助平行编译 crate。不幸的是,为了实现这种加速,这些线程需要或多或少地独立工作,这意味着代码优化受到影响。例如,想象一下,在一个线程中编译的 crate 可以从另一个 crate 中的一些代码的内联中受益--因为这两个 crate 是独立的,所以内联不能发生!这种设置是对的。那么,这种设置是对编译时性能和运行时性能的一种权衡。默认情况下,Rust 在调试模式下使用有效的无限制数量的 codegen 单元(基本上,"尽可能快地编译"),在发布模式下使用较少的数量(在撰写本文时为 16)。
lto 设置开启了链接时优化(LTO),它使编译器(或者链接器,如果你想从技术上了解它的话)共同优化你的程序的位,称为编译单元,这些单元最初是单独编译的。LTO 的具体细节超出了本书的范围,但其基本思想是,每个编译单元的输出包括有关进入该单元的代码的信息。在所有单元被编译后,链接器对所有单元进行另一次传递,并使用这些额外的信息来优化合并后的编译代码。这个额外的过程增加了编译时间,但恢复了大部分运行时的性能,这些性能可能是由于将编译分割成小部分而损失的。特别是,LTO 可以为那些对性能敏感的程序提供显著的性能提升,这些程序可能会从交叉比率优化中受益。不过要注意的是,交叉比率 LTO 会使你的编译时间增加很多。
Rust 默认在每个 crate 内的所有编码单元中执行 LTO,试图弥补因使用许多编码单元而造成的优化损失。由于 LTO 只在每个 crate 内执行,而不是跨 crate,所以这个额外的过程并不繁琐,而且增加的编译时间应该低于使用大量编码单元所节省的时间。Rust 还提供了一种被称为 "瘦 LTO "的技术,它允许 LTO 通道大部分被并行化,但代价是会错过一些 "完整 "LTO 通道会发现的优化。
注意: LTO 在很多情况下也可以用来跨越外部函数接口的边界进行优化。参见
linker-pluginlto rustc标志以了解更多细节。
[profile] 部分也支持帮助调试的标志,如 debug、debug-assertions 和 overflow-checks。debug 标志告诉编译器在编译的二进制文件中包含调试符号。这增加了二进制文件的大小,但这意味着你可以在回溯和配置文件中得到函数名称和其他东西,而不仅仅是指令地址。debug-assertions 标志启用了 debug_assert!宏和其他相关的调试代码,否则不会被编译(通过 cfg(debug_assertions))。这样的代码可能会使你的程序运行得更慢,但它使你更容易在运行时发现有问题的行为。溢出检查(overflow-checks)标志,顾名思义,可以对整数操作进行溢出检查。这使它们的速度变慢(注意到一个趋势了吗?),但可以帮助你在早期抓住棘手的错误。默认情况下,这些都是在调试模式下启用,在发布模式下禁用。
[profile.*.panic]
[profile] 部分有另一个值得单独讨论的标志:panic。这个选项决定了当你的程序中的代码调用 panic 的时候会发生什么,可以是直接调用,也可以是通过 unwrap 这样的东西间接调用。你可以将 panic 设置为 unwind(大多数平台上的默认值)或 abort。我们将在第 9 章中更多地讨论 panic 和 unwinding,但我将在这里做一个简单的总结。
通常在 Rust 中,当你的程序恐慌时,恐慌的线程开始解开它的栈。你可以认为解开堆栈是强行从当前函数递归到该线程的堆栈底部。也就是说,如果 main 调用了 foo,foo 调用了 bar,而 bar 调用了 baz,那么 baz 中的恐慌将强行从 baz 返回,然后是 bar,然后是 foo,最后是 main,导致程序退出。一个展开的线程会正常丢弃堆栈上的所有值,这给了值一个清理资源、报告错误等的机会。这给了运行中的系统一个机会,即使在恐慌的情况下也能优雅地退出。
当一个线程惊慌失措并展开(unwinds)时,其他线程继续运行,不受影响。只有当运行 main 的线程退出时,程序才会终止。也就是说,恐慌通常被隔离在发生恐慌的线程中。
这意味着展开(unwinding)是一把双刃剑;程序在一些失败的组件中蹒跚前行,这可能会导致各种奇怪的行为。例如,想象一下,一个线程在更新 Mutex 中的状态时,中途慌乱了。任何随后获得该 Mutex 的线程现在都必须准备好处理这一事实,即状态可能处于部分更新、不一致的状态。 出于这个原因,一些同步原语(如 Mutex)会记住它们最后被访问时是否发生了恐慌,并将其传达给随后试图访问该原语的任何线程。如果一个线程遇到了这样的状态,它通常也会恐慌,这将导致一个级联,最终终止整个程序。但这可以说比带着损坏的状态继续运行要好得多!
支持展开(unwinding)所需的簿记不是免费的,它通常需要编译器和目标平台的特殊支持。例如,许多嵌入式平台根本就不能有效地解开堆栈。因此,Rust 支持一种不同的恐慌模式:abort 确保整个程序在发生恐慌时立即退出。在这种模式下,没有线程可以做任何清理工作。 这看起来很严重,而且确实如此,但它确保了程序永远不会在半工作状态下运行,而且错误会立即显现。
警告
panic设置是全局性的--如果你把它设置为abort,你所有的依赖也会被编译成abort。
你可能已经注意到,当一个线程恐慌时,它往往会打印一个回溯:导致恐慌发生的函数调用的痕迹。这也是一种展开(unwinding)的形式,尽管它与这里讨论的展开恐慌行为是分开的。你可以通过给 rustc 传递 -Cforce-unwind-tables 即使在 panic=abort 情况下也可以进行回溯,这使得 rustc 包括回溯堆栈所需的信息,同时仍然可以终止恐慌的程序。
配置文件覆盖(PROFILE OVERRIDES)
你可以使用 profile overrides 为特定的依赖关系或特定的 profile 设置 profile 选项。例如,清单 5-8 显示了如何使用
[profile.<profile-name>.package.<crate-name>]语法,在调试模式下为serdecrate 启用积极的优化,为所有其他 crate 启用适度的优化。#![allow(unused)] fn main() { [profile.dev.package.serde] opt-level = 3 [profile.dev.package."*"] opt-level = 2 // 清单 5-8:覆盖特定依赖关系或特定模式的配置文件选项 }如果某些依赖在调试模式下速度过慢(如解压缩或视频编码),而你又需要对其进行优化,以便你的测试套件不至于花上几天时间来完成,那么这种优化覆盖就会很方便。你也可以在 ~/.cargo/config 的 Cargo 配置文件中使用
[profile.dev](或类似)部分来指定全局配置文件的默认值。当你为一个特定的依赖关系设置优化参数时,请记住,这些参数只适用于作为该 crate 的一部分而编译的代码;如果本例中的
serde有一个通用的方法或类型,而你在你的 crate 中使用该方法或类型,该方法或类型的代码将在你的 crate 中被单态化和优化,你 crate 的配置文件设置将适用,而不是serde的配置文件覆盖中的设置。
条件编译
你写的大多数 Rust 代码都是通用的--无论它在什么 CPU 或操作系统上运行,都会有同样的效果。但有时你必须做一些特别的事情,让代码在 Windows、ARM 芯片上运行,或者在针对特定平台应用二进制接口(ABI)进行编译时运行。或者你想在某个 CPU 指令可用时编写一个特定函数的优化版本,或者在持续集成(CI)环境中运行时禁用一些缓慢但无趣的设置代码。为了迎合这样的情况,Rust 提供了条件编译的机制,即只有在编译环境的某些条件为真时,才会编译一个特定的代码段。
我们用 cfg 关键字来表示有条件的编译,你在本章前面的 "在你的 crate 中使用 features"中看到过。它通常以#[cfg(condition)] 属性的形式出现,表示只有在条件为真时才会编译下一个项目。Rust 也有 #[cfg_attr(condition, attribute)],如果条件成立,则编译为 #[attribute],否则为 no-op。你也可以使用 cfg!(condition) 宏将 cfg 条件评估为布尔表达式。
每个 cfg 结构都需要一个由选项组成的单一条件,比如 feature = "some-feature",以及组合器 all、any 和 not,它们的作用可能就是你所期望的。选项要么是简单的名字,比如 unix,要么是键/值对,比如 feature 条件所使用的。
有许多有趣的选项,你可以使汇编依赖于此。让我们从最常见的到最不常见的来看看它们。
-
Feature 选项
你已经看过这些例子了。特征选项的形式是
feature = "name-of-feature",如果指定的特征被启用,则认为是真的。你可以使用组合器在一个条件中检查多个特征。例如,any(feature = "f1", feature = "f2")如果特征f1或特征f2被启用,则为真。 -
操作系统选项
这些使用键/值语法,键为
target_os,值为windows、macos和linux。你也可以用target_family来指定一个操作系统系列,它的值是windows或unix。 这些都很常见,它们已经有了自己的命名简式,所以你可以直接使用cfg(windows)和cfg(unix)。例如,如果你想让一个特定的代码段只在 macOS 和 Windows 上编译,你可以这样写。#[cfg(any(windows, target_os = "macos"))]。 -
上下文选项
这些可以让你根据特定的编译环境来定制代码。其中最常见的是测试选项,只有当编译箱在测试配置文件下被编译时,它才是真的。请记住,test 只为被测试的 crate 设置,而不是为它的任何依赖项设置。这也意味着,在运行集成测试时,测试不会在你的 crate 中设置;是集成测试在测试配置文件下被编译,而你的实际 crate 被正常编译(也就是没有设置测试)。这同样适用于
doc和doctest选项,它们分别只在构建文档或编译doctests时设置。 还有debug_assertions选项,它默认设置为调试模式。 -
Tool 选项
一些工具,像 clippy 和 Miri,设置了自定义选项(后面会有更多介绍),让你在这些工具下运行时自定义编译。通常,这些选项是以相关的工具命名的。例如,如果你想让一个特定的计算密集型测试不在 Miri 下运行,你可以给它一个属性
#[cfg_attr(miri, ignore)]。 -
架构选项
这些可以让你根据编译器所针对的 CPU 指令集来进行编译。你可以用
target_arch来指定一个特定的架构,它的值是x86、mips和arch64,或者你可以用target_feature来指定一个特定的平台特性,它的值是avx或sse2。对于非常低级的代码,你可能还会发现target_endian和target_pointer_width选项很有用。 -
编译器选项
这些可以让你的代码适应它所编译的平台 ABI,并且可以通过
target_env的值(如gnu、msvc和musl)获得。由于历史原因,这个值通常是空的,特别是在 GNU 平台上。通常只有当你需要直接与环境 ABI 对接时才需要这个选项,例如当使用#[link]与 ABI 特定的符号名链接时。
虽然 cfg 条件通常是用来定制代码的,但有些也可以用来定制依赖关系。例如,依赖关系 winrt 通常只在 Windows 上有意义,而 nix crate 可能只在基于 Unix 的平台上有用。清单 5-9 给出了一个如何使用 cfg 条件的例子。
#![allow(unused)] fn main() { [target.'cfg(windows)'.dependencies] winrt = "0.7" [target.'cfg(unix)'.dependencies] nix = "0.17" // 清单 5-9:条件性依赖 }
在这里,我们指定 winrt 0.7 版仅在 cfg(windows) 下被视为依赖(所以,在 Windows 上),而 nix 0.17 版仅在 cfg(unix) 下被视为依赖(所以,在 Linux、macOS 和其他基于 Unix 的平台上)。有一点需要注意的是,[dependencies] 部分是在构建过程的早期进行评估的,当时只有某些 cfg 选项可用。特别是,feature 和上下文选项在这个时候还不能用,所以你不能用这个语法来拉入基于 feature 和上下文的依赖。然而,你可以使用任何只依赖于目标规范或架构的 cfg,以及任何由调用 rustc 的工具明确设置的选项(如 cfg(miri))。
注意:在我们讨论依赖性规范的时候,我强烈建议你设置你的 CI 基础设施,使用
cargo-deny和cargo-audit等工具对你的依赖性进行基本审计。这些工具可以检测到以下情况:你过渡性地依赖某个特定依赖的多个主要版本,你依赖未维护或有已知安全漏洞的 crate,或者你使用你可能想要避免的许可证。使用这样的工具是以自动化的方式提高你的代码库质量的一个好方法。
添加你自己的自定义条件编译选项也很简单。你只需要确保当 rustc 编译你的 crate 时,--cfg=myoption 被传递给 rustc。最简单的方法是将你的 --cfg 添加到 RUSTFLAGS 环境变量中。这在 CI 中很有用,你可能想根据你的测试套件是在 CI 上还是在开发机器上运行来定制它:在你的 CI 设置中把 --cfg=ci 添加到 RUSTFLAGS,然后在你的代码中使用 cfg(ci) 和 cfg(not(ci))。这样设置的选项也可以在 Cargo.toml 的依赖关系中使用。
版本控制(Versioning)
所有的 Rust crates 都是有版本的,并且要遵循 Cargo 的语义版本控制的实现。语义版本管理规定了什么样的变更需要什么样的版本增加,以及哪些版本被认为是兼容的,以及以什么样的方式兼容的规则。RFC 1105 标准本身非常值得一读(它的技术含量并不高),但总结起来,它区分了三种变化:破坏性变化,需要进行重大的版本变更;增加,需要进行小的版本变更;以及错误修复,只需要进行补丁版本变更。RFC 1105 很好地概述了什么是 Rust 中的破坏性修改,我们在本书的其他地方也提到了它的某些方面。
我不会在这里详述不同类型变化的确切语义。相反,我想强调一些在 Rust 生态系统中出现的不太直接的版本号方式,当你决定如何对你自己的 crate 进行版本管理时,你需要记住这些方式。
最小支持的 Rust 版本
第一个 Rust 主义是最小支持的 Rust 版本(MSRV)。 在 Rust 社区有很多关于项目在 MSRV 和版本划分时应该遵守什么政策的争论,而且没有真正好的答案。问题的核心在于,一些 Rust 用户被限制在使用旧版本的 Rust,通常是在企业环境中,他们没有什么选择。 如果我们不断利用新稳定的 API,这些用户将无法编译我们的 crates 的最新版本,将被抛弃。
有两种方法可以使处于这种情况的用户生活得更轻松。第一种是建立一个 MSRV 政策,承诺新版本的程序包将总是与过去 X 个月的任何稳定版本一起编译。具体数字不一,但 6 或 12 个月是很常见的。在 Rust 的六周发布周期中,这分别对应于最近的四个或八个稳定版本。任何引入项目的新代码都必须用 MSRV 编译器进行编译(通常由 CI 检查),或者被保留到 MSRV 政策允许它被合并为原样。这有时会很麻烦,因为这意味着这些 crate 不能利用语言所能提供的最新和最好的东西,但它会使你的用户生活得更轻松。
第二个技巧是确保在 MSRV 发生变化时,增加你的 crate 的次要版本号。因此,如果你发布了 2.7.0 版本的 crate,并将你的 MSRV 从 Rust 1.44 增加到 Rust 1.45,那么一个停留在 1.44 上并依赖于你的 crate 的项目可以使用依赖版本指定符 version = "2, <2.7" 来保持项目的工作,直到它可以转移到 Rust 1.45 上。重要的是,你要增加次要版本,而不仅仅是补丁版本,这样你就可以在必要时通过发布另一个补丁来为之前的 MSRV 版本发布关键的安全修复。
一些项目对他们的 MSRV 支持如此重视,以至于他们认为 MSRV 的变化是一个破坏性的变化,并增加了主要版本号。这意味着下游项目将明确地选择加入 MSRV 变化,而不是选择退出,但这也意味着那些没有如此严格的 MSRV 要求的用户,如果不更新他们的依赖关系,就无法看到未来的错误修复,这可能需要他们也发布一个破坏性的变化。正如我所说,这些解决方案没有一个是没有缺点的。在今天的 Rust 生态系统中执行 MSRV 是一个挑战。只有一小部分 crate 提供了任何 MSRV 保证,即使你的依赖关系提供了保证,你也需要不断地监控它们,以了解它们何时增加 MSRV。当他们这样做的时候,你需要用前面提到的限制性的版本范围来发布你的 crate,以确保你的 MSRV 不会改变。这可能反过来迫使你放弃对你的依赖关系的安全和性能更新,因为你将不得不继续使用旧版本,直到你的 MSRV 政策允许更新。而这个决定也会延续到你的依赖者身上。有人提议将 MSRV 检查纳入 Cargo 本身,但截至目前,还没有任何可行的方案被稳定下来。
最小的依赖版本
当你第一次添加一个依赖关系时,并不总是很清楚你应该给这个依赖关系一个什么样的版本说明。程序员通常会选择最新的版本,或者只选择当前的主要版本,但这两种选择都有可能是错误的。我所说的 "错误 "并不是指你的 crate 不能编译,而是指做出这个选择可能会给你的 crate 的用户带来麻烦。 让我们看看为什么这些情况都有问题。
首先,考虑这样的情况:你添加了对 hugs = "1.7.3"的依赖,这是最新发布的版本。现在想象一下,某个地方的开发者依赖于你的 crate,但他们也依赖于另一个 crate,foo,它本身也依赖于 hugs。进一步想象,foo 的作者对他们的 MSRV 政策非常谨慎,所以他们依赖于 hugs = "1, <1.6"。这里,你会遇到麻烦。当 Cargo 看到 hugs = "1.7.3"时,它只考虑>=1.7 的版本。但是它看到 foo 对 hugs 的依赖需要<1.6,所以它放弃了,并报告说没有一个版本的 hugs 与所有需求兼容。
注意:在实践中,有很多原因可以解释为什么一个 crate 可能明确地不需要一个较新版本的依赖关系。最常见的原因是为了执行 MSRV,满足企业的审计要求(较新的版本将包含未被审计的代码),以及确保可重复的构建,其中只使用准确列出的版本。
这很不幸,因为你的 crate 很可能在比如说 hugs 1.5.6 中编译得很好。也许它甚至可以在任何 1.X 版本中编译正常。但如果使用最新的版本号,你就等于告诉 Cargo 只考虑这个小版本或更大的版本。那么,解决办法是用 hugs = "1"来代替吗?不,这也不完全正确。可能是你的代码确实依赖于 hugs 1.6 中添加的东西,所以虽然 1.6.2 可以,但 1.5.6 就不行了。如果你只在最终使用较新版本的情况下编译你的 crate,你就不会注意到这一点,但如果依赖关系图中的某些 crate 指定 hugs = "1, <1.5",你的 crate 就不会被编译了。
正确的策略是列出最早的版本,该版本拥有你的 crate 所依赖的所有东西,并确保在你向你的 crate 添加新的代码时也是如此。你最好的办法是使用 Cargo 的不稳定的-Zminimal-versions 标志,这使得你的 crate 使用所有依赖的最小可接受版本,而不是最大版本。然后,将所有的依赖关系设置为最新的主要版本号,尝试编译,并为任何不能编译的依赖关系添加一个次要版本,如此反复,直到一切编译正常,你现在有了最低版本要求。
值得注意的是,就像 MSRV 一样,最小的版本检查也面临着一个生态系统的采用问题。虽然你可能已经正确地设置了所有的版本指定器,但你所依赖的项目可能没有设置。这使得 Cargo 的最小版本标志在实践中难以使用(这也是它仍然不稳定的原因)。如果你依赖 foo,而 foo 依赖 bar,并指定 bar="1",而实际上它需要 bar="1.4",那么无论你如何列出 foo,Cargo 都会报告说编译 foo 失败,因为-Z 标志告诉它总是喜欢最小版本。你可以通过在你的依赖项中直接列出 bar 并加上适当的版本要求来解决这个问题,但这些变通方法的设置和维护是很痛苦的。你可能最终会列出大量的依赖关系,而这些依赖关系只是通过你的横向依赖关系拉进来的,而且你必须随着时间的推移保持这个列表的更新。
注意:目前的一个建议是提出一个标志,即对当前的 crate 倾向于最小的版本,但对依赖性倾向于最大的版本,这似乎很有希望。
变更记录(Changelogs)
除了最微不足道的 crate,我强烈建议保留一份更新日志。没有什么比看到一个依赖关系得到了重大的版本升级,然后不得不翻阅 Git 日志来弄清什么变化以及如何更新你的代码更令人沮丧的了。我建议你不要只是把 Git 日志转到一个名为 changelog 的文件里,而是要保留一个手动更新日志。它更有可能是有用的。
Keep a Changelog 格式是一个简单但很好的更新日志格式,该格式记录在 https://keepachangelog.com/。
未发布的版本
即使依赖关系的来源是一个目录或一个 Git 仓库,Rust 也会考虑版本号。这意味着,即使你还没有向 crates.io 发布版本,语义版本管理也是很重要的;在两次发布之间,你的 Cargo.toml 中列出的版本是很重要的。语义版本管理标准并没有规定如何处理这种情况,但是我将提供一个工作流程,这个工作流程可以很好地发挥作用,而不会过于繁琐。
在你发布了一个版本后,立即将 Cargo.toml 中的版本号更新为下一个补丁版本,后缀为-alpha.1。如果你刚刚发布了 2.0.3,就把新版本定为 2.0.4-alpha.1。
当你在两个版本之间对代码进行修改时,要注意是否有增加性或破坏性的变化。如果发生了这样的情况,而相应的版本号在上次发布后没有变化,就把它递增。例如,如果最后发布的版本是 2.0.3,目前的版本是 2.0.4-alpha.2,而你做了一个增加性的修改,那么就把这个版本改为 2.1.0-alpha.1,如果你做了一个破坏性的修改,就变成 3.0.0-alpha.1。如果已经做了相应的版本增加,只需增加α编号即可。
当你发布的时候,去掉后缀(除非你想做一个预发布),然后发布,从头开始。
这个过程是有效的,因为它使两个常见的工作流程运作得更好。首先,想象一下,一个开发者依赖于你的 crate 的主要版本 2,但他们需要一个目前只在 Git 中可用的功能。然后你提交了一个突破性的修改。如果你不同时增加主版本,他们的代码就会突然以意想不到的方式失败,要么无法编译,要么出现奇怪的运行时问题。如果你按照这里的程序,他们就会收到 Cargo 的通知,说发生了破坏性修改,他们必须解决这个问题,或者提交一个特定的提交。
接下来,想象一下,一个开发者需要一个他们刚刚为你的 crate 贡献的功能,但这还不是你的 crate 的任何发布版本的一部分。他们在 Git 依赖关系后面使用你的 crate 已经有一段时间了,所以他们项目中的其他开发者已经有了你的 crate 仓库的旧版检查。如果你不在 Git 中增加主版本号,这个开发者就没有办法告知他们的项目现在依赖于刚刚合并的功能。如果他们推送他们的改动,他们的同事会发现项目不再编译了,因为 Cargo 会重新使用旧的签出。另一方面,如果开发者能够增加 Git 依赖的次要版本号,那么 Cargo 就会意识到旧的签出已经过时了。
这个工作流程绝非完美。它没有提供一个很好的方法来沟通不同版本之间的多个小改动或大改动,而且你仍然需要做一些工作来跟踪版本。然而,它确实解决了 Rust 开发者在针对 Git 依赖关系工作时遇到的两个最常见的问题,而且即使你在两个版本之间做了多个这样的改动,这个工作流程仍然可以抓住许多问题。
如果你不太担心版本号过小或连续的问题,你可以通过简单地总是递增版本号的适当部分来改进这个建议的工作流程。不过要注意,根据你做这种修改的频率,这可能会使你的版本号变得相当大
总结
在这一章中,我们看了一些配置、组织和发布 crate 的机制,这些都是为了你自己和其他人的利益。我们还讨论了在 Cargo 中使用依赖关系和功能时的一些常见问题,希望这些问题在将来不会让你感到困扰。在下一章中,我们将讨论测试问题,并探讨如何超越我们熟悉和喜爱的 Rust 的简单 #[test] 函数。
第六章 测试
在这一章中,我们将研究如何扩展 Rust 的测试能力,以及在测试中加入哪些其他类型的测试。Rust 有许多内置的测试设施,在《Rust 编程语言》中都有详细介绍,主要由#[test] 属性和 test/目录代表。然而,随着代码库的发展和测试需求的增加,你可能需要在单个函数上添加#[test] 标签。
本章主要分为两部分。第一部分包括 Rust 测试机制,如标准测试工具(harness)和条件测试代码。第二部分介绍了评估 Rust 代码正确性的其他方法,如基准测试、提示测试和模糊测试。
Rust 测试机制
要理解 Rust 提供的各种测试机制,你必须首先理解 Rust 是如何构建和运行测试的。当你运行 cargo test --lib 时,Cargo 所做的唯一特别的事情就是把--测试标志传递给 rustc。这个标志告诉 rustc 产生一个测试二进制文件,运行所有的单元测试,而不是仅仅编译 crate 的库或二进制文件。在幕后,--测试有两个主要作用。首先,它使 cfg(test) 成为可能,这样你就可以有条件地包含测试代码(关于这一点,稍后再谈)。其次,它使编译器生成一个测试工具:一个精心生成的主函数,当你的程序运行时,会调用程序中的每个 #[test] 函数。
测试工具(The Test Harness)
编译器通过程序性宏的混合生成测试工具(harness)主函数,我们将在第 7 章中更深入地讨论这个问题,并轻轻地撒上一些魔法。基本上,测试工具将每个由 #[test] 注释的函数转化为测试描述符--这就是程序性宏的部分。然后,它将每个描述符的路径暴露给生成的主函数--这就是神奇的部分。描述符包括的信息有:测试的名称,它所设置的任何附加选项(如 #[should_panic]),等等。在其核心部分,测试工具在 crate 中迭代测试,运行它们,捕捉它们的结果,并打印出结果。因此,它还包括解析命令行参数的逻辑(比如 --testthreads=1),捕获测试输出,并行运行列出的测试,并收集测试结果。
截至目前,Rust 的开发者正在努力使测试工具生成的魔法部分成为公开可用的 API,这样开发者就可以建立自己的测试工具。这项工作仍处于实验阶段,但该提案与目前存在的模型相当吻合。需要解决的部分问题是如何确保#[test] 函数对生成的主函数可用,即使它们在私有子模块中。
集成测试(tests/ 中的测试)遵循与单元测试相同的过程,但有一个例外,它们被编译为自己独立的板块,这意味着它们只能访问主板块的公共接口,并针对没有#[cfg(test)] 编译的主板块运行。测试工具为 tests/ 中的每个文件生成。测试工具不为 tests/ 下的子目录中的文件生成,以允许你为你的测试共享子模块。
注意:如果你明确想要一个子目录下的文件的测试工具,你可以通过调用文件 main.rs 选择加入。
Rust 并不要求你使用默认的测试工具。你可以选择不使用它,而是通过在 Cargo.toml 中为特定的集成测试设置 harness = false 来实现你自己的主方法,代表测试运行器,如清单 6-1 所示。你定义的主方法将被调用来运行测试
#![allow(unused)] fn main() { [[test]] name = "custom" path = "tests/custom.rs" harness = false // 清单 6-1:选择不使用标准测试工具 }
没有测试工具,#[test] 周围的魔法都不会发生。相反,你需要自己写一个主函数来运行你想执行的测试代码。从本质上讲,你在写一个普通的 Rust 二进制文件,只是碰巧被货物测试所运行。该二进制文件负责处理默认工具通常做的所有事情(如果你想支持它们的话),比如命令行标志。工具属性是为每个集成测试单独设置的,所以你可以有一个使用标准工具的测试文件和一个不使用的测试文件。
默认测试工具的参数
默认的测试工具支持一些命令行参数来配置测试的运行方式。这些参数不是直接传给
cargo test的,而是传给 Cargo 在运行cargo test时为你编译和运行的测试二进制。要访问这组标志,请将--传给cargo test,然后是测试二进制文件的参数。例如,要查看测试二进制文件的帮助文本,你可以运行cargo test -- --help。通过这些命令行参数,有许多方便的配置选项。
--nocapture标志禁用了运行 Rust 测试时通常发生的输出捕获。如果你想实时观察测试的输出,而不是在测试失败后一下子观察输出,这很有用。你可以使用--test-threads选项来限制同时运行的测试数量,如果你有一个测试挂起或出现segfaults,而你想通过连续运行测试来找出是哪一个,这很有帮助。 还有一个--skip选项用于跳过符合某种模式的测试,--ignored用于运行通常会被忽略的测试(比如那些需要外部程序运行的测试),以及--list用于仅仅列出所有可用测试。请记住,这些参数都是由默认的测试工具实现的,所以如果你禁用它(用
harness = false),你就必须在你的主函数中自己实现你需要的参数。
没有工具的集成测试主要用于基准测试,我们将在后面看到,但当你想运行不符合标准的 "一个功能,一个测试 "模式的测试时,它们也很有用。例如,你会经常看到无工具测试用于模糊器、模型检查器和需要自定义全局设置的测试(如在 WebAssembly 下或与自定义目标一起工作时)。
#[cfg(test)]
当 Rust 为测试而构建代码时,它设置了编译器配置标志 test,然后你可以用条件编译来使代码被编译出来,除非它是专门被测试的。从表面上看,这似乎很奇怪:难道你不想测试与生产中的代码完全相同的代码吗?你想,但在测试时有专门的代码可以让你写出更好、更彻底的测试,在几个方面。
嘲讽 (MOCKING)
在编写测试时,你经常想严格控制你所测试的代码,以及你的代码可能与之交互的任何其他类型。例如,如果你正在测试一个网络客户端,你可能不想在一个真实的网络上运行你的单元测试,而是想直接控制 "网络 "发射的字节和时间。或者,如果你在测试一个数据结构,你希望你的测试使用的类型允许你控制每个方法在每次调用时返回什么。你可能还想收集一些指标,比如某个方法被调用的频率,或者某个字节序列是否被发射出来。
这些 "假的"类型和实现被称为模拟(mocks),它们是任何广泛的单元测试套件的一个关键 feature。虽然你经常可以手动完成这种控制所需的工作,但如果有一个库为你处理大部分琐碎的细节,那就更好了。这就是自动化模拟的作用。一个模拟库将有生成具有特定属性或签名的类型(包括函数)的设施,以及在测试执行期间控制和反省这些生成的项目的机制。
Rust 中的 Mocking 通常是通过泛型发生的--只要你的程序、数据结构、框架或工具在你可能想要 Mock 的东西上是泛型的(或需要一个 trait 对象),你就可以使用 Mock 库来生成符合要求的类型,将那些泛型参数实例化。然后,通过用生成的模拟类型实例化你的通用结构来编写单元测试,你就可以开始比赛了!
在泛型不方便或不合适的情况下,比如你想避免让你的类型的某一方面对用户来说是泛型的,你可以把你想模拟的状态和行为封装在一个专门的结构中。然后你可以生成该结构及其方法的模拟版本,并根据
cfg(test)或类似cfg(feature = "test_mock_foo"的测试专用特性,使用条件编译来使用真实或模拟的实现。)目前,在 Rust 社区,还没有一个单一的嘲弄库,甚至没有一个单一的嘲弄方法,成为唯一的答案。据我所知,最广泛、最彻底的嘲讽库是
mockallcrate,但它仍在积极开发中,而且还有许多其他竞争者。
只测试 api (Test-Only APIs)
首先,只有测试的代码允许你向你的(单元)测试暴露额外的方法、字段和类型,这样测试不仅可以检查公共 API 的行为是否正确,还可以检查内部状态是否正确。例如,考虑来自 hashbrown 的 HashMap 类型,它是实现标准库 HashMap 的 crate。HashMap 类型实际上只是 RawTable 类型的一个封装,RawTable 实现了大部分的哈希表逻辑。假设在对一个空地图进行 HashMap::insert 操作后,你想检查地图中的一个桶是否为非空,如清单 6-2 所示。
#![allow(unused)] fn main() { #[test] fn insert_just_one() { let mut m = HashMap::new(); m.insert(42, ()); let full = m.table.buckets.iter().filter(Bucket::is_full).count(); assert_eq!(full, 1); } // 清单 6-2:一个访问不可访问的内部状态的测试,因此不能编译。 }
这段代码将不能按原样编译,因为虽然测试代码可以访问 HashMap 的私有表字段,但它不能访问 RawTable 的同样私有的桶字段,因为 RawTable 生活在不同的模块中。 我们可以通过使桶字段可见性 pub(crate) 来解决这个问题,但我们真的不希望 HashMap 能够一般地接触桶,因为它可能意外地破坏 RawTable 的内部状态。即使是将水桶设为只读也会有问题,因为 HashMap 中的新代码可能会开始依赖于 RawTable 的内部状态,从而使未来的修改更加困难。
解决办法是使用#[cfg(test)]。我们可以给 RawTable 添加一个方法,只允许在测试时访问 buckets,如清单 6-3 所示,从而避免在其余的代码中添加脚步声。然后,清单 6-2 中的代码可以被更新为调用 buckets() 而不是访问私有的 buckets 字段。
#![allow(unused)] fn main() { impl RawTable { #[cfg(test)] pub(crate) fn buckets(&self) -> &[Bucket] { &self.buckets } } // 清单 6-3:使用 `#[cfg(test)]` 来使内部状态在测试环境中可被访问。 }
测试断言的簿记
拥有只在测试期间存在的代码的第二个好处是,你可以增强程序以执行额外的运行时簿记,然后可以被测试检查。例如,设想你正在编写你自己的标准库中的 BufWriter 类型的版本。当测试它时,你想确保 BufWriter 不会不必要地发出系统调用。最明显的方法是让 BufWriter 跟踪它在底层 Write 上调用了多少次写入。然而,在生产中,这个信息并不重要,而且保持跟踪会带来(边际的)性能和内存开销。通过 #[cfg(test)],你可以让记账只在测试时发生,如清单 6-4 中所示。
#![allow(unused)] fn main() { struct BufWriter<T> { #[cfg(test)] write_through: usize, // other fields... } impl<T: Write> Write for BufWriter<T> { fn write(&mut self, buf: &[u8]) -> Result<usize> { // ... if self.full() { #[cfg(test)] self.write_through += 1; let n = self.inner.write(&self.buffer[..])?; // ... } } // 清单 6-4:使用#[cfg(test)] 将记账限制在测试环境中。 }
请记住,test 只为被编译为测试的 crate 而设置。对于单元测试,这就是被测试的 crate,正如你所期望的那样。然而,对于集成测试,它是被编译为测试的集成测试二进制文件--你正在测试的 crate 只是被编译为一个库,所以不会有测试设置。
文档测试
文档注释中的 Rust 代码片段会自动作为测试案例运行。由于 doctests 出现在你的 crate 的公共文档中,并且用户可能会模仿它们所包含的内容,所以它们被作为集成测试运行。这意味着 doctests 不能访问私有字段和方法,并且测试不设置在主 crate 的代码上。每个测试都被编译为它自己的专用 crate,并在隔离状态下运行,就像用户把测试复制到他们自己的程序中一样。
在幕后,编译器对测试进行了一些预处理,使其更加简洁。最重要的是,它自动在你的代码周围添加一个 fn main。这使得测试只关注用户可能关心的重要部分,比如实际使用你的库的类型和方法的部分,而不包括不必要的模板。
你可以通过在 doctest 中定义你自己的 fn main 来选择不使用这种自动包装。你可能想这样做,例如,如果你想用 #[tokio::main] async fn main 这样的方法写一个异步 main 函数,或者你想在 doctest 中添加额外的模块。
要在你的测试中使用 ? 操作符,你通常不必使用自定义的主函数,因为 rustdoc 包括一些启发式方法,如果你的代码看起来像使用了 ? (例如,如果它以 Ok(()) 结尾),就将返回类型设置为 Result<(), impl Debug>。如果类型推理让你对函数的错误类型感到为难,你可以通过改变 doctest 的最后一行来明确类型,像这样Ok::<(), T>(())来消除歧义。
Doctests 有一些额外的功能,当你为更复杂的接口编写文档时,这些功能会很方便。首先是隐藏个别行的能力。如果你在测试的某一行前面加上 #,那么当测试被编译和运行时,该行就会被包括在内,但它不会被包括在文档中生成的代码片段中。这可以让你轻松地隐藏那些对当前例子不重要的细节,例如为虚拟类型实现 trait 或生成值。如果你想展示一连串的例子,而不是每次都显示相同的引导代码,这也很有用。清单 6-5 给出了一个带有隐藏行的测试的例子。
#![allow(unused)] fn main() { /// Completely frobnifies a number through I/O. /// /// In this first example we hide the value generation. /// ``` /// # let unfrobnified_number = 0; /// # let already_frobnified = 1; /// assert!(frobnify(unfrobnified_number).is_ok()); /// assert!(frobnify(already_frobnified).is_err()); /// ``` /// /// Here's an example that uses ? on multiple types /// and thus needs to declare the concrete error type, /// but we don't want to distract the user with that. /// We also hide the use that brings the function into scope. /// ``` /// # use mylib::frobnify; /// frobnify("0".parse()?)?; /// # Ok::<(), anyhow::Error>(()) /// ``` /// /// You could even replace an entire block of code completely, /// though use this _very_ sparingly: /// ``` /// # /* /// let i = ...; /// # */ /// # let i = 42; /// frobnify(i)?; /// ``` fn frobnify(i: usize) -> std::io::Result<()> { // 清单 6-5:用 `#` 隐藏测试中的行数 }
注意:小心使用这一功能;如果用户复制粘贴了一个例子,但由于你隐藏了必要的步骤而无法工作,这可能会让他们感到沮丧。
与#[test] 函数非常相似,doctests 也支持修改 doctest 运行方式的属性。这些属性紧跟在用于表示代码块的三连击之后,多个属性可以用逗号分隔。
和测试函数一样,你可以指定 should_panic 属性来表示某个测试中的代码在运行时应该惊慌失措,或者指定 ignore 来检查代码段,只有在货物测试运行时使用--ignored 标志。你也可以使用 no_run 属性来表示一个给定的 doctest 应该编译但不应该运行。
属性 compile_fail 告诉 rustdoc,文档示例中的代码不应该被编译。这向用户表明某种特定的使用是不可能的,并作为一个有用的测试,提醒你在你的库的相关方面发生变化时要更新文档。你也可以使用这个属性来检查某些静态属性对你的类型是否成立。清单 6-6 显示了一个例子,说明你如何使用 compile_fail 来检查一个给定的类型没有实现 Send,这对于维护不安全代码的安全保证可能是必要的。
# struct MyNonSendType(std::rc::Rc<()>);
fn is_send<T: Send>() {}
is_send::<MyNonSendType>();
// 清单 6-6:用 `compile_fail` 测试代码是否编译失败
compile_fail 是一个相当粗糙的工具,因为它没有给出代码不能编译的原因。例如,如果代码因为缺少分号而不能编译,compile_fail 测试会显得很成功。出于这个原因,你通常只想在确定测试确实没有编译出预期的错误后再添加这个属性。如果你需要对编译错误进行更精细的测试,例如在开发宏时,可以看看 trybuild crate。
其他测试工具
测试有很多内容,而不仅仅是运行测试函数,看看它们是否产生了预期的结果。对测试技术、方法和工具的彻底调查超出了本书的范围,但有一些关键的 Rust 特定部分,在你扩展你的测试剧目时你应该知道。
Linting
你可能不认为 linter 的检查是测试,但在 Rust 中,它们往往可以是测试。Rust 的 linter clippy 把它的一些 lints 归类为正确性 lints。这些线程可以捕捉那些可以编译但几乎可以肯定是错误的代码模式。一些例子是 a = b; b = a,它不能交换 a 和 b;std::mem::forget(t),其中 t 是一个引用;以及 for x in y.next(),它将只迭代 y 中的第一个元素。如果你还没有把 clippy 作为你 CI 管道的一部分来运行,你可能应该这样做。
Clippy 还附带了一些其他的提示,虽然这些提示通常很有帮助,但可能比你所希望的更有意见。例如,type_complexity lint,默认是打开的,如果你在程序中使用一个特别复杂的类型,比如 Rc<Vec<Vec<Box<(u32, u32, u32, u32)>>>>,它会发出警告。虽然该警告鼓励你写出更容易阅读的代码,但你可能会发现它太过迂腐而没有广泛的用途。如果你的代码的某些部分错误地触发了一个特定的警告,或者你只是想允许它的一个特定实例,你可以用 #[allow(clippy::name_of_lint)] 来选择不对那段代码进行警告。
Rust 编译器也以警告的形式提供了它自己的提示,尽管这些提示通常更倾向于编写成语代码而不是检查正确性。相反,编译器中的正确性提示被简单地视为错误(看看 rustc -W help 的列表)。
注意:不是所有的编译器警告都是默认启用的。那些默认禁用的警告通常还在完善中,或者更多的是关于风格而不是内容。一个很好的例子是 "习惯用语 Rust 2018 版" 警告,你可以用
#![warn(rust_2018_idioms)]启用它。当这个提示被启用时,编译器会告诉你是否你没有利用 Rust 2018 版带来的变化。当你开始一个新项目时,你可能想养成启用其他一些提示的习惯,如missing_docs和missing_debug_implementations,它们分别警告你是否忘记记录 crate 中的任何公共项目或为任何公共类型添加Debug实现。
测试生成(Test Generation)
编写一个好的测试套件是一项艰巨的工作。即使你做了这些工作,你写的测试也只是测试你在写这些测试时考虑的特定行为集。幸运的是,你可以利用一些测试生成技术来开发更好和更彻底的测试。这些测试生成输入,供你用来检查你的应用程序的正确性。存在许多这样的工具,每一个都有自己的优势和劣势,所以在这里我只介绍这些工具使用的主要策略:模糊测试和属性测试。
模糊测试(Fuzzing)
关于 fuzzing 的书已经写了一整本,但在高层次上,这个想法很简单:给你的程序生成随机输入,看看它是否崩溃。如果程序崩溃了,那就是一个错误。例如,如果你正在编写一个 URL 解析库,你可以通过系统地生成随机字符串并将它们扔给解析函数,直到它崩溃为止,来对你的程序进行模糊测试。天真地做,这将需要一段时间来产生结果:如果模糊器从 a 开始,然后是 b,然后是 c,以此类推,它将需要很长时间来产生一个棘手的 URL,如 http://[:]。 在实践中,现代模糊器使用代码覆盖率指标来探索你的代码中的不同路径,这让他们比真正随机选择的输入更快地达到更高的覆盖度。
模糊器能很好地发现你的代码不能正确处理的奇怪角落情况。它们不需要你做什么设置:你只需将模糊器指向一个接受 "可模糊 "输入的函数,然后它就开始了。例如,清单 6-7 显示了一个如何对 URL 解析器进行模糊测试的例子。
#![allow(unused)] fn main() { libfuzzer_sys::fuzz_target!(|data: &[u8]| { if let Ok(s) = std::str::from_utf8(data) { let _ = url::Url::parse(s); } }); // 清单 6-7:用 libfuzzer 对一个 URL 解析器进行模糊处理 }
模糊器将产生半随机的输入到闭包中,任何形成有效 UTF-8 字符串的输入都将被传递给解析器。 注意,这里的代码并不检查解析是否成功,而是寻找解析器因违反内部不变性而恐慌或崩溃的情况。
模糊器一直在运行,直到你终止它,所以大多数模糊工具都有一个内置机制,在探索了一定数量的测试案例后停止。如果你的输入不是像哈希表那样的琐碎的可模糊的类型,你通常可以使用像 Arbitrary 这样的工具来把模糊器产生的字节串变成更复杂的 Rust 类型。这感觉就像魔法一样,但在引擎盖下,它实际上是以一种非常简单的方式实现的。Crate 定义了一个 Arbitrary trait,它有一个方法,即 arbitrary,可以从随机字节的源头构造实现类型。像 u32 或 bool 这样的原始类型从输入中读取必要数量的字节来构造自己的有效实例,而像 HashMap 或 BTreeSet 这样更复杂的类型从输入中产生一个数字来决定其长度,然后在其内部类型上调用 Arbitrary 的次数。甚至还有一个属性,#[derive(Arbitrary)],它通过在每个包含的类型上调用 Arbitrary 来实现 Arbitrary! 要进一步探索模糊处理,我建议从 cargo-fuzz 开始。
基于属性的测试 (Property-Based Testing)
有时你不仅要检查你的程序是否崩溃,而且要检查它是否做了预期的事情。你的加法函数没有惊慌失措,这很好,但如果它告诉你 add(1, 4) 的结果是 68,它可能还是错的。这就是基于属性的测试发挥作用的地方;你描述了一些你的代码应该坚持的属性,然后属性测试框架生成输入并检查这些属性是否确实成立。
使用基于属性的测试的一个常见方法是,首先写一个你想测试的代码的简单但天真的版本,你相信它是正确的。然后,对于一个给定的输入,你把这个输入同时给你想测试的代码和简化但天真的版本。如果两个实现的结果或输出是相同的,你的代码就是好的,这就是你要找的正确性属性,但如果不是,你很可能发现了一个错误。你也可以使用基于属性的测试来检查与正确性没有直接关系的属性,比如一个实现的操作所花费的时间是否比另一个少。共同的原则是,你希望真实版本和测试版本之间的任何结果差异都是有参考价值的,并且是可操作的,这样每一次失败都可以让你做出改进。天真的实现可能是你试图替换或增强的标准库中的一个(如 std::collections::VecDeque),也可能是你试图优化的算法的一个简单版本(如天真与优化的矩阵乘法)。
如果这种生成输入直到满足某些条件的方法听起来很像模糊测试,那是因为它是--比我更聪明的人认为,模糊测试 "只是 "基于属性的测试,你要测试的属性是 "它不会崩溃"。
基于属性的测试的一个缺点是,它更依赖于所提供的输入描述。模糊测试会不断尝试所有可能的输入,而属性测试则倾向于由开发者的注释来指导,如 "0 到 64 之间的数字 "或 "包含三个逗号的字符串"。这使得属性测试能更快地达到模糊测试可能需要很长时间才能随机遇到的情况,但它确实需要手工操作,并可能错过重要但小众的错误输入。然而,随着模糊器和属性测试人员的关系越来越密切,模糊器也开始获得这种基于约束的搜索能力。
如果你对基于属性的测试生成感到好奇,我建议从 proptest crate 开始。
测试操作序列
由于模糊器和属性测试器允许你生成任意的 Rust 类型,你并不局限于测试 crate 中的单个函数调用。例如,如果你想测试某种类型的
Foo在执行特定的操作序列时的行为是否正确,你可以定义一个列举操作的枚举,并使你的测试函数接受一个Vec<Operation>。然后你可以实例化一个Foo,并对该Foo一个接一个地执行每个操作。大多数测试器都支持最小化输入,所以如果发现有违反属性的输入,它们甚至会搜索仍然违反属性的最小操作序列。
扩张测试(Test Augmentation)
比方说,你有一个宏伟的测试套件,而且你的代码通过了所有的测试。这是很光荣的。但是,有一天,其中一个通常可靠的测试莫名其妙地失败了,或者因为分割故障而崩溃了。这类非确定性测试失败有两个常见的原因:竞争条件,只有当两个操作以特定的顺序发生在不同的线程上时,你的测试才可能失败,以及不安全代码中的未定义行为,例如,如果一些不安全的代码从未初始化的内存中读取一个特定的值。
用普通的测试来捕捉这类错误可能很困难--通常你对线程调度、内存布局和内容或其他随机的系统因素没有足够的底层控制来编写一个可靠的测试。你可以在一个循环中多次运行每个测试,但如果坏的情况足够罕见或不可能,即使这样也可能抓不到错误。幸运的是,有一些工具可以帮助增强你的测试,使捕捉这些类型的错误更加容易。
其中第一个是惊人的工具 Miri,它是 Rust 的中级中间表示法(MIR)的解释器。MIR 是 Rust 的一个内部简化表示,它可以帮助编译器找到优化和检查属性,而不必考虑 Rust 本身的所有语法糖。通过 Miri 运行你的测试就像运行 cargo miri test 一样简单。Miri 解释你的代码,而不是像普通的二进制文件那样编译和运行,这使得测试的运行速度相当慢。但作为回报,Miri 可以在你的每一行代码的执行过程中跟踪整个程序状态。这使得 Miri 能够检测并报告你的程序是否表现出某些类型的未定义行为,比如未初始化的内存读取、在数值被丢弃后的使用,或者越界指针访问等。与其让这些操作产生奇怪的程序行为,有时可能只导致可观察到的测试失败(如崩溃),Miri 会在它们发生时发现它们,并立即告诉你。
例如,考虑清单 6-8 中非常不健全的代码,它创建了对一个值的两个独占引用。
#![allow(unused)] fn main() { let mut x = 42; let x: *mut i32 = &mut x;let (x1, x2) = unsafe { (&mut *x, &mut *x) }; println!("{} {}", x1, x2) // 清单 6-8:Miri 检测到的疯狂的不安全代码是不正确的 }
在写这篇文章的时候,如果你通过 Miri 运行这段代码,你会得到一个错误,准确地指出了问题所在。
#![allow(unused)] fn main() { error: Undefined Behavior: trying to reborrow for Unique at alloc1383, but parent tag <2772> does not have an appropriate item in the borrow stack --> src/main.rs:4:6 | 4 | let (x1, x2) = unsafe { (&mut *x, &mut *x) }; | ^^ trying to reborrow for Unique at alloc1383, but parent tag <2772> does not have an appropriate item in the borrow stack }
注意:Miri 仍在开发中,其错误信息并不总是最容易理解的。这是一个正在积极解决的问题,所以当你读到这篇文章时,错误输出可能已经有了很大的改善。
另一个值得关注的工具是 Loom,这是一个聪明的库,它试图确保你的测试在运行时有每个相关的交错并发操作。在高水平上,Loom 跟踪所有的跨线程同步点,并反复运行你的测试,每次都会调整线程从这些同步点出发的顺序。因此,如果线程 A 和线程 B 都使用同一个 Mutex,Loom 会确保测试运行一次时,A 先使用,另一次时 B 先使用。Loom 还跟踪原子访问、内存顺序和对 UnsafeCell 的访问(我们将在第 9 章讨论),并检查线程是否对它们进行了不适当的访问。如果测试失败了,Loom 可以给你一个确切的清单,说明哪些线程以什么顺序执行,这样你就可以确定崩溃是如何发生的。
性能测试 (Performance Testing)
编写性能测试是很困难的,因为通常很难准确地建立一个反映真实世界使用情况的工作负载模型。但有这样的测试是很重要的;如果你的代码突然运行慢了 100 倍,那真的应该被认为是一个 bug,但如果没有性能测试,你可能不会发现这种回归。如果你的代码运行速度快了 100 倍,这也可能表明有什么地方不对劲。这两种情况都是将自动化性能测试作为 CI 的一部分的很好的理由--如果性能在任何一个方向上发生剧烈变化,你都应该知道。
与功能测试不同,性能测试没有一个共同的、定义明确的输出。一个功能测试要么成功,要么失败,而性能测试可能会给你一个吞吐量数字,一个延迟曲线,一个处理的样本数量,或任何其他可能与有关应用程序相关的指标。 此外,一个性能测试可能需要在一个循环中运行一个函数几十万次,或者它可能需要在一个多核盒子的分布式网络中运行几个小时。由于这个原因,我们很难谈论如何在一般意义上编写性能测试。相反,在本节中,我们将探讨在 Rust 中编写性能测试时可能遇到的一些问题,以及如何缓解这些问题。有三个特别常见的陷阱经常被忽视,即性能差异、编译器优化和 I/O 开销。让我们依次来探讨这些问题。
性能差异(Performance Variance)
性能的变化有很多原因,许多因素会影响一个特定的机器指令序列的运行速度。有些是显而易见的,比如 CPU 和内存的时钟速度,或者机器的负载情况,但许多因素则更为微妙。例如,你的内核版本可能会改变分页性能,你的用户名的长度可能会改变内存的布局,房间里的温度可能会导致 CPU 时钟下降。最终,如果你运行一个基准测试两次,你会得到相同的结果,这是非常不可能的。事实上,你可能会观察到明显的差异,即使你使用的是相同的硬件。或者,从另一个角度来看,你的代码可能变得更慢或更快,但由于基准测试环境的不同,这种影响可能是看不见的。
没有完美的方法来消除性能结果中的所有差异,除非你碰巧能够在高度多样化的机器上反复运行基准测试。即便如此,重要的是尽量处理这种测量差异,以便从基准给我们的嘈杂测量中提取信号。在实践中,我们对抗差异的最好的朋友是多次运行每个基准,然后看一下测量值的分布,而不仅仅是一个单一的。Rust 有一些工具可以帮助我们做到这一点。例如,不要问 "这个函数平均运行了多长时间?"像 hdrhistogram 这样的工具箱使我们能够查看 "运行时间的哪个范围涵盖了我们观察到的 95%的样本 "这样的统计数据。为了更加严格,我们可以使用统计学中的空假设检验等技术来建立一些信心,即测量的差异确实对应于真实的变化,而不仅仅是噪音。
关于统计假设检验的讲座超出了本书的范围,但幸运的是,这些工作大部分已经由其他人完成。例如,criterion crate 为你做了所有这些,甚至更多。你所要做的就是给它一个函数,它可以调用这个函数来运行你的基准的一次迭代,它将运行适当的次数,以保证结果的可靠性。然后,它产生一个基准报告,其中包括结果的总结,对异常值的分析,甚至还有随时间变化的趋势的图形表示。当然,它不能消除只是在特定的硬件配置上进行测试的影响,但它至少可以对跨执行的可测量的噪音进行分类。
编译器优化
现在的编译器真的很聪明。他们消除死代码,在编译时计算复杂的表达式,解开循环,并执行其他黑暗的魔法来榨取我们代码中的每一滴性能。通常这很好,但是当我们试图测量某段代码的速度时,编译器的聪明才智会给我们带来无效的结果。例如,以清单 6-9 中的 Vec::push 为基准的代码为例。
#![allow(unused)] fn main() { let mut vs = Vec::with_capacity(4); let start = std::time::Instant::now(); for i in 0..4 { vs.push(i); } println!("took {:?}", start.elapsed()); // 清单 6-9:一个可疑的快速性能基准测试 }
如果你用 godbolt.org 或 cargo-asm 之类的软件看一下在发布模式下编译的这段代码的汇编输出,你会立即注意到有些问题:对 Vec::with_capacity 和 Vec::push 的调用,乃至整个 for 循环,都不见踪影。它们已经被完全优化了。编译器意识到,代码中没有任何东西真正需要执行向量操作,因此将它们作为死代码消除了。当然,编译器完全有权利这样做,但对于基准测试来说,这并不是特别有用。为了避免基准测试中的这类优化,标准库提供了 std::hint::black_box。这个函数一直是很多人争论和困惑的话题,在写这篇文章的时候还在等待稳定化,但它非常有用,值得在这里讨论一下。在其核心部分,它只是一个身份函数(一个接收 x 并返回 x 的函数),告诉编译器假设函数的参数是以任意(合法)的方式使用的。它并不阻止编译器对输入参数进行优化,也不阻止编译器对返回值的使用方式进行优化。相反,它鼓励编译器实际计算函数的参数(假设它将被使用),并将结果存储在 CPU 可以访问的地方,这样 black_box 就可以带着计算值被调用。编译器可以自由地,比如说,在编译时计算输入参数,但它仍然应该把结果注入到程序中。
这个函数是我们对许多(尽管不是全部)基准测试需求的全部需要。例如,我们可以对清单 6-9 进行注释,使向量访问不再被优化掉,如清单 6-10 所示。
#![allow(unused)] fn main() { let mut vs = Vec::with_capacity(4); let start = std::time::Instant::now(); for i in 0..4 { black_box(vs.as_ptr()); vs.push(i); black_box(vs.as_ptr()); } println!("took {:?}", start.elapsed()); // 清单 6-10:清单 6-9 的修正版 }
我们已经告诉编译器,假设在循环的每个迭代中,在调用 push 之前和之后,都以任意的方式使用 vs。这迫使编译器按顺序执行每个 push,而不合并或以其他方式优化连续的调用,因为它必须假定在每次调用之间 vs 可能发生 "无法优化的任意事情"(这是 black_box 部分)。
注意,我们使用了 vs.as_ptr() 而不是 &vs,这是因为有一个注意事项,即编译器应该假定 black_box 可以对其参数进行任何合法操作。通过共享引用突变 Vec 是不合法的,所以如果我们使用 black_box(&vs),编译器可能会注意到 vs 在循环的迭代之间不会改变,并根据这一观察进行优化。
I/O 开销的测量 (I/O Overhead Measurement)
在编写基准时,很容易意外地测量错误的东西。例如,我们经常想实时获得有关基准运行情况的信息。为了做到这一点,我们可能会编写清单 6-11 中那样的代码,目的是测量 my_function 的运行速度。
#![allow(unused)] fn main() { let start = std::time::Instant::now(); for i in 0..1_000_000 { println!("iteration {}", i); my_function(); } println!("took {:?}", start.elapsed()); // 清单 6-11:我们在这里真正的基准是什么? }
这看起来似乎达到了目的,但实际上,它并没有真正衡量 my_function 的速度。相反,这个循环最可能的是告诉我们打印一百万个数字需要多长时间。循环主体中的 println! 在幕后做了很多工作:它把一个二进制整数变成十进制数字以便打印,锁定标准输出,使用至少一个系统调用写出一串 UTF-8 代码点,然后释放标准输出锁。 不仅如此,如果你的终端打印出它收到的输入的速度很慢,系统调用可能会阻塞。那是一个很大的周期!而调用 my_function 的时间可能就显得苍白了。
当你的基准使用随机数时也会发生类似的事情。如果你在一个循环中运行 my_function(rand::random()),你很可能主要是在测量生成一百万个随机数的时间。对于获取当前时间、读取配置文件或启动新线程,情况也是一样的--相对而言,这些事情都需要很长的时间,最终可能掩盖了你真正想要测量的时间。
幸运的是,一旦你意识到这个特殊的问题,往往很容易解决。确保你的基准测试循环的主体除了你想测量的特定代码外几乎什么都不包含。所有其他的代码应该在基准测试开始之前或者在基准测试的测量部分之外运行。如果你在使用 criterion,看看它提供的不同的计时循环--它们都是为了满足需要不同测量策略的基准测试情况而存在的!
总结
在这一章中,我们非常详细地探讨了 Rust 提供的内置测试功能。我们还研究了一些在测试 Rust 代码时很有用的测试设施和技术。 这是本书中最后一章,重点讨论 Rust 中级使用的更高层次的问题。从下一章的声明性和过程性宏开始,我们将更多地关注 Rust 代码。下一页见!
第七章 宏
宏在本质上是一种让编译器为你写代码的工具。你给编译器一个生成代码的公式,给定一些输入参数,编译器就会用公式的运行结果来替换宏的每次调用。你可以把宏想象成自动代码替换,由你来定义替换的规则。
Rust 的宏有许多不同的形状和大小,以方便实现许多不同形式的代码生成。两种主要类型是声明性宏和过程宏,我们将在本章中探讨这两种类型。我们还将探讨宏在日常编码中的一些使用方法,以及在更高级的使用中出现的一些隐患。
来自基于 C 语言的程序员可能习惯于 C 和 C++的邪恶之地,在那里你可以用 #define 将每个 true 改为 false,或者删除所有 else 关键字的出现。如果你是这种情况,你就需要将宏与做 "坏事" 的感觉分开。Rust 中的宏远不是 C 语言中的宏。它们遵循(大部分)定义明确的规则,并且相当耐误用。
声明宏
声明性宏是那些使用 macro_rules! 语法定义的宏,它使你可以方便地定义类似函数的宏,而不必为此目的编写一个专门的 crate(就像你对过程宏所做的那样)。一旦你定义了一个声明性的宏,你就可以用宏的名字和惊叹号来调用它。我喜欢把这种宏想象成一种编译器辅助的搜索和替换:它能完成许多常规的、结构良好的转换任务,并能消除重复的模板。在你到目前为止的 Rust 使用经验中,你所认识到的大多数宏都可能是声明性宏。然而,请注意,并非所有类似函数的宏都是声明性宏;macro_rules! 本身就是一个例子,format_args! 是另一个例子。! 后缀只是向编译器表明,宏的调用将在编译时被替换成不同的源代码。
注意:由于 Rust 的解析器专门识别和解析了用
!注释的宏调用,你只能在解析器允许的地方使用它们。它们在大多数你期望的地方都可以使用,比如在表达式位置或impl块中,但不是在任何地方。例如,你不能(在写这篇文章时)在预期有标识符或匹配臂的地方调用一个类函数宏。
为什么声明式宏被称为声明式,这可能不是很明显。毕竟,你不是在程序中 "声明 "了一切吗?在这种情况下,声明性指的是你不说宏的输入应该如何转化为输出,只是说当输入为 B 时,你希望输出看起来像 A。这使得声明式宏简洁而富有表现力,尽管它也有使它们变得相当隐晦的倾向,因为你有一种有限的语言来表达你的声明。
什么时候使用它们
当你发现自己在不断地写同样的代码,而你又想不写的时候,声明性的宏就很有用。它们最适合于相当机械的替换--如果你的目的是进行花哨的代码转换或大量的代码生成,过程宏可能更适合。
我最常使用的是声明式宏,在我发现自己在写重复的和结构相似的代码时,例如在测试和 trait 实现中。对于测试,我经常想多次运行同一个测试,但配置略有不同。我可能有像清单 7-1 中所示的东西。
#![allow(unused)] fn main() { fn test_inner<T>(init: T, frobnify: bool) { ... } #[test] fn test_1u8_frobnified() { test_inner(1u8, true); } // ... #[test] fn test_1i128_not_frobnified() { test_inner(1i128, false); } // 第 7-1 项:重复测试代码 }
虽然这样做行得通,但它太冗长、太重复,而且太容易出现手工错误。使用宏,我们可以做得更好,如清单 7-2 所示。
#![allow(unused)] fn main() { macro_rules! test_battery { ($($t:ty as $name:ident),*) => {$( mod $name { #[test] fn frobnified() { test_inner::<$t>(1, true) } #[test] fn unfrobnified() { test_inner::<$t>(1, false) } })* } } test_battery! { u8 as u8_tests, // ... i128 as i128_tests ); // 清单 7-2:让一个宏为你重复。 }
这个宏将每个以逗号分隔的指令扩展成自己的模块,然后包含两个测试,一个是以 true 调用 test_inner,另一个是以 false 调用。虽然宏的定义并不简单,但它使添加更多的测试更加容易。每种类型都是 test_battery!调用中的一行,宏将负责生成真和假参数的测试。我们还可以让它为 init 的不同值生成测试。现在,我们已经大大减少了忘记测试某个特定配置的可能性。
Trait 实现的情况也类似。如果你定义了自己的特质,你通常会想为标准库中的一些类型实现该特质,即使这些实现是微不足道的。让我们想象一下,你发明了 Clone trait,并想为标准库中所有的 Copy 类型实现它。你可以使用一个类似清单 7-3 中的宏,而不是手动为每个类型编写一个实现。
#![allow(unused)] fn main() { macro_rules! clone_from_copy { ($($t:ty),*) => { $(impl Clone for $t { fn clone(&self) -> Self { *self } })* } } clone_from_copy![bool, f32, f64, u8, i8, /* ... */]; // 清单 7-3:使用宏来实现许多类似类型的特质,一举两得 }
在这里,我们为每个提供的类型生成一个 Clone 的实现,其主体只是使用 * 来复制出 &self。你可能想知道为什么我们不为 T 添加一个覆盖的实现 Clone for T where T: Copy。我们可以这样做,但不这样做的一个重要原因是,这将迫使其他 crate 的类型也为他们自己的类型使用相同的 Clone 实现,而这些类型恰好都是 Copy。一个叫做专业化的实验性编译器 trait 可以提供一个解决方法,但是在写这篇文章的时候,这个 trait 的稳定化还有一段距离。所以,就目前而言,我们最好还是专门枚举这些类型。 这种模式也超越了简单的转发实现:例如,你可以很容易地改变清单 7-3 中的代码,对所有整数类型实现 AddOne trait。
注意:如果你发现自己不知道应该使用泛型还是声明性的宏,你应该使用泛型。 泛型通常比宏更符合人体工程学,并且与语言中的其他构造整合得更好。 考虑这个经验法则:如果你的代码基于类型而改变,就使用泛型;否则就使用宏。
他们如何工作的
每一种编程语言都有一个语法,它规定了构成源代码的各个字符如何被转化为标记。令牌是语言的最底层构件,如数字、标点符号、字符串和字符字面,以及标识符;在这个层面上,语言关键词和变量名称之间没有区别。例如,文本(value + 4)在类似 Rust 的语法中会由五个令牌序列(, value, +, 4, )表示。将文本转化为令牌的过程也为编译器的其他部分和解析文本的棘手的低级细节之间提供了一层抽象。例如,在标记表示法中,没有空白的概念,而且 /*"foo"*/和"/*foo*/"有不同的表示法(前者没有标记,后者是一个内容为/*foo*/的字符串字面标记)。
一旦源代码变成了一个标记序列,编译器就会浏览这个序列,并为这些标记赋予句法意义。例如,以 () 为界限的标记构成了一个组,!标记表示宏调用,等等。这就是解析的过程,它最终产生一个抽象语法树(AST),描述了源代码所代表的结构。作为一个例子,考虑表达式 let x = || 4,它由 tokens let(关键字)、x(标识符)、=(标点符号)、|(标点符号)的两个实例和 4(字面意思)组成的序列。当编译器将其转化为语法树时,它表示为一个语句,其模式是标识符 x,其右侧表达式是一个闭包,其主体是一个空参数列表和一个整数字 4 的字面表达。请注意,语法树的表示方法比标记序列要丰富得多,因为它按照语言的语法为标记组合赋予了语法意义。
Rust 宏规定了语法树,当编译器在解析过程中遇到宏调用时,给定的令牌序列要转换为语法树,它必须对宏求值以确定替换令牌,这最终将成为宏调用的语法树。然而,此时编译器仍在解析标记,可能还不能对宏求值,因为它所做的只是解析宏定义的标记。相反,编译器推迟宏调用分隔符中包含的任何内容的解析,并记住输入标记序列。当编译器准备好计算所指示的宏时,它将在标记序列上计算宏,解析它产生的标记,并将生成的语法树替换为宏调用所在的树。
从技术上讲,编译器确实为宏的输入做了一点解析。具体来说,它解析基本的东西,比如字符串字面值和分隔的组,因此产生一系列的标记树,而不仅仅是标记。例如,代码 x - (a.b + 4) 解析为三个标记树的序列。第一个标记树是标识符 x 的单个标记,第二个标记树是标识符-的单个标记,第三个标记树是一个组(使用括号作为分隔符),该组本身由五个标记树序列组成:a(一个标识符)、.(标点符号)、b(另一个标识符)、+(另一个标点符号)和 4(文字)。这意味着宏的输入不一定是有效的 Rust,但它必须由 Rust 编译器可以解析的代码组成。例如,在 Rust 中不能在宏调用之外编写 for <- x,但可以在宏调用内部编写,只要宏生成有效的语法。另一方面,你不能将 for{ 传递给宏,因为它没有右大括号。
声明性宏总是生成有效的 Rust 作为输出。你不能让一个宏生成,比如说,一个函数调用的前半部分,或者一个没有后面的块的 if。一个声明性的宏必须生成一个表达式(基本上是任何可以赋值给变量的东西),一个语句,如 let x = 1;,一个项目,如 trait 定义或 impl 块,一个类型,或一个匹配模式。这使得 Rust 的宏可以抵制滥用:你根本不可能写一个产生无效 Rust 代码的声明式宏,因为宏定义本身不会被编译。
这就是声明性宏的全部内容--当编译器遇到宏调用时,它将调用定界符中包含的标记传递给宏,解析产生的标记流,并将宏调用替换为产生的 AST。
如何编写声明式宏
对声明性宏所支持的所有语法的详尽解释超出了本书的范围。然而,我们将涵盖基础知识,因为有一些奇怪的地方值得指出。
声明性宏由两个主要部分组成:匹配器和转录器。一个特定的宏可以有许多匹配器,每个匹配器都有一个相关的转录器。当编译器找到一个宏的调用时,它会从头到尾走一遍宏的匹配器,当它找到一个与调用中的标记相匹配的匹配器时,它会通过走相应转录器的标记来替换调用。清单 7-4 显示了声明式宏规则的不同部分是如何结合在一起的。
#![allow(unused)] fn main() { macro_rules! /* macro name */ { (/* 1st matcher */) => { /* 1st transcriber */ }; (/* 2nd matcher */) => { /* 2nd transcriber */ }; } // 清单 7-4:声明性宏定义组件 }
匹配器(Matchers)
你可以把宏匹配器看作是一个标记树,编译器试图以预定义的方式将其扭曲,以匹配它在调用地点得到的输入标记树。作为一个例子,考虑一个带有 $a:ident + $b:expr 匹配器的宏。该匹配器将匹配任何标识符 (:ident) 后面的加号,以及任何 Rust 表达式 (:expr)。如果用 x + 3 * 5 来调用宏,编译器会注意到,如果它设置 $a = x 和 $b = 3 * 5,那么匹配器就会匹配。尽管 * 从未出现在匹配器中,但编译器意识到 3 * 5 是一个有效的表达式,因此它可以与$b:expr 匹配,它接受任何表达式(:expr 部分)。
匹配器可以变得非常多,但它们有巨大的表达能力,很像正则表达式。对于一个不太长的例子,这个匹配器接受一个或多个 (+) 逗号分割 (),) 的序列 ($()),以 key => value 格式给出的键/值对:
#![allow(unused)] fn main() { $($key:expr => $value:expr),+ }
而且,最重要的是,用这个匹配器调用宏的代码可以为键或值提供任意复杂的表达式,匹配器的魔力将确保键和值表达式被适当地分区。
宏规则支持各种各样的片段类型;你已经看到了用于标识符的:ident 和用于表达式的:expr,但也有用于类型的:ty,甚至用于任何单一标记树的:tt 你可以在 Rust 语言参考(https://doc.rust-lang.org/reference/macrosby-example.html)的第三章中找到完整的片段类型列表。这些,加上重复匹配模式的机制($()),使你能够匹配大多数简单的代码模式。然而,如果你发现很难用匹配器来表达你想要的模式,你可能想尝试用过程宏来代替,在这里你不需要遵循 macro_rules! 要求的严格语法。我们将在本章后面更详细地讨论这些问题。
转录器(Transcribers)
一旦编译器匹配了一个声明性的宏匹配器,它就会使用匹配器相关的转录器生成代码。由宏匹配器定义的变量被称为元变量,编译器将每个元变量在转录器中的任何出现(如上一节例子中的$key)替换为与匹配器中该部分匹配的输入。如果你在匹配器中有重复(比如同一例子中的$(),+),你可以在转录器中使用相同的语法,它将为输入中的每个匹配重复一次,每次扩展都为该迭代的每个元变量提供适当的替换。例如,对于$key 和$value 匹配器,我们可以写下面的转录器,为每个被匹配的$key/$value 对产生一个 insert 调用到某个 map 中去。
#![allow(unused)] fn main() { (map.insert($key, $value);)+ }
请注意,在这里我们希望每个重复都有一个分号,而不仅仅是为重复划定界限,所以我们把分号放在重复的括号内。
注意:你必须在转录器中的每个重复中使用一个元变量,以便编译器知道要使用匹配器中的哪个重复(如果有多个重复的话)。
卫生(Hygiene)
你可能听说过 Rust 的宏是卫生的,也许是卫生的使它们更安全或更适合工作,但不一定了解这意味着什么。当我们说 Rust 宏是卫生的,我们的意思是,一个声明性的宏(通常)不能影响没有明确传递给它的变量。一个微不足道的例子是,如果你声明了一个名字为 foo 的变量,然后调用一个同样定义了一个名为 foo 的变量的宏,那么宏的 foo 在调用地点(宏被调用的地方)默认是不可见的。同样,宏也不能访问在调用地点定义的变量(甚至是 self),除非它们被明确地传递进来。
大多数时候,你可以认为宏的标识符存在于自己的命名空间中,与它们所扩展的代码的命名空间是分开的。举个例子,看看清单 7-5 中的代码,它有一个试图(但失败了)在调用地点影射一个变量的宏。
#![allow(unused)] fn main() { macro_rules! let_foo { ($x:expr) => { let foo = $x; } } let foo = 1; // expands to let foo = 2; let_foo!(2); assert_eq!(foo, 1); // 清单 7-5:宏存在于他们自己的小宇宙中。大多数情况下。 }
在编译器展开 let_foo!(2) 之后,断言看起来应该失败。然而,原始代码中的 foo 和宏生成的 foo 存在于不同的宇宙中,除了碰巧共享一个人类可读的名字外,它们之间没有任何关系。事实上,编译器会抱怨说宏中的 let foo 是一个未使用的变量。这种卫生方式对于宏的调试非常有帮助--你不必担心因为你碰巧选择了相同的变量名而意外地影射或覆盖了宏调用者中的变量!
然而,这种卫生的分离并不适用于变量标识符之外。声明性宏确实与调用站点共享一个类型、模块和函数的命名空间。这意味着你的宏可以定义可以在调用范围内调用的新函数,为在其他地方定义的类型添加新的实现(并且没有传入),引入一个新的模块,然后可以在宏被调用的地方访问,等等。这是由设计决定的--如果宏不能像这样影响更广泛的代码,那么用它们来生成类型、trait 实现和函数就会麻烦得多,而这正是它们最方便的地方。
当你写一个你想从你的 crate 中导出的宏时,宏中缺乏对类型的卫生管理是特别重要的。为了使宏真正能够被重用,你不能对调用者的范围内有哪些类型做任何假设。也许调用你的宏的代码定义了 mod std {} 或者导入了自己的 Result 类型。为了安全起见,确保你使用完全指定的类型,比如 ::core::option::Option 或 ::alloc::boxed::Box。如果你特别需要引用定义宏的 crate 中的东西,使用特殊的元变量 $crate。
注意:如果可以的话,避免使用
::std路径,这样宏就能在no_stdcrate 中继续工作。
如果你想让宏影响调用者范围内的特定变量,你可以选择在宏和它的调用者之间共享标识符。关键是要记住标识符的来源,因为那是标识符将被绑定的命名空间。如果你把 let foo = 1; 放在一个宏中,那么标识符 foo 就起源于这个宏,并且永远不会被调用者的标识符命名空间所使用。另一方面,如果宏把 $foo:ident 作为参数,然后写上 let $foo = 1;,当调用者用 !(foo) 调用宏时,标识符将起源于调用者,因此将在调用者的范围内引用 foo。
标识符也不必如此明确地传递;任何出现在宏之外的代码中的标识符都将引用调用者范围内的标识符。在清单 7-6 的例子中,变量标识符出现在 :expr 中,但是仍然可以访问调用者范围中的变量。
#![allow(unused)] fn main() { macro_rules! please_set { ($i:ident, $x:expr) => { $i = $x; } } let mut x = 1; please_set!(x, x + 1); assert_eq!(x, 2); // 清单 7-6:让宏在调用地点访问标识符 }
我们可以在宏中使用 =$i+1 来代替,但我们不能使用 =x+1,因为在宏的定义范围中没有 x 这个名字。
关于声明性宏和范围的最后一点说明:与 Rust 中的几乎所有其他东西不同,声明性宏只有在被声明后才存在于源代码中。如果你试图使用一个你在文件中进一步定义的宏,这将不会起作用!这适用于你的项目;如果你声明了一个宏,那么你就可以使用它。这适用于你的项目;如果你在一个模块中声明了一个宏,并想在另一个模块中使用它,你声明宏的模块必须出现在 crate 的前面,而不是后面。如果 foo 和 bar 是位于 crate 根部的模块,foo 声明了一个 bar 想要使用的宏,那么 mod foo 必须出现在 lib.rs 中的 mod bar 之前!
注意:宏的这种奇怪的范围(正式称为文本范围)有一个例外,那就是如果你用
#[macro_export]标记宏。这个注解可以有效地将宏提升到 crate 的根部,并将其标记为pub,这样它就可以在 crate 的任何地方或 crate 的附属物中使用。
过程宏
你可以把过程宏看作是解析器和代码生成的结合,在这中间你要写胶水代码。在高层次上,通过过程宏,编译器收集输入宏的标记序列,并运行你的程序,以确定用什么标记来替换它们。
过程宏之所以被称为过程宏,是因为你定义了如何在给定的输入标记中生成代码,而不是仅仅写出生成的代码。编译器方面涉及的智能很少--就其所知,过程宏或多或少是一个可以任意替换代码的源代码预处理器。 你的输入可以被解析为 Rust 标记流的要求仍然存在,但仅此而已!。
过程宏的类型
过程宏有三种不同的风格,每一种都是专门针对一个特定的普通用例的
- 类似于函数的宏,如
macro_rules!生成的。 - 属性宏,如
#[test]。 - 派生宏,如
#[derive(Serialize)]。
所有这三种类型都使用相同的基本机制:编译器向你的宏提供一串标记,它希望你产生一串与输入树有关的标记作为回报(可能)。然而,它们在调用宏的方式和处理其输出的方式上有所不同。我们将简要地介绍每一种。
函数宏(Function-Like Macros)
类似函数的宏是过程宏的最简单的形式。 像声明性宏一样,它只是用过程宏返回的代码替换了调用处的宏代码。然而,与声明式宏不同的是,所有的防护栏都是关闭的:这些宏(像所有的过程式宏一样)不需要是卫生的,也不会保护你在调用位置与周围代码中的标识符发生交互。相反,你的宏应该明确指出哪些标识符应该与周围的代码重叠(使用 Span::call_site),哪些应该被视为宏的私有部分(使用 Span::mixed_site,我们将在后面讨论)。
属性宏
属性宏也是将属性分配给的项批发出去,但这个宏需要两个输入:出现在属性中的标记树(减去属性的名称)和它所连接的整个项的标记树,包括该项可能有的任何其他属性。属性宏允许你轻松地编写一个过程宏来改变一个项目,例如通过为一个函数定义添加前奏或尾声(像 #[test] 那样),或者通过修改一个结构的字段。
派生宏
派生宏与其他两个宏略有不同,它是对宏的目标进行添加,而不是替换。尽管这个限制看起来很严重,但派生宏是创建过程宏的最初动机之一。具体来说,Serde crate 需要派生宏来实现其现在众所周知的 #[derive(Serialize, Deserialize)] 魔法。
派生宏可以说是过程宏中最简单的,因为它们有如此严格的形式:你只能在被注解的项目之后追加项目;你不能替换被注解的项目,而且你不能让派生接受参数。派生宏确实允许你定义辅助属性--这些属性可以放在被注释的类型中,为派生宏提供线索(比如 #[serde(skip)])--但这些功能主要是像标记一样,不是独立的宏。
过程宏的成本
在我们讨论不同的过程宏类型何时合适之前,值得讨论的是,为什么你在使用过程宏之前可能要三思而后行--即增加编译时间。
过程宏可以显著增加编译时间,主要有两个原因。首先,它们往往会带来一些相当重的依赖性。例如,syn crate 为 Rust 标记流提供了一个解析器,使编写过程宏的经验更加容易,在启用所有功能的情况下,编译需要几十秒。你可以(也应该)通过禁用你不需要的功能,并在调试模式而不是发布模式下编译你的过程宏来缓解这一问题。代码在调试模式下的编译速度通常要快几倍,对于大多数过程宏,你甚至不会注意到执行时间的差异。
过程宏增加编译时间的第二个原因是,它使你很容易在不知不觉中生成大量的代码。虽然宏使你不必实际输入生成的代码,但它并没有使编译器不必解析、编译和优化它。当你使用更多的过程宏时,生成的模板就会增加,而且会使你的编译时间变长。
这就是说,过程宏的实际执行时间很少是整个编译时间的一个因素。虽然编译器必须等待过程宏做完它的事情才能继续,但实际上,大多数过程宏不做任何繁重的计算。 也就是说,如果你的过程宏特别复杂,你的编译可能会在过程宏代码上花费大量的执行时间,这是值得注意的。
所以你认为你想要一个宏
现在让我们看看每种过程宏的一些良好用途。 我们从简单的开始:派生宏。
何时使用派生宏
派生宏只用于一件事,而且只有一件事:在有可能实现自动化的地方,自动实现一个 trait。并非所有的 trait 都有明显的自动化实现,但许多 trait 都有。在实践中,只有当 trait 经常被实现,并且它对任何给定类型的实现都相当明显时,你才应该考虑为该 trait 添加一个派生宏。第一个条件似乎是常识;如果你的特质只被实现一两次,可能不值得为它编写和维护一个复杂的派生宏。
然而,第二个条件可能看起来比较奇怪:实现 "明显" 是什么意思?考虑一下像 Debug 这样的 trait。如果你被告知 Debug 是做什么的,并且给你看了一个类型,你可能会期望 Debug 的实现在输出每个字段的名字的同时,也输出其值的调试表示。 这就是 derive(Debug) 的作用。那么 Clone 呢?你可能希望它只是克隆每个字段--同样,这也是 derive(Clone) 所做的。对于 derive(serde::Serialize),我们希望它能序列化每个字段和它的值,而它就是这么做的。一般来说,你希望一个 trait 的派生与开发者对它可能做什么的直觉相匹配。如果一个 trait 没有明显的派生,或者更糟的是,如果你的派生与明显的实现不匹配,那么你最好不要给它一个派生宏。
何时使用函数宏
类似于函数的宏很难给出一个一般的经验法则。 你可能会说,当你想要一个类似于函数的宏,但又不能用 macro_rules! 来表达时,你应该使用类似于函数的宏!但这是一个相当主观的指导原则。毕竟,如果你真的用心去做,你可以用声明式宏来做很多事情。
有两个特别好的理由让我们去找一个类似函数的宏。
- 如果你已经有了一个声明性的宏,而它的定义变得非常多,以至于宏很难维护。
- 如果你有一个纯函数,你需要在编译时能够执行,但不能用
const fn来表达。这方面的一个例子是phfcrate,当在编译时给定一组键时,它使用一个完美的哈希函数生成一个哈希图或集。另一个是hex-literal,它接收一串十六进制的字符,并将其替换为相应的字节。一般来说,任何不只是在编译时对输入进行转换,而是真正对其进行计算的东西都可能是一个好的候选者。
我不建议为了破坏宏内的卫生状况而去找一个类函数宏。类函数宏的卫生性是一个可以避免许多调试问题的功能,在你故意破坏它之前,你应该仔细考虑。
何时使用属性宏
这给我们留下了属性宏。尽管这些可以说是过程宏中最通用的,但它们也是最难知道何时使用的。多年来,我一次又一次地看到属性宏在四个方面增加了巨大的价值。
-
测试生成(Test generation)
想要在多个不同的配置下运行同一个测试,或者用相同的引导代码运行许多类似的测试是非常常见的。虽然声明性的宏可以让你表达这一点,但如果你有一个像
#[foo_test]这样的属性,在每个注释的测试中引入设置前奏和后记,或者像#[test_case(1)]#[test_case(2)]这样的可重复属性来标记一个给定的测试应该被重复多次,每次输入一次,你的代码往往会更容易阅读和维护。 -
框架注释
像
rocket这样的库使用属性宏来为函数和类型增加额外的信息,然后框架就会使用这些信息,而用户无需进行大量的手动配置。 能够写#[get("/<name>")] fn hello(name: String)要比用函数指针等设置一个配置结构方便得多。从本质上讲,这些属性构成了一种微型的特定领域语言(DSL),它隐藏了大量的模板,否则是必要的。同样,异步 I/O 框架tokio让你使用#[tokio::main] async fn main()来自动设置运行时间并运行你的异步代码,从而使你不必在每个异步应用程序的主函数中写相同的运行时间设置。 -
透明的中间件
有些库希望以不引人注目的方式将自己注入你的应用程序,以提供不改变应用程序功能的附加值。例如,跟踪和记录库(如
tracing)和指标收集库(如metered)允许你通过向一个函数添加一个属性来透明地检测该函数,然后对该函数的每次调用都会运行一些由库决定的额外代码。 -
类型转换(Type transformers)
有时你想不仅仅是为一个类型派生特性,而是真正以某种基本方式改变类型的定义。在这种情况下,属性宏是最合适的方式。
pin_projectcrate 就是一个很好的例子:它的主要目的不是实现一个特定的 trait,而是确保对一个给定类型的字段的所有钉住访问都是按照 Rust 的Pin类型和Unpintrait 所规定的严格规则进行的(我们将在第 8 章中更多地讨论这些类型)。它通过生成额外的辅助类型,为注解的类型添加方法,以及引入静态安全检查来确保用户不会意外地射中自己的脚。虽然pin_project可以用过程性的derive宏来实现,但该派生特性的实现很可能并不明显,这违反了我们关于何时使用过程宏的规则之一。
它们是如何工作的?
所有过程宏的核心是 TokenStream 类型,它可以被迭代以获得组成该标记流的单个 TokenTree 项目。TokenTree 要么是一个单一的标记,如标识符、标点符号或字面意义,要么是另一个 TokenStream,它被一个分隔符 () 或 {} 所包围。通过走一个 TokenStream,你可以解析出任何你想要的语法,只要单个标记是有效的 Rust 标记。如果你想把你的输入解析成 Rust 代码,你可能想使用 syn crate,它实现了一个完整的 Rust 解析器,可以把 TokenStream 变成一个易于遍历的 Rust AST。
对于大多数过程宏,你不仅要解析 TokenStream,还要产生 Rust 代码,注入到调用过程宏的程序中。有两种主要的方法来做到这一点。第一种是手动构建 TokenStream,并每次扩展一个 TokenTree。第二种是使用 TokenStream 的 FromStr 实现,它让你用 "".parse::<TokenStream>() 解析一个包含 Rust 代码的字符串到 TokenStream。你也可以混合使用这些方法;如果你想给你的宏的输入预加一些代码,只需为序幕构造一个 TokenStream,然后使用 Extend trait 来追加原始输入。
注意:
TokenStream也实现了Display,它可以漂亮地打印出流中的令牌。这对于调试来说是非常方便的。
令牌比我到目前为止描述的要神奇一些,因为每个令牌,甚至每个 TokenTree,都有一个跨度(span)。跨度是编译器将生成的代码与生成它的源代码联系起来的方式。每个标记的跨度都标志着该标记的来源。例如,考虑一个像清单 7-7 中的(声明性)宏,它为所提供的类型生成了一个微不足道的 Debug 实现。
#![allow(unused)] fn main() { macro_rules! name_as_debug { ($t:ty) => { impl ::core::fmt::Debug for $t { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { ::core::write!(f, ::core::stringify!($t)) } } }; } // 清单 7-7:实现 Debug 的一个非常简单的宏 }
现在让我们想象一下,有人用 name_as_debug!(u31) 来调用这个宏。技术上讲,编译器错误发生在宏内部,特别是我们为 $t 写的地方($t 的其他用法可以处理无效的类型)。但我们希望编译器能在用户的代码中指向 u31--事实上,这正是跨度(spans)让我们做的。
生成的代码中 $t 的跨度是映射到宏调用中 $t 的代码。这些信息会通过编译器进行处理,并与最终的编译器错误相关联。当编译器错误最终被打印出来时,编译器将打印出宏中的错误,说 u31 类型不存在,但会突出显示宏调用中的 u31 参数,因为那是与错误相关的跨度。
跨度(spans)是相当灵活的,如果你使用 compile_error! 宏,它可以让你编写过程宏,产生复杂的错误信息。顾名思义,compile_error! 导致编译器在任何地方发出一个错误,并以提供的字符串作为信息。这可能看起来不是很有用,直到你把它与跨度(span)配对。通过设置你为 compile_error! 调用而生成的 TokenTree 的跨度等于输入的某个子集的跨度,你就有效地告诉编译器发出这个编译错误,并将用户指向源代码的这一部分。这两种机制结合在一起,让宏产生的错误似乎源于代码的相关部分,即使实际的编译器错误是在生成的代码中的某个地方,而用户甚至从来没有看到过!"。
注意:如果你曾经好奇
syn的错误处理是如何进行的,它的Error类型实现了一个Error::to_compile_error方法,将它变成了一个只持有compile_error!指令的TokenStream。syn的Error类型的特别之处在于,它在内部持有一个错误集合,每个错误集合都会产生一个独立的compile_error!指令,并有自己的跨度,这样你就可以很容易地从程序宏中产生多个独立的错误。
跨度(spans)的力量还不止于此;跨度也是 Rust 的宏观卫生的实现方式。当你构造一个 Ident 标记时,你也给出了该标识符的跨度,而该跨度决定了该标识符的范围。如果你把标识符的跨度设置为 Span::call_site(),标识符就会在宏被调用的地方被解析,因此不会与周围的范围隔离。另一方面,如果你把它设置为 Span::mixed_site(),那么(变量)标识符就会在宏的定义处被解析,因此对于调用处的类似命名的变量来说,是完全卫生的。Span::mixed_site 之所以被称为 "mixed_site",是因为它与 macro_rules! 的标识符卫生规则相匹配!正如我们前面所讨论的,在变量使用宏定义站点和类型、模块和其他一切使用调用站点之间 "混合 "了标识符解析。
总结
在这一章中,我们介绍了声明性和过程宏,并探讨了在你的代码中何时会发现它们各自的作用。我们还深入探讨了支撑每种类型的宏的机制,以及当你编写自己的宏时需要注意的一些特性和问题。在下一章中,我们将开始我们的异步编程和 Future 特性的旅程。我保证--它就在下一页。
第八章 异步编程
异步编程,顾名思义,就是不同步的编程。在高水平上,异步操作是一种在后台执行的操作,程序不会等待异步操作完成,而是会立即继续下一行代码。如果你还不熟悉异步编程,这个定义可能会觉得不够充分,因为它实际上并没有解释异步编程是什么。要真正理解异步编程模型以及它在 Rust 中的工作原理,我们首先要挖掘出什么是替代方案。也就是说,在理解异步编程模型之前,我们需要先理解同步编程模型。这对于澄清概念和展示使用异步编程的代价都很重要:异步的解决方案并不总是正确的 在这一章的开始,我们将快速浏览一下异步编程作为一个概念的动机;然后我们将深入探讨 Rust 中的异步在引擎盖下的实际工作情况。
异步是怎么回事?(What’s the Deal with Asynchrony?)
在我们讨论同步和异步编程模型的细节之前,我们首先需要快速浏览一下你的计算机在运行你的程序时究竟在做什么。 计算机的速度很快。非常快。事实上,如此之快,以至于它们大部分时间都在等待事情发生。除非你在解压文件、编码音频或计算数字,否则你的 CPU 有可能大部分时间是闲置的,等待操作完成。它在等待一个网络数据包的到来,等待鼠标的移动,等待磁盘完成一些字节的写入,甚至可能只是等待从主内存的读取完成。从 CPU 的角度来看,大多数这样的事件都是在几个月后发生的。当一个事件发生时,CPU 会多运行几条指令,然后再回去等待。看看你的 CPU 利用率--它可能在某个低的个位数,这可能是它大部分时间徘徊的地方。
同步接口
同步接口允许你的程序(或者说,你程序中的单个线程)一次只执行一个操作;每个操作都要等上一个同步操作完成后才能运行。你在野外看到的大多数接口都是同步的:你调用它们,它们去做一些事情,最后它们在操作完成后返回,你的程序可以继续运行。正如我们在本章后面所看到的,其原因是使一个操作异步化需要相当多的额外机制。除非你需要异步的好处,否则坚持同步模式需要更少的繁文缛节。
同步接口隐藏了所有这些等待;应用程序调用一个函数,说 "把这些字节写到这个文件",一段时间后,该函数完成,下一行代码执行。 在幕后,真正发生的是操作系统排队向磁盘写操作,然后让应用程序进入睡眠状态,直到磁盘报告说它已经完成写入。应用程序将此体验为函数需要很长的时间来执行,但实际上它根本没有真正执行,只是在等待。
以这种方式顺序执行操作的接口也常常被称为阻塞,因为接口中的操作必须等待某些外部事件的发生才能取得进展,在该事件发生之前,会阻止进一步的执行。无论你把一个接口称为同步还是阻塞,其基本思想都是一样的:在当前操作完成之前,应用程序不会继续前进。当操作在等待时,应用程序也在等待。
同步接口通常被认为是容易操作和简单推理的,因为你的代码一次只执行一行。但它们也允许应用程序一次只做一件事。这意味着,如果你想让你的程序等待用户输入或网络数据包,你就不走运了,除非你的操作系统专门为此提供了一个操作。 同样,即使你的程序可以在磁盘写文件时做一些其他有用的工作,它也没有这个选择,因为文件写入操作会阻止执行。
多线程
到目前为止,允许并发执行的最常见的解决方案是使用多线程。在多线程程序中,每个线程负责执行一个特定的独立的阻塞操作序列,操作系统在各线程之间进行多路复用,这样,如果任何线程可以取得进展,就会取得进展。如果一个线程阻塞,其他一些线程可能仍然可以运行,因此应用程序可以继续做有用的工作。
通常情况下,这些线程使用同步基元(primitive)(如锁或通道)相互通信,以便应用程序仍然可以协调它们的工作。例如,你可能有一个线程在等待用户输入,一个线程在等待网络数据包,另一个线程在等待这些线程中的任何一个在所有三个线程之间共享的通道上发送一个消息。
多线程为你提供了并发性--在任何时候都可以执行多个独立操作的能力。由运行应用程序的系统(在这种情况下,操作系统)在没有被阻塞的线程中进行选择,并决定下一步执行哪一个。如果一个线程被阻塞了,它可以选择运行另一个可以取得进展的线程来代替。
多线程与阻塞接口相结合,可以让你走得更远,大量可用于生产的软件都是以这种方式构建的。但这种方法也不是没有缺点的。首先,对所有这些线程的跟踪很快就会变得很麻烦;如果你必须为每一个并发任务(包括像等待键盘输入这样的简单任务)建立一个线程,那么线程的数量就会迅速增加,而跟踪所有这些线程的交互、通信和协调所需的额外复杂性也会增加。
第二,线程越多,切换成本就越高。每当一个线程停止运行,另一个线程重新启动时,你需要在操作系统调度器上做一次往返,而这不是免费的。在一些平台上,生成新的线程也是一个相当沉重的过程。 具有高性能需求的应用程序通常通过重复使用线程和使用操作系统调用来减轻这种成本,允许你在许多相关的操作上进行阻塞,但最终你留下了同样的问题:阻塞接口要求你有与你想要进行的阻塞调用数量一样多的线程。
最后,线程在你的程序中引入了并行性。并发和并行的区别很微妙,但很重要:并发意味着任务的执行是交错进行的,而并行意味着多个任务同时执行。如果你有两个任务,它们的执行用 ASCII 表示可能看起来像_--(并发性)与=====(并行性)。多线程不一定意味着并行,即使你有很多线程,你可能只有一个核心,所以在给定的时间内只有一个线程在执行,但这两者通常是相辅相成的。你可以通过使用 Mutex 或其他同步原语使两个线程在执行中相互排斥,但这也会引入额外的复杂性线程想要并行运行。虽然并行通常是一件好事,但谁不想让自己的程序在更多的内核上运行得更快呢,这也意味着你的程序必须处理对共享数据结构的真正同步访问。这意味着要从 Rc、Cell 和 RefCell 转移到功能更强大但也更慢的 Arc 和 Mutex。虽然你可能想在你的并发程序中使用后一种类型以实现并行性,但线程迫使你使用它们。我们将在第 10 章中更详细地研究多线程。
异步接口
现在我们已经探索了同步接口,我们可以看看另一个选择:异步或非阻塞接口。一个异步接口可能不会直接产生结果,而是表明结果将在稍后的时间内出现。这让调用者有机会在此期间做其他事情,而不是在该特定操作完成之前必须睡觉。在 Rust 的术语中,非同步接口是一个返回 Poll 的方法,如清单 8-1 中定义。
#![allow(unused)] fn main() { enum Poll<T> { Ready(T), Pending } // 清单 8-1:异步的核心:"你在这里或稍后再来 "的类型 }
Poll 通常出现在名称以 poll 开头的函数的返回类型中--这些方法表明它们可以在不阻塞的情况下尝试进行操作。我们将在本章后面讨论它们是如何做到这一点的,但一般来说,它们会在正常阻塞之前尽可能多地执行操作,然后返回。最重要的是,他们会记住他们离开的地方,这样他们就可以在以后恢复执行,并再次取得更多进展。
这些非阻塞函数使我们能够轻松地同时执行多个任务。例如,如果你想从网络或用户的键盘上读取信息,无论哪一个先有事件,你所要做的就是在一个循环中轮询这两者,直到其中一个返回 Poll::Ready。不需要任何额外的线程或同步!
这里的循环一词应该让你有点紧张。你不希望你的程序在一秒钟内通过 30 亿次的循环,而在下一次输入发生之前,它可能是几分钟。在阻塞接口的世界里,这不是一个问题,因为操作系统只是让线程进入睡眠状态,然后在相关事件发生时将其唤醒,但在这个勇敢的非阻塞的新世界里,我们如何避免在等待时燃烧循环? 这就是本章剩余部分的内容。
标准化的轮询
为了达到一个每个库都能以非阻塞方式使用的世界,我们可以让每个库的作者编写他们自己的轮询方法,所有这些方法的名称、签名和返回类型都略有不同,但这很快就会变得不方便。相反,在 Rust 中,轮询是通过 Future trait 标准化的。清单 8-2 中显示了 Future 的简化版本(我们将在本章的后面回到真正的版本)。
#![allow(unused)] fn main() { trait Future { type Output; fn poll(&mut self) -> Poll<Self::Output>; } // 清单 8-2: Future trait 的简化视图 }
实现 Future 特性的类型被称为 futures,代表可能还没有的值。一个 future 可以代表一个网络数据包的下一次到来,鼠标光标的下一次移动,或者仅仅是某个时间点的过去。你可以把 Future<Output = Foo> 理解为 "一个在 future 会产生 Foo 的类型"。像这样的类型在其他语言中经常被称为承诺--它们承诺最终会产生指定的类型。当一个 future 最终返回 Poll::Ready(T) 时,我们说这个 future 被解析为一个 T。
有了这个特性,我们可以概括提供 poll 方法的模式。与使用 poll_recv 和 poll_keypress 这样的方法不同,我们可以使用 recv 和 keypress 这样的方法,它们都返回具有适当 Output 类型的 impl Future。这并不会改变你必须轮询它们的事实(我们稍后会处理这个问题),但这确实意味着至少有一个标准化的接口来处理这些挂起的值,我们不需要到处使用 poll_ 前缀。
注意:在一般情况下,在一个
future返回Poll::Ready后,你不应该再次轮询。如果你这样做了,那么这个 future 就有权利恐慌了。一个在返回Ready后可以安全轮询的 future,有时被称为融合的 future。
人体工程学的 Futures
以我到目前为止所描述的方式编写一个实现 Future 的类型是相当麻烦的。要知道为什么,首先看一下清单 8-3 中相当直接的异步代码块,它只是试图将消息从输入通道 rx 转发到输出通道 tx。
#![allow(unused)] fn main() { async fn forward<T>(rx: Receiver<T>, tx: Sender<T>) { while let Some(t) = rx.next().await { tx.send(t).await; } } // 清单 8-3:使用`async`和`await`实现一个通道转发的 future }
这段使用 async 和 await 语法编写的代码,看起来与同等的同步代码非常相似,很容易阅读。我们简单地在一个循环中发送我们收到的每一条消息,直到没有更多的消息为止,每一个 await 点都对应着一个同步变体可能阻塞的地方。现在想想,如果你不得不通过手动实现 Future trait 来表达这段代码,由于每次调用 poll 都是从函数的顶部开始的,你需要打包必要的状态,以便从代码最后产生的地方继续下去。结果是相当怪异的,正如清单 8-4 所展示的。
#![allow(unused)] fn main() { enum Forward<T> { // (1) WaitingForReceive(ReceiveFuture<T>, Option<Sender<T>>), WaitingForSend(SendFuture<T>, Option<Receiver<T>>), } impl<T> Future for Forward<T> { type Output = (); // (2) fn poll(&mut self) -> Poll<Self::Output> { match self { // (3) Forward::WaitingForReceive(recv, tx) => { if let Poll::Ready((rx, v)) = recv.poll() { if let Some(v) = v { let tx = tx.take().unwrap(); // (4) *self = Forward::WaitingForSend(tx.send(v), Some(rx)); // (5) // Try to make progress on sending. return self.poll(); // (6) } else { // No more items. Poll::Ready(()) } } else { Poll::Pending } } Forward::WaitingForSend(send, rx) => { if let Poll::Ready(tx) = send.poll() { let rx = rx.take().unwrap(); *self = Forward::WaitingForReceive(rx.receive(), Some(tx)); // Try to make progress on receiving. return self.poll(); } else { Poll::Pending } } } } } // 清单 8-4:手动实现通道转发的 future }
在 Rust 中,你很少需要写这样的代码,但它让我们了解到事情是如何运作的,所以让我们来看看它。首先,我们将我们的 future 类型定义为枚举 (1),我们将用它来跟踪我们当前正在等待的东西。 这是由于当我们返回 Poll::Pending 时,对 poll 的下一次调用将从函数的顶部开始。我们需要一些方法来知道我们正在做什么,这样我们就知道该继续哪个操作了。此外,我们需要根据我们正在做的事情来跟踪不同的信息:如果我们正在等待一个接收完成,我们需要保留那个 ReceiveFuture(其定义在本例中没有显示),这样我们就可以在下次自己被轮询时轮询它,SendFuture 也是如此。这里的选项可能也会让你觉得很奇怪;我们很快就会回到这些问题上。
当我们为 Forward 实现 Future 时,我们将其输出类型声明为 (2),因为这个 future 实际上并不返回任何东西。相反,当它完成了从输入通道到输出通道的所有转发后,future 就会解析(没有结果)。在一个更完整的例子中,我们的转发类型的 Output 可能是一个 Result ,这样它就可以把来自 receive() 和 send() 的错误从堆栈中传回给正在轮询转发完成情况的函数。但是这段代码已经够复杂了,所以我们改天再讨论这个问题。
当 Forward 被轮询时,它需要恢复到它最后离开的地方,我们通过匹配当前在 self (3) 中持有的枚举变量来找到它。无论我们进入哪个分支,第一步都是轮询阻止当前操作进展的 future;如果我们试图接收,我们轮询 ReceiveFuture,如果我们试图发送,我们轮询 SendFuture。如果对 poll 的调用返回 Poll::Pending,那么我们就不能取得任何进展,并且我们自己也返回 Poll::Pending。但是,如果当前的 future 解决了,我们就有工作要做了!
当内部 futures 之一解决时,我们需要通过切换存储在 self 中的枚举变量来更新当前的操作是什么。为了做到这一点,我们必须从 self 中移出,调用 Receiver::receive 或 Sender::send--但我们不能这样做,因为我们只有&mut self。因此,我们把要移动的状态存储在一个 Option 中,然后用 Option::take (4) 移出。这样做很傻,因为我们将要覆盖 self (5),因此 OptionS 总是 Some,但有时需要一些技巧来使借用检查器高兴。
最后,如果我们确实取得了进展,我们会再次轮询 self (6),这样如果我们可以立即在待定的发送或接收上取得进展,我们就会这样做。这对于实现真正的 Future 特性时的正确性来说是必要的,我们稍后会回到这个问题上,但现在可以把它看作是一种优化。
我们只是手写了一个状态机:一个有许多可能的状态并在它们之间移动以响应特定事件的类型。这只是一个相当简单的状态机。想象一下,在更复杂的用例中,你不得不写这样的代码,因为你有额外的中间步骤
除了编写笨重的状态机之外,我们还必须知道 Sender::send 和 Receiver::receive 所返回的 future 的类型,这样我们才能将它们存储在我们的类型中。如果这些方法返回的是 impl Future,我们就没有办法为我们的变量写出类型。 send 和 receive 方法还必须拥有发送者和接收者的所有权;如果它们不这样做,它们返回的 future 的生存期就会与self 的借用相联系,当我们从轮询中返回时,它就会结束。但这是行不通的,因为我们正试图将这些 future 存储在 self 中。
注意:你可能已经注意到
Receiver看起来很像Iterator的异步版本。其他人也注意到了同样的事情,标准库正准备为能够有意义地实现poll_next的类型添加一个 trait。下一步,这些异步迭代器(通常被称为流)最终可能会得到一流的语言支持,比如直接在它们上面循环的能力。
归根结底,这段代码很难写,很难读,也很难改。例如,如果我们想增加错误处理,代码的复杂性将大大增加。幸运的是,有一个更好的方法!
async/await
Rust 1.39 给了我们 async 关键字和密切相关的 await 后缀操作符,我们在清单 8-3 的原始例子中使用了它们。它们一起为编写像清单 8-5 中的异步状态机提供了更方便的机制。 具体来说,它们可以让你以这样的方式编写代码,甚至看起来都不像一个状态机!
#![allow(unused)] fn main() { async fn forward<T>(rx: Receiver<T>, tx: Sender<T>) { while let Some(t) = rx.next().await { tx.send(t).await; } } // 清单 8-5:使用 async 和 await 实现一个通道转发的 future,与清单 8-3 重复 }
如果你对 async 和 await 没有太多的经验,清单 8-4 和清单 8-5 之间的区别可能会让你明白为什么 Rust 社区看到它们的登陆会如此兴奋。 但由于这是一本中级书,让我们再深入一点,了解这一小段代码是如何取代更长的手工实现的。要做到这一点,我们首先要谈一谈生成器--实现 async 和 await 的机制。
生成器(Generators)
简单地说,生成器是一段带有一些额外的编译器生成位的代码,这些位使生成器能够在执行过程中停止或生成,然后从最后生成的位置继续执行。以清单 8-3 中的 forward 函数为例。假设它到达了要发送的调用,但是通道当前已满。这个函数不能取得任何进展,但它也不能阻塞(毕竟这是非阻塞代码),所以它需要返回。现在假设通道最终清除,我们想继续发送。如果我们再次从顶部调用 forward,它将再次调用 next,我们之前试图发送的项目将丢失,所以这是不好的。相反,我们变成了一个生成器。
每当 forward 生成器无法继续执行时,它需要将其当前状态存储在某个地方,以便当它最终恢复执行时,它将以正确的状态在正确的位置继续执行。它通过编译器生成的相关数据结构保存状态,该结构包含生成器在给定时间点的所有状态。该数据结构上的一个方法(也是生成的)允许生成器从其当前状态恢复,存储在 &mut self 中,并在生成器再次无法继续时更新状态。
这种 "返回但允许我稍后恢复 "的操作被称为让步 (yielding),这实际上意味着它在返回的同时保留了一些额外的状态在一边。当我们以后想恢复对 forward 的调用时,我们调用已知的进入生成器的入口(resume 方法,这是异步生成器的轮询),生成器检查先前存储在 self 中的状态以决定下一步该做什么。 这与我们在清单 8-4 中手动做的事情完全一样换句话说,清单 8-5 中的代码松散地描述了清单 8-6 中的假设代码。
#![allow(unused)] fn main() { generator fn forward<T>(rx: Receiver<T>, tx: Sender<T>) { loop { let mut f = rx.next(); let r = if let Poll::Ready(r) = f.poll() { r } else { yield }; if let Some(t) = r { let mut f = tx.send(t); let _ = if let Poll::Ready(r) = f.poll() { r } else { yield }; } else { break Poll::Ready(()); } } } // 清单 8-6:将 async/await 解构为一个生成器 }
在写这篇文章的时候,生成器实际上在 Rust 中是不能使用的--它们只是被编译器内部用来实现 async/await--但这在将来可能会改变。生成器在很多情况下都很方便,比如实现迭代器而不需要随身携带一个 struct ,或者实现一个 impl Iterator 它可以计算出如何一次产生一个项目。
如果你仔细看一下列表 8-5 和 8-6,一旦你知道每一个 await 或 yield 实际上是一个函数的返回,它们就会显得有些神奇。毕竟,在函数中有几个局部变量,而且不清楚当我们以后恢复时如何恢复它们。这就是编译器产生的生成器部分发挥作用的地方。编译器透明地注入代码,将这些变量持久化,并在执行时从生成器的相关数据结构中读取这些变量,而不是栈。因此,如果你声明、写入或从某个局部变量 a 中读取,你实际上是在操作类似于 self.a 的东西。这一切真的很奇妙。
手动 forward 实现和异步/等待版本之间微妙但重要的区别是,后者可以跨让步(yield)点保持引用。这使得清单 8-5 中的 Receiver::next 和 Sender::send 等函数可以使用 &mut self,而不是清单 8-4 中的 self。如果我们试图在手动状态机的实现中为这些方法使用 &mut self 接收器,借用检查器将没有办法强制执行存储在 Forward 中的 Rreceiver 在 Receiver::next 被调用和它返回的 future 被解析之间不能被引用,因此它将拒绝该代码。只有将 Receiver 移到 future 中,我们才能让编译器相信 Receiver 在其他方面是不可访问的。同时,通过 async/await,借用检查器可以在编译器将其变成状态机之前检查代码,并验证 rx 确实没有被再次访问,直到 future 被丢弃之后,当它的 await 返回时。
生成器的大小
用来支持生成器状态的数据结构必须能够容纳任何一个 yield 点的组合状态。如果你的
async fn包含,比如说,一个[u8; 8192],这 8KiB 必须存储在发生器本身。即使你的async fn只包含较小的局部变量,它也必须包含它所等待的任何 future,因为它需要在稍后调用poll时能够轮询这样的 future。这种嵌套意味着生成器,以及基于异步函数和块的 future,可以变得相当大,而在你的代码中却没有任何可见的迹象表明其大小增加。 这反过来又会影响你的程序的运行性能,因为这些巨大的生成器可能必须在函数调用之间以及在数据结构中复制,这相当于相当数量的内存复制。事实上,你通常可以通过在你的应用程序的性能档案中寻找在 memcpy 函数中花费的过多时间来识别你的基于生成器的 future 的大小是如何影响性能的!
然而,找到这些大型 future 并不容易,往往需要手动识别长的或复杂的异步函数链。Clippy 将来可能会在这方面提供帮助,但在写这篇文章的时候,你只能靠自己了。当你发现一个特别大的 future 时,你有两个选择:你可以尝试减少异步函数需要的本地状态的数量,或者你可以把 future 移到堆里(用 Box::pin),这样,移动 future 只需要移动它的指针。后者是迄今为止最简单的方法,但它也引入了一个额外的分配和一个指针指示。你最好的选择通常是把有问题的 future 时放在堆上,测量你的性能,然后用你的性能基准来指导你。
Pin 和 Unpin
我们还没有完全完成。虽然生成器很整洁,但到目前为止,我所描述的技术出现了一个挑战。特别是,如果生成器中的代码(或者相当于异步块)需要一个对局部变量的引用,会发生什么并不清楚。在清单 8-5 的代码中,如果下一条消息没有立即可用,rx.next() 返回的 future 必须持有对 rx 的引用,这样它就知道在生成器下一次恢复时应该在哪里再试。当生成器停止工作时,future 和 future 所包含的引用就被藏在生成器中。但是,如果生成器被移动,现在会发生什么呢?具体来说,请看清单 8-7 中的代码,它调用了 forward。
#![allow(unused)] fn main() { async fn try_forward<T>(rx: Receiver<T>, tx: Sender<T>) -> Option<impl Future> { let mut f = forward(rx, tx); if f.poll().is_pending() { Some(f) } else { None } } // 清单 8-7:在轮询后移动一个 future }
try_forward 函数只进行一次轮询 forward,以尽可能多地转发消息而不阻塞。如果接收器仍然可能产生更多的消息(也就是说,如果它返回 Poll::Pending 而不是 Poll::Ready(None)),这些消息将被推迟到以后的某个时间转发,方法是将转发 future 返回给调用者,调用者可以选择在它认为合适的时候再次轮询。
让我们用我们到目前为止对 async 和 await 的了解来研究一下这里发生了什么。当我们轮询前向生成器时,它经过了不知多少次的 while 循环,如果接收方结束了,最终返回 Poll::Ready(()),否则返回 Poll::Pending。如果它返回 Poll::Pending,生成器包含一个从 rx.next() 或 tx.send(t) 返回的 future。这些 future 都包含对最初提供给 forward 的参数之一的引用(分别是 rx 和 tx),这些参数也必须存储在生成器中。但是当 try_forward 返回整个生成器时,生成器的字段也会移动。因此,rx 和 tx 不再位于内存中的相同位置,而存储在停顿的 future 中的引用也不再指向正确的数据了
我们在这里遇到的是一个自引用的数据结构的情况:一个既持有数据又对该数据进行引用的结构。有了生成器,这些自引用结构很容易构建,如果不能支持它们,将是对人机工程学的重大打击,因为这意味着你将无法在任何 yield 点上持有引用。在 Rust 中支持自引用数据结构的(巧妙的)解决方案是以 Pin 类型和 Unpin 特性的形式出现的。简而言之,Pin 是一个封装类型,它可以防止被封装的类型被(安全地)移动,而 Unpin 是一个标记特质,表示实现的类型可以安全地从 Pin 中移除。
Pin
这里有很多细微的差别,所以让我们从 Pin 包装器的一个具体使用开始。清单 8-2 给了你一个简化的 Future trait,但我们现在准备剥开简化的一部分。清单 8-8 显示了 Future trait 在某种程度上更接近其最终形式。
#![allow(unused)] fn main() { trait Future { type Output; fn poll(self: Pin<&mut Self>) -> Poll<Self::Output>; } // 清单 8-8:一个不那么简化的关于 `future` trait 的视图,其中包括 `Pin` }
特别是,这个定义要求你在 Pin<&mut Self >上调用 poll。一旦你在 Pin 后面有一个值,这就构成了一个契约,这个值将永远不会再移动。这意味着你可以随心所欲地在内部构建自我引用,完全按照你对生成器的要求。
注意:虽然
Future使用了Pin,但Pin并不与Futuretrait 相联系--你可以将Pin用于任何自引用的数据结构。
但你如何让一个 Pin 调用轮询?以及 Pin 如何确保包含的值不会移动?为了看看这个魔法是如何运作的,让我们看看 std::pin::Pin 的定义和它的一些关键方法,如清单 8-9 所示。
#![allow(unused)] fn main() { struct Pin<P> { pointer: P } impl<P> Pin<P> where P: Deref { pub unsafe fn new_unchecked(pointer: P) -> Self; } impl<'a, T> Pin<&'a mut T> { pub unsafe fn get_unchecked_mut(self) -> &'a mut T; } impl<P> Deref for Pin<P> where P: Deref { type Target = P::Target; fn deref(&self) -> &Self::Target; } // 清单 8-9:std::pin::Pin 和它的关键方法 }
这里有很多东西需要解读,我们要把清单 8-9 中的定义复习几遍,才能让所有的部分都有意义,所以请耐心等待。
首先,你会注意到,Pin 持有一个指针类型。也就是说,它不是直接持有某个 T,而是持有一个通过 Deref 推断到 T 的类型 P。这意味着你不是拥有一个 Pin<MyType>,而是拥有一个 Pin<Box<MyType> 或者 Pin<Rc<MyType> 或者 Pin<&mut MyType>。这样设计的原因很简单--Pin 的主要目标是确保一旦你把一个 T 放在 Pin 后面,这个 T 就不会移动,因为这样做可能会使存储在 T 中的自我引用失效。 在本节的其余部分,我将把 P 称为指针类型,T 称为目标类型。
接下来,注意到 Pin 的构造函数 new_unchecked 是不安全的。这是因为编译器没有办法实际检查指针类型是否确实承诺了被指向的(目标)类型不会再移动。例如,考虑到栈中的变量 foo。如果 Pin 的构造函数是安全的,我们可以做 Pin::new(&mut foo),调用一个需要 Pin<&mut Self> 的方法(从而假设 Self 不会再移动),然后放弃 Pin。在这一点上,我们可以随心所欲地修改 foo,因为它不再是借来的了--包括移动它!然后我们可以再次钉住它,并调用 Self 的方法。然后我们可以再次钉住它,并调用同样的方法,这样它就不会知道它第一次构建的任何自引用指针现在都是无效的了。
Pin 构造函数的安全性
Pin的构造函数不安全的另一个原因是,它的安全性取决于本身是安全的 traits 的实现。例如,Pin<P>实现get_unchecked_mut的方式是使用DerefMut::deref_mut对P的实现。虽然对get_unchecked_mut的调用是不安全的,但impl DerefMut for P却不是。然而它收到了一个&mut self,因此可以自由地(并且没有不安全的代码)移动 T。因此,对Pin::new_unchecked的安全要求不仅是指针类型不会让目标类型再次被移动(就像在Pin<&mut T>的例子中),而且它的Deref、DerefMut和Drop实现也不会将指向的值移动到它们所接收的&mut self后面。
然后我们进入 get_unchecked_mut 方法,它给你一个 Pin 指针类型背后的 T 的可变引用。这个方法也是不安全的,因为一旦我们给出了一个 &mut T,调用者就必须保证不会用这个 &mut T 来移动这个 T 或以其他方式使其内存失效,以免任何自引用被失效。如果这个方法不是不安全的,调用者可以调用一个接收 Pin<&mut Self> 的方法,然后在两个 Pin<&mut _> 上调用 get_unchecked_mut 的安全变量,然后使用 mem::swap 来交换 Pin 后面的值。如果我们再在任何一个 Pin 上调用一个接收 Pin<&mut Self> 的方法,它关于 Self 没有移动的假设就会被违反,它所存储的任何内部引用都会失效。
也许令人惊讶的是,Pin<P> 总是实现 Deref<Target = T>,而这是完全安全的。原因是 &T 并不能让你在不写其他不安全代码的情况下移动 T(例如 UnsafeCell,我们将在第 9 章讨论)。这是一个很好的例子,说明为什么不安全块的范围会超出它所包含的代码。如果你在应用程序的某个部分写了一些代码,使用 UnsafeCell(不安全地)替换了一个&后面的 T,那么这个&T 最初可能来自一个 Pin<&mut T>,你现在已经违反了 Pin 后面的 T 可能永远不会移动的不变性,即使你不安全地替换 &T 的地方甚至没有提到 Pin!
注意:如果你在阅读本章时浏览了
Pin文档,你可能已经注意到Pin::set,它接收一个&mut self和一个<P as Deref>::Target,并安全地改变Pin后面的值。这是有可能的,因为set并不返回之前被钉住的值--它只是把它丢在原地,并把新的值存储在那里。因此,它并不违反钉子的不变性:旧的值被放置在钉子之后,从未在钉子之外被访问过。
Unpin: 安全 Pinning 的关键
在这一点上,你可能会问:鉴于获取一个可变的引用是不安全的,为什么不直接让 Pin 持有一个 T?也就是说,与其要求通过指针类型进行转接,不如为 get_unchecked_mut 制定契约,即只有在你没有移动 Pin 的情况下才可以安全调用它。这个问题的答案就在于指针设计中对 Pin 的安全使用。回想一下,我们首先需要 Pin 的全部原因是,我们可以有可能包含对自己的引用的目标类型(比如生成器),并给他们的方法一个保证,即目标类型没有移动,因此内部的自我引用仍然有效。Pin 让我们使用类型系统来执行这一保证,这很好。但不幸的是,就目前的设计而言,Pin 的工作非常不容易。这是因为它总是需要不安全的代码,即使你正在处理一个不包含任何自我引用的目标类型,因此并不关心它是否被移动。
这就是标记属性 Unpin 发挥作用的地方。一个类型的 Unpin 实现简单地断言,当被用作目标类型时,该类型可以安全地从 Pin 中移动出来。也就是说,该类型承诺,当被用作目标类型时,它将永远不会使用任何关于参照物不再移动的 Pin 的保证,因此这些保证可能被破坏。Unpin 是一个自动 trait,就像 Send 和 Sync 一样,所以对于任何只包含 Unpin 成员的类型,编译器都会自动实现。只有那些明确选择不使用 Unpin 的类型(比如生成器)和包含这些类型的类型才是 !Unpin。
对于 Unpin 的目标类型,我们可以提供一个更简单的安全接口给 Pin,如清单 8-10 所示。
#![allow(unused)] fn main() { impl<P> Pin<P> where P: Deref, P::Target: Unpin { pub fn new(pointer: P) -> Self; } impl<P> DerefMut for Pin<P> where P: DerefMut, P::Target: Unpin { fn deref_mut(&mut self) -> &mut Self::Target; } // 清单 8-10:对 Unpin 目标类型的安全 API }
为了理解清单 8-10 中的安全 API,请思考清单 8-9 中不安全方法的安全要求:函数 Pin::new_unchecked 是不安全的,因为调用者必须保证参照物不能被移动到 Pin 之外,而且指针类型的 Deref、DerefMut 和 Drop 的实现不会通过它们接收的参照物移动参照物。这些要求是为了确保一旦我们给一个 T 发出一个 Pin,我们就不会再移动这个 T。但是如果这个 T 是 Unpin,它已经声明它不关心它是否被移动,即使它之前被钉住了,所以如果调用者不满足这些要求也没关系!
类似地,get_unchecked_mut 是不安全的,因为调用者必须保证它不会将 T 从 &mut T 中移出--但是在 T:Unpin 中,T 已经声明它在被钉住后也可以被移动,所以这个安全要求不再重要了。这意味着对于 Pin<P>,P::Target:Unpin,我们可以简单地提供这两个方法的安全变体(DerefMut 是 get_unchecked_mut 的安全版本)。事实上,我们甚至可以提供一个 Pin::into_inner,如果目标类型是 Unpin,它可以简单地返回拥有的 P,因为 Pin 本质上是不相关的。
获取 Pin 的方法
有了对 Pin 和 Unpin 的新理解,我们现在可以在使用清单 8-8 中要求 Pin<&mut Self> 的新 Future 定义上取得进展。第一步是构建所需的类型。 如果 Future 的类型是 Unpin,这一步就很容易了,我们只需使用 Pin::new(&mut future)。如果它不是 Unpin,我们可以通过两种主要方式之一钉住 future:钉住堆或钉住栈。
让我们从钉在堆里开始。Pin 的主要契约是,一旦某个东西被钉住,它就不能移动。Pining API 负责为 Pin 上的所有方法和特性遵守这一契约,所以任何构造 Pin 的函数的主要作用是确保如果 Pin 本身移动,引用值也不会移动。确保这一点的最简单的方法是将参照物放在堆上,然后在 Pin 中放置一个指向参照物的指针。然后你可以随心所欲地移动 Pin,但目标将保持在原来的位置。这就是 Box::pin(安全)方法背后的原理,它接收一个 T 并返回一个 Pin<Box<T>>。它没有任何魔力;它只是断言 Box 遵循 Pin 的构造函数、Deref 和 Drop 契约。
UNPIN BOX
当我们在讨论
Box的时候,看看Box的Unpin的实现吧。Box类型无条件地对任何T实现了Unpin,即使该T不是Unpin。这可能会让你感到奇怪,因为前面说过,Unpin是一个自动特性,通常只有在一个类型的所有成员都是Unpin的情况下才会为该类型实现。Box 是一个例外,因为它可以提供一个安全的Pin构造函数:如果你移动一个Box<T>,你不会移动T。 换句话说,无条件的实现断言你可以将一个Box<T>移出Pin,即使T不能被移出 Pin。 然而请注意,这并不能使你将一个是!Unpin的T移出Pin<Box<T>>。
另一个选项,即钉在栈上,就比较麻烦了,在写这篇文章的时候需要一点不安全的代码。我们必须确保被钉住的值在带有&mut 的 Pin 被丢弃后不能被访问。如清单 8-11 中的宏所示,我们通过遮蔽(shadowing)该值来实现这一目标,或者通过使用提供该宏的某个 crate 来实现。有一天,它甚至可能被纳入标准库中。
#![allow(unused)] fn main() { macro_rules! pin_mut { ($var:ident) => { let mut $var = $var; let mut $var = unsafe { Pin::new_unchecked(&mut $var) }; } } // 清单 8-11:用于钉住栈的宏程序 }
通过获取要钉在栈上的变量名称,该宏确保调用者已经在栈的某个地方得到了它想要钉的值。$var 的遮蔽确保了调用者不能放弃 Pin 并继续使用未被钉住的值(这将违反任何目标类型的!!Unpin 契约)。通过移动存储在$var 中的值,该宏还确保调用者不能在不放弃原始变量的情况下放弃绑定宏声明的$var。具体来说,如果没有这一行,调用者可以写(注意额外的范围):
#![allow(unused)] fn main() { let foo = /* */; { pin_mut!(foo); foo.poll() }; foo.mut_self_method(); }
在这里,我们把 foo 的一个钉住的实例给了 poll,但后来我们又对 foo 使用了一个没有 Pin 的 &mut,这违反了引脚契约。另一方面,有了额外的重新赋值,这段代码也会把 foo 移到新的作用域中,使它在作用域结束后无法使用。
因此,在栈上的钉子需要不安全的代码,与 Box::pin 不同,但避免了 Box 引入的额外分配,也可以在 no_std 环境下工作。
返回 Future
我们现在有了钉住的 future,而且我们知道这意味着什么。但你可能已经注意到,在你用 async 和 await 编写的大多数异步代码中,这些重要的钉住东西都没有显示出来。 这是因为编译器把它隐藏起来了。
回想一下我们讨论清单 8-5 的时候,我告诉你 <expr>.await 脱糖会变成类似的东西:
#![allow(unused)] fn main() { loop { if let Poll::Ready(r) = expr.poll() { break r } else { yield } } }
这是一个非常轻微的简化,因为正如我们所看到的,只有当你有一个 Pin<&mut Self> 作为 future 的时候,你才能调用 Future::poll。 解码实际上要复杂一些,如清单 8-12 所示。
#![allow(unused)] fn main() { match expr { // (1) mut pinned => loop { match unsafe { Pin::new_unchecked(&mut pinned) }.poll() { // (2) Poll::Ready(r) => break r, Poll::Pending => yield, } } } // 清单 8-12:对 `<expr>.await` 进行更准确的解读。 }
匹配 (1) 是一个巧妙的速记,不仅可以确保扩展仍然是一个有效的表达式,而且可以将表达式的结果移到一个变量中,然后我们可以将其钉在栈上。除此之外,主要的新增内容是对 Pin::new_unchecked (2) 的调用。这个调用是安全的,因为对于包含异步块的轮询,由于 Future::poll 的签名,它必须已经被钉住了。而这个异步块是被轮询过的,以便我们能达到对 Pin::new_unchecked 的调用,所以生成器的状态是被钉住的。由于 pinned 存储在与异步块相对应的生成器中(必须如此才能正确恢复 yield),我们知道 pinned 不会再移动。此外,除了通过 Pin 之外,一旦我们进入循环,pinned 就不能被访问,所以没有任何代码能够从 pinned 的值中移动出来。因此,我们满足了 Pin::new_unchecked 的所有安全要求,并且代码是安全的。
进入睡眠(Going to Sleep)
我们在 Pin 的问题上走得很深,但现在我们从另一边出来了,还有一个围绕着 future 的问题可能已经让你的大脑发痒了。如果对 Future::poll 的调用返回 Poll::Pending,你需要在以后的时间里再次调用 poll,以检查你是否可以取得进展。这个东西通常被称为执行器 (executor)。你的执行器可以是一个简单的循环,轮询所有你正在等待的 future,直到它们都返回 Poll::Ready,但这将消耗大量的 CPU 周期,你可能会用在其他更有用的地方,比如运行你的网页浏览器。相反,我们希望执行器做任何它能做的有用的工作,然后进入睡眠状态。它应该保持睡眠状态,直到其中一个 future 能够取得进展,然后才会醒来做另一个程序,然后再去睡觉。
唤醒(Waking Up)
决定何时对一个给定的 future 进行回访的条件差别很大。它可能是 "当一个网络包到达这个端口时","当鼠标光标移动时","当有人在这个通道上发送时","当 CPU 收到一个特定的中断时",甚至是 "在这么多时间过去后"。在此基础上,开发者可以编写自己的 future,包裹多个其他 future,因此,他们可能有多个唤醒条件。一些 future 甚至可以引入他们自己完全自定义的唤醒事件。
为了适应这些众多的用例,Rust 引入了 Waker 的概念:一种唤醒执行器的方式,以示可以取得进展。Waker 是使整个 future 机制发挥作用的原因。执行者构建了一个 Waker,它与执行者用来进入睡眠状态的机制结合在一起,并将 Waker 传递给它所轮询的任何 future。怎么做?通过 Future::poll 的额外参数,到目前为止我还没有告诉你。这一点很抱歉。清单 8-13 给出了 Future 的最终和真正的定义--不再有谎言了!
#![allow(unused)] fn main() { trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } ` // 清单 8-13:实际的 Future trait }
&mut Context 包含 Waker。参数是一个 Context,而不是直接的 Waker,所以如果认为有必要的话,我们可以用额外的上下文来增强异步生态系统的 futures。
Waker 的主要方法是 wake(以及通过引用的变体 wake_by_ref),它应该在 future 可以再次取得进展时被调用。wake 方法不需要任何参数,其效果完全由构建 Waker 的执行者定义。你看,Waker 在秘密地对执行者进行通用。或者,更准确地说,不管是什么构造的 Waker 都可以决定 Waker::wake 被调用时,Waker 被克隆时,Waker 被放弃时,会发生什么。 这一切都通过一个手动实现的 vtable 发生,其功能类似于我们在第二章讨论的动态调度。
构造一个 Waker 的过程有点复杂,它的机制对于使用一个 Waker 来说并不是那么重要,但是你可以在标准库中的 RawWakerVTable 类型中看到构建模块。它有一个构造函数,接收 wake 和 wake_by_ref 以及 Clone 和 Drop 的函数指针。RawWakerVTable 通常在一个执行者的所有 Waker 中共享,它与一个原始指针捆绑在一起,用于保存每个 Waker 实例的特定数据(比如它是为哪个 future 服务的),并被转化为一个 RawWaker。这又被传递给 Waker::from_raw,产生一个安全的 Waker,可以传递给 Future::poll。
履行轮询契约(Fulfilling the Poll Contract)
到目前为止,我们已经绕过了 future 对唤醒器的实际作用。 这个想法相当简单:如果 Future::poll 返回 Poll::Pending,那么 future 就有责任确保在 future 下次能够取得进展时调用所提供的唤醒器。大多数 future 维护这一属性的方法是,只有当其他一些 future 也返回 Poll::Pending 时,才返回 Poll::Pending;以这种方式,它琐碎地履行了 poll 的契约,因为内部 future 必须遵循同样的契约。但不可能一路都是乌龟。在某些时候,你会遇到一个不轮询其他 future 的 future,而是做一些事情,比如写到网络套接字或试图在一个通道上接收。这些通常被称为叶子 future,因为它们没有孩子。一个叶子 future 没有内部 future,而是直接代表一些可能还没有准备好返回结果的资源。
注意:轮询契约是清单 8-4 中的递归轮询调用 (6) 对于正确性来说是必要的原因。
叶子 future 通常有两种形式:一种是等待同一进程中的事件(如通道接收器),另一种是等待进程外部的事件(如 TCP 数据包读取)。那些等待内部事件的程序都倾向于遵循相同的模式:将唤醒你的代码存放在可以找到它的地方,当它产生相关事件时,让代码调用唤醒 Waker。例如,考虑一个需要在内存通道中等待消息的叶子 future。它将其 Waker 存储在发送方和接收方共享的通道部分,然后返回 Poll::Pending。当一个发送者出现并向通道中注入消息时,它注意到等待的接收者留在那里的唤醒器,并在从 send 返回之前调用唤醒器。现在接收方被唤醒了,并且轮询合同得到了维护。
处理外部事件的叶子 future 涉及的内容更多,因为产生它们所等待的事件的代码对 future 或 waker 一无所知。最常见的生成代码是操作系统内核,它知道磁盘何时准备好或计时器何时到期,但它也可能是一个 C 语言库,当一个操作完成时调用一个回调到 Rust 或其他类似的外部实体。像这样包裹外部资源的叶子 future 可以旋转一个线程,执行一个阻塞的系统调用(或等待 C 的回调),然后使用内部唤醒机制,但这将是一种浪费;你会在每次操作必须等待时旋转一个线程,并留下许多单一用途的线程坐在周围等待事情。
相反,执行者倾向于提供叶子 future 的实现,在幕后与执行者沟通,安排与操作系统的适当互动。具体如何安排取决于执行器和操作系统,但大致上说,执行器会跟踪所有它应该在下次睡眠时监听的事件源。当叶子的 future 意识到它必须等待一个外部事件时,它就会更新该执行器的状态(它知道这个状态,因为它是由执行器 crate 提供的),以包括该外部事件源和它的 Waker。当执行器不能再取得进展时,它就会收集各种待定的叶子 future 所等待的所有事件源,并对操作系统进行一个大的阻塞调用,告诉它在叶子 future 所等待的任何资源有新的事件时返回。在 Linux 上,这通常是通过 epoll 系统调用实现的;Windows、BSD、macOS 和几乎所有其他操作系统都提供类似的机制。当该调用返回时,执行者在所有与操作系统报告的事件源相关的唤醒者上调用唤醒,因此,轮询合约得到了履行。
注意:反应器(reactor)是执行器的一部分,叶子 future 用它来注册事件源,当它没有更多有用的工作要做时,执行器就会等待它。将执行器和反应器分开是可能的,但将它们捆绑在一起通常会提高性能,因为两者可以更容易地共同优化。
叶子 future 和执行器之间紧密结合的一个连锁反应是,来自一个执行器 crate 的叶子 future 往往不能用于不同的执行器。或者至少,除非叶子 future 的执行器也在运行,否则它们不能被使用。当叶子 future 去存储它的 Waker 并注册它所等待的事件源时,它所对应的执行器需要有该状态的设置并需要运行,这样事件源才会被实际监控并最终被唤醒。有一些方法可以解决这个问题,比如让叶子 future 生成一个执行器,如果一个执行器还没有运行的话,但这并不总是可取的,因为这意味着一个应用程序可以透明地最终有多个执行器同时运行,这可能会降低性能,并意味着你在调试时必须检查多个执行器的状态。
希望支持多个执行器的库 crate 必须对其叶子资源进行通用。例如,一个库可以存储一个泛型的 T:AsyncRead + AsyncWrite,而不是使用特定执行器的 TcpStream 或 File future 类型。然而,生态系统还没有确定这些特征到底应该是什么样子的,以及哪些特征是需要的,所以就目前而言,要使代码在执行器上真正通用是相当困难的。例如,虽然 AsyncRead 和 AsyncWrite 在整个生态系统中是比较常见的(或者在必要时可以很容易地进行调整),但目前没有任何 trait 用于在后台运行一个 future(spwning,我们将在后面讨论)或用于表示一个计时器。
醒着是用词不当(Waking Is a Misnomer)
你可能已经意识到 Waker::wake 似乎不一定能唤醒任何东西。例如,对于外部事件(如上一节所述),执行者已经被唤醒了,而它在属于该执行者的 Waker 上调用唤醒,这似乎很愚蠢实际情况是,Waker::wake` 是一个错误的名称--实际上,它是一个特定 future 可运行的信号。 也就是说,它告诉执行器,当它有机会时,应该确保轮询这个特定的 future,而不是再去睡觉,因为这个 future 可以取得进展。如果执行器目前正在睡觉,这可能会唤醒它,这样它就会去轮询那个 future,但这更像是一种副作用,而不是其主要目的。
对于执行器来说,知道哪些 future 是可运行的是很重要的,原因有二。首先,它需要知道什么时候可以停止轮询一个 future 并进入睡眠状态;仅仅轮询每个 future 直到它返回 Poll::Pending 是不够的,因为轮询一个较晚的 future 可能会让一个较早的 future 取得进展。考虑到这样的情况:两个 future 在通道上来回跳动消息,相互之间。当你轮询一个时,另一个就准备好了,反之亦然。在这种情况下,执行器不应该进入睡眠状态,因为总是有更多的工作要做。
第二,知道哪些 future 是可运行的,可以让执行者避免不必要地轮询 future。如果一个执行器管理着数以千计的待定 future,它不应该仅仅因为一个事件使其中一个 future 可运行而轮询所有的 future。如果是这样,执行异步代码确实会变得非常慢。
任务和子执行器(Tasks and Subexecutors)
异步程序中的 future 形成了一棵树:一个 future 可以包含任何数量的其他 future,这些 future 又可以包含其他 future,一直到与 waker 交互的叶子 future。每棵树的根是你给执行者的主要 "运行" 函数的 future。这些根 future 被称为任务,它们是执行器和 future 树之间的唯一联系点。执行者在任务上调用轮询,从那时起,每个包含的 future 的代码必须弄清楚哪些内部 future 需要轮询,一直到相关叶子。
执行者通常为他们轮询的每个任务构建一个单独的 Waker,这样当 wake 被调用时,他们就知道哪个任务是可运行的,并能将其标记为可运行。这就是 RawWaker 中的原始指针的作用--在共享各种 Waker 方法的代码时区分不同的任务。
当执行者最终轮询一个任务时,该任务从其实现的 Future::poll 的顶部开始运行,并且必须从那里决定如何到达更深的 future,现在可以取得进展。由于每个 future 只知道它自己的字段,而不知道整个树的情况,这一切都通过调用轮询来发生,每个轮询都要穿越树中的一条边。
选择哪一个内部 future 进行轮询通常是显而易见的,但并非总是如此。在 async/await 的情况下,要轮询的 future 是我们被阻塞的那个。但是在一个等待几个 future 中的第一个取得进展的 future(通常称为选择),或者等待一组 future 中的所有 future(通常称为连接),有许多选择。一个必须做出这种选择的 future 基本上是一个子执行器。它可以轮询其内部的所有 future,但这样做可能是相当浪费的。相反,这些子执行器在对任何内部 future 调用轮询之前,通常会用它们自己的 Waker 类型来包装它们在轮询的 Context 中收到的 Waker。在包装代码中,他们在对原始 Waker 调用唤醒之前,在自己的状态中把刚刚轮询的 future 标记为可运行。这样,当执行者最终再次轮询子执行者的 future 时,子执行者可以查阅自己的内部状态,以找出哪些内部 future 导致了当前调用的轮询,然后只轮询这些 future。
异步代码中的阻塞
对于从异步代码中调用同步代码,你必须小心谨慎,因为执行器线程在执行当前任务时所花费的任何时间都是它没有在运行其他任务时花费的时间。如果一个任务长期占据当前线程而不回馈给执行者,这可能发生在执行一个阻塞的系统调用(如
std::sync::sleep),运行一个偶尔不回馈的子执行器,或者运行在一个没有等待的紧密循环中,那么当前执行者线程负责的其他任务就不能在这段时间运行。通常情况下,这表现为在某些任务可以取得进展时(比如客户端连接时)和它们实际执行时之间的长时间延迟。一些多线程执行器实现了工作窃取技术,闲置的执行器线程从繁忙的执行器线程中窃取任务,但这更像是一种缓解措施,而不是一种解决方案。最终,你可能会出现这样的情况:所有的执行器线程都被阻塞了,因此在其中一个阻塞操作完成之前,没有任务可以运行。
一般来说,在执行计算密集型操作或调用可能在异步上下文中阻塞的函数时,应该非常小心。这样的操作应该尽可能转换为异步操作,或者在专用线程上执行,然后使用支持异步的基元(primitive)进行通信,如通道。一些执行器还提供了一些机制,用于指示异步代码的特定部分可能会阻塞,或者用于在循环的上下文中自愿 yield,否则可能不会 yield,这可以构成解决方案的一部分。一个好的经验法则是,任何 future 都不应该能够运行超过 1 毫秒而不返回 Poll::Pending。
用 spawn 把这一切联系起来(Tying It All Together with spawn)
在使用异步执行器时,你可能会遇到一个产生 future 的操作。我们现在可以探索这意味着什么了!让我们通过例子来做。让我们通过举例的方式来进行。首先,考虑清单 8-14 中的简单服务器实现
#![allow(unused)] fn main() { async fn handle_client(socket: TcpStream) -> Result<()> { // Interact with the client over the given socket. } async fn server(socket: TcpListener) -> Result<()> { while let Some(stream) = socket.accept().await? { handle_client(stream).await?; } } // 清单 8-14:按顺序处理连接 }
顶层的服务器函数本质上是一个大的 future,它监听新的连接,并在新的连接到来时做一些事情。你把这个 future 交给执行器并说 "运行这个",由于你不希望你的程序立即退出,你可能会让执行器在这个 future 上阻塞。 也就是说,调用执行器来运行服务器 future 将不会返回,直到服务器 future 解决,这可能永远不会(另一个客户端可能稍后到达)。
现在,每当一个新的客户端连接进来时,清单 8-14 中的代码就会建立一个新的 future(通过调用 handle_client)来处理这个连接。因为处理本身就是一个 future,所以我们 await 它,然后转到下一个客户端连接。
这种方法的缺点是,我们每次只处理一个连接,没有并发性。一旦服务器接受了一个连接,就会调用 handle_client 函数,由于我们在等待它,所以我们不会再绕过这个循环,直到 handle_client 的返回 future 解决(估计是在该客户离开后)。
我们可以在此基础上进行改进,保留一组所有的客户 future,让服务器接受新连接的循环也检查所有的客户 future,看看是否有客户可以取得进展。 清单 8-15 显示了这可能是什么样子。
#![allow(unused)] fn main() { async fn server(socket: TcpListener) -> Result<()> { let mut clients = Vec::new(); loop { poll_client_futures(&mut clients)?; if let Some(stream) = socket.try_accept()? { clients.push(handle_client(stream)); } } } // 清单 8-15:处理与手动执行器的连接 }
这至少可以同时处理许多连接,但这是很复杂的。它的效率也不高,因为代码现在是忙循环,在处理我们已有的连接和接受新的连接之间切换。而且它每次都要检查每个连接,因为它不知道哪些连接可以取得进展(如果有的话)。它也不能在任何时候 await,因为这将阻止其他 future 取得进展。你可以实现你自己的 wakers,以确保代码只轮询可以取得进展的 future,但最终这是在开发你自己的迷你执行器的路上。
坚持只用一个服务器任务的另一个缺点是,服务器最终是单线程的,因为它内部包含了所有客户端连接的 future。只有一个任务,为了对其进行轮询,代码必须持有对该任务的 future 的独占引用(poll 取得 Pin<&mut Self>),而在同一时间只有一个线程可以持有。
解决办法是让每个客户的 future 成为自己的任务,让执行者在所有任务之间进行复用。你猜对了,你是通过生成 future 来做的。执行器将继续在服务器的 future 任务上进行阻塞,但如果它不能在该 future 任务上取得进展,它将使用其执行机制在幕后同时在其他任务上取得进展。最重要的是,如果执行器是多线程的,而你的客户端 future 是 Send 的,它可以并行地运行它们,因为它可以同时持有独立任务的&mutS。清单 8-16 给出了一个例子,说明这可能是什么样子。
#![allow(unused)] fn main() { async fn server(socket: TcpListener) -> Result<()> { while let Some(stream) = socket.accept().await? { // Spawn a new task with the Future that represents this client. // The current task will continue to just poll for more connections // and will run concurrently (and possibly in parallel) with handle_client. spawn(handle_client(stream)); } // 清单 8-16: 生成 future 以创建更多可以并发轮询的任务 }
当你 spawn 一个 future,从而使其成为一个任务,这有点像 spawn 一个线程。future 继续在后台运行,并与交给执行器的任何其他任务同时复用。然而,与生成的线程不同,生成的任务仍然依赖于被执行器轮询。如果执行器停止运行,无论是因为你放弃它,还是因为你的代码不再运行执行器的代码,那些生成的任务将停止进展。在服务器的例子中,想象一下,如果主服务器的 future 由于某种原因解决了,会发生什么。由于执行器已将控制权交还给你的代码,它不能继续做,嗯,任何事情。多线程执行器通常会产生后台线程,即使执行器将控制权交还给用户的代码,也会继续轮询任务,但并不是所有的执行器都会这样做,所以在你依赖这种行为之前,请检查你的执行器。
总结
在本章中,我们看了一下 Rust 中可用的异步结构的幕后。我们看到了编译器是如何实现生成器和自引用类型的,以及为什么这项工作对于支持我们现在所知道的 async/await 是必要的。 我们还探讨了 future 是如何执行的,以及当任何特定时刻只有部分任务可以取得进展时,wakers 是如何允许执行者在任务之间进行复用的。在下一章中,我们将解决可能是 Rust 中最深层和讨论最多的领域:不安全代码。深吸一口气,然后翻开这一页。
第九章 不安全代码
仅仅提到不安全代码,就会引起 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 中的并发性,看看如何让你闪亮的新电脑上的所有内核都朝同一个方向拉动!
第十章 并发性(和并行性)
通过这一章,我希望为你提供所有你需要的信息和工具,以便在你的 Rust 程序中有效地利用并发性,在你的库中实现对并发性的支持,并正确使用 Rust 的并发性原语。我不会直接教你如何实现一个并发的数据结构或编写一个高性能的并发应用程序。相反,我的目标是让你充分了解底层机制,使你有能力在你可能需要的情况下自己使用它们。
并发有三种类型:单线程并发(如我们在第 8 章讨论的 async/await),单核多线程并发,以及多核并发,产生真正的并行。每种类型都允许你的程序中的并发任务以不同的方式交错执行。如果你考虑到操作系统调度和抢占的细节,甚至还有更多的子类型,但我们不会太深入地讨论这个问题。
在类型层面上,Rust 只表示并发的一个方面:多线程。一个类型要么可以被一个以上的线程安全使用,要么就不可以。即使你的程序有多个线程(所以是并发的),但只有一个核心(所以不是并行的),Rust 必须假设,如果有多个线程,就可能有并行性。我们将要讨论的大多数类型和技术都同样适用于两个线程是否真的并行执行,所以为了保持语言的简单性,我将在本章中使用 "事情或多或少在同一时间运行 "这一非正式意义上的并发性一词。当区别很重要的时候,我就会指出来。
Rust 基于类型的安全多线程方法的特别之处在于,它不是编译器的一个功能,而是一个库的功能,开发人员可以扩展到开发复杂的并发合约。由于线程安全在类型系统中是通过 Send 和 Sync 的实现和约束来表达的,它一直传播到应用程序代码中,整个程序的线程安全仅通过类型检查来检查。
Rust 编程语言已经涵盖了大多数涉及到并发的基础知识,包括 Send 和 Sync 特性、Arc 和 Mutex 以及通道。因此,我不会在这里重申很多内容,除非在其他主题的背景下值得特别重复的地方。相反,我们将看看是什么让并发变得困难,以及一些常见的并发模式,旨在处理这些困难。在深入研究如何使用原子操作来实现较低级别的并发操作之前,我们还将探讨并发和异步如何相互作用(以及它们如何不相互作用)。最后,我将以一些建议来结束本章,这些建议是关于如何在使用并发代码时保持理智。
并发的麻烦
在我们深入研究并发编程的良好模式和 Rust 的并发机制的细节之前,值得花一些时间来理解为什么并发首先是个挑战。也就是说,为什么我们需要为并发代码提供特殊的模式和机制?
正确性(Correctness)
并发的主要困难是协调对一个在多个线程之间共享的资源的访问--特别是写入访问。如果很多线程想共享一个资源,仅仅是为了读取它,那么这通常很容易:你把它放在一个 Arc 里,或者把它放在你可以得到一个 'static 的东西里,然后你就完成了。 但是一旦任何线程想写,各种问题都会出现,通常是以数据竞争的形式出现。简而言之,当一个线程在更新共享状态的同时,另一个线程也在访问该状态,无论是读取还是更新,都会发生数据竞争。如果没有额外的保障措施,第二个线程可能会读取部分被覆盖的状态,破坏第一个线程所写的部分内容,或者根本看不到第一个线程所写的内容。一般来说,所有的数据竞争都被认为是未定义的行为。
数据竞争是一类更广泛的问题的一部分,这些问题主要(尽管不是唯一)发生在并发环境中:竞争条件。当一个指令序列可能出现多种结果时,就会出现竞争条件,这取决于系统中其他事件的相对时间。这些事件可以是线程执行一段特定的代码,也可以是定时器响起,也可以是网络数据包进来,还可以是其他任何时间可变的事件。 与数据竞争不同,竞争条件本身并不坏,也不被认为是未定义的行为。然而,当特别奇特的竞争发生时,它们是产生错误的温床,正如你在本章中看到的那样。
性能
通常情况下,开发人员在他们的程序中引入并发性,希望能够提高性能。或者,更准确地说,他们希望并发性将使他们能够通过利用更多的硬件资源在每秒内执行更多的操作。这可以通过让一个线程运行而另一个线程在等待来实现,也可以通过让线程在每个核心上同时进行工作来跨越多个核心,否则这些工作会在一个核心上连续发生。大多数开发者在谈论并发性时指的是后一种性能增益,而并发性通常是以可扩展性为框架的。 在这种情况下,可扩展性意味着 "这个程序的性能随着核心数量的增加而扩展",这意味着如果你给你的程序更多的核心,它的性能就会提高。
虽然实现这样的提速是可能的,但它比看起来要难。可扩展性的最终目标是线性可扩展性,即内核数量增加一倍,你的程序在每单位时间内完成的工作量就增加一倍。线性可扩展性通常也被称为完美可扩展性。然而,在现实中,很少有并发程序能够实现这样的速度提升。亚线性扩展更为常见,当你从一个核心到两个核心时,吞吐量几乎是线性增长的,但增加更多的核心会产生递减的回报。有些程序甚至会出现负扩展,即让程序访问更多的内核会降低吞吐量,通常是因为许多线程都在争夺一些共享资源。
想一想一群人试图弹出一块气泡膜上的所有气泡,可能会有帮助--增加更多的人最初会有帮助,但在某些时候你会得到递减的回报,因为拥挤使得任何一个人的工作都更难。如果参与的人特别没有效率,你的小组最终可能会站在一起讨论谁应该弹出下一个泡泡,而根本没有弹出任何泡泡这种本应平行执行的任务之间的干扰被称为争夺,是良好扩展的克星。争论可能以多种方式出现,但主要的犯罪者是相互排斥、共享资源耗尽和虚假共享。
相互排斥(Mutual Exclusion)
当只有一个并发任务被允许在任何时候执行一段特定的代码时,我们说该段代码的执行是相互排斥的--如果一个线程执行它,其他线程就不能同时这样做。这方面的典型例子是互斥锁,或称互斥器,它明确规定在任何时候只有一个线程可以进入你的程序代码的特定关键部分。然而,相互排斥也可以隐性地发生。例如,如果你建立一个线程来管理一个共享资源,并通过 mpsc 通道向它发送作业,该线程有效地实现了相互排斥,因为每次只有一个这样的作业可以执行。·
在调用操作系统或库调用时,也会出现相互排斥的情况,这些调用在内部强制要求单线程访问一个关键部分。例如,多年来,标准的内存分配器对一些分配要求相互排斥,这使得内存分配成为在其他高度并行的程序中产生重大争论的操作。同样地,许多操作系统的操作看起来应该是独立的,比如在同一目录下创建两个不同名字的文件,最终可能不得不在内核中顺序进行。
注意:可扩展的并发分配是 jemalloc 内存分配器存在的理由!
互斥是并行加速的最明显的障碍,因为根据定义,它迫使你的程序的某些部分串行执行。即使你让你的程序的其余部分完美地与内核的数量相匹配,你能达到的总速度也会受到互斥、串行部分的长度的限制。请注意你的互斥部分,并设法将它们限制在严格必要的地方。
注意:对于理论上的人来说,可以用阿姆达尔定律来计算由于代码的相互排斥部分而导致的可实现的速度提升的极限。
共享资源耗尽
不幸的是,即使你在任务中实现了完美的并发性,这些任务需要与之互动的环境本身也可能不是完全可扩展的。内核在给定的 TCP 套接字上每秒只能处理这么多的发送,内存总线一次只能做这么多的读取,而你的 GPU 的并发能力是有限的。这是没办法解决的。环境通常是完美的可扩展性在实践中崩溃的地方,对这种情况的修复往往需要大量的重新设计(甚至是新的硬件!),所以我们不会在本章中多谈这个话题。请记住,可扩展性很少是你可以 "实现 "的,而更多的是你要努力争取的东西。
虚假共享(False Sharing)
当两个不应该互相争夺的操作发生争夺时,就会发生虚假共享,从而阻止了有效的同时执行。这通常是因为这两个操作碰巧在某些共享资源上相交,尽管它们使用的是该资源的不相关部分。
最简单的例子就是锁的过度共享,在这种情况下,一个锁守护着一些复合状态,而两个原本独立的操作都需要使用这个锁来更新它们的特定状态部分。这反过来意味着这些操作必须以串行方式执行,而不是并行方式。在某些情况下,有可能将单个锁分成两个,每个不相干的部分都有一个,这样就可以使操作并行进行。然而,像这样分割一个锁并不总是直接的,状态可能共享一个锁,因为第三个操作需要锁定状态的所有部分。通常情况下,你仍然可以分割锁,但你必须注意不同线程获取分割锁的顺序,以避免当两个操作试图以不同的顺序获取锁时可能发生的死锁(如果你好奇的话,可以查一下哲学家就餐问题)。另外,对于某些问题,你可以通过使用底层算法的无锁版本来完全避免关键部分,尽管这些算法也是很难搞好的。归根结底,虚假共享是一个很难解决的问题,并没有一个单一的万能解决方案,但识别问题是一个好的开始。
一个更微妙的虚假共享的例子发生在 CPU 层面,正如我们在第二章中简要讨论的那样。CPU 在内部对内存的操作是以缓存线--内存中连续字节的较长序列--而不是单个字节为单位,以分摊内存访问的成本。例如,在大多数英特尔处理器上,高速缓存线的大小是 64 字节。这意味着,每一个内存操作最终都要读取或写入 64 字节的某个倍数。当两个内核想要更新两个不同的字节的值,而这两个字节恰好在同一条高速缓存线上时,错误的共享就开始发挥作用;这些更新必须按顺序执行,尽管这些更新在逻辑上是不相干的。
想象一下,你分配了一个整数数组来表示每个线程完成了多少操作,但是这些整数都在同一个缓存线内--现在,所有的并行线程都会为他们的每一个操作争夺这一个缓存线。如果操作相对较快的话,大部分的执行时间可能都会花在争夺这些计数器上。
避免错误的缓存行共享的诀窍是把你的值填充到一个缓存行的大小。这样一来,两个相邻的值总是落在不同的缓存线上。当然,这也会增加你的数据结构的大小,所以只有在基准测试表明有问题时才使用这种方法。
可扩展性的成本
你应该注意并发性的一个正交方面,即首先引入并发性的成本。编译器在优化单线程代码方面非常出色--毕竟他们已经做了很长时间了--而且单线程代码往往比并发代码可以使用更少的昂贵保障措施(比如锁、通道或原子指令)。总的来说,并发的各种成本可以使一个并行程序比它的单线程对应的程序慢,给定任何数量的核心这就是为什么在优化和并行化之前和之后都要进行测量的原因:结果可能会让你吃惊。
如果你对这个话题感到好奇,我强烈建议你阅读 Frank McSherry 的 2015 年论文 "Scalability! 但代价是什么?"(
https://www.frankmcsherry.org/assets/COST.pdf),其中揭露了一些特别恶劣的 "昂贵的扩展 "的例子。
并发模型
Rust 有三种模式可以为你的程序添加并发性,你会经常遇到这种情况:共享内存并发性、工作者池和角色 (actors)。仔细研究每一种你可以添加并发的方式将需要一本自己的书,所以在这里我将专注于这三种模式。
共享内存
从概念上讲,共享内存并发是非常直接的:线程通过操作它们之间共享的内存区域进行合作。这可能采取由突变器保护的状态的形式,或者存储在支持许多线程并发访问的 HashMap 中。许多线程可能在不相干的数据片断上执行相同的任务,例如许多线程在一个 Vec 的不相干的子范围上执行某个功能,或者它们可能在执行需要一些共享状态的不同任务,例如在一个数据库中,一个线程处理用户对一个表的查询,而另一个线程在后台优化用于存储该表的数据结构。
当你使用共享内存并发时,你对数据结构的选择是非常重要的,特别是当所涉及的线程需要非常紧密地合作时。一个普通的 mutex 可能会阻止超过非常少的核心数量的扩展,一个读者/写者锁可能会允许更多的并发读取,代价是更慢的写入,而一个分片的读者/写者锁可能允许完全可扩展的读取,代价是使写入高度混乱。同样地,一些并发散列图旨在实现良好的全面性能,而另一些则专门针对,比如说,在写入很少的情况下,并发读取。一般来说,在共享内存并发中,你想使用专门为你的目标用例而设计的数据结构,这样你就可以利用优化,将你的应用程序不关心的性能方面与它关心的方面进行交换。
共享内存并发很适合线程需要共同更新一些共享状态的用例,而这些共享状态是不相通的。也就是说,如果一个线程需要用某个函数 f 来更新状态 s,而另一个线程需要用某个函数 g 来更新状态,并且 f(g(s)) != g(f(s)),那么共享内存并发可能是必要的。如果不是这种情况,其他两种模式可能更适合,因为它们倾向于导致更简单和更高性能的设计。
注意:有些问题有已知的算法,可以在不使用锁的情况下提供并发的共享内存操作。随着内核数量的增加,这些无锁算法可能会比基于锁的算法有更好的扩展性,尽管由于它们的复杂性,它们的单核性能也往往较慢。像往常一样,对于性能问题,首先要进行基准测试,然后寻找其他的解决方案。
工作池(Worker Pools)
在工人池模型中,许多相同的线程从一个共享的工作队列中接收工作,然后它们完全独立地执行。 例如,网络服务器通常有一个工人池来处理进入的连接,异步代码的多线程运行时往往使用工人池来集体执行一个应用程序的所有 futures(或者更准确地说,它的顶级任务)。
共享内存并发和工作者池之间的界限往往是模糊的,因为工作者池倾向于使用共享内存并发来协调它们如何从队列中获取作业,以及如何将未完成的作业返回到队列中。例如,假设你正在使用数据并行库 rayon,对一个向量的每个元素并行地执行一些函数。在幕后,rayon 启动了一个工作池,将向量分割成子范围,然后将子范围分配给池中的线程。当池中的线程完成一个范围时,rayon 会安排它开始工作于下一个未处理的子范围。向量在所有工作线程之间共享,各线程通过一个共享内存队列式的数据结构进行协调,该结构支持工作偷窃。
工作窃取是大多数工作池的一个关键特征。其基本前提是,如果一个线程提前完成其工作,并且没有更多未分配的工作可用,该线程可以窃取已经分配给不同工作线程但尚未启动的工作。并非所有的工作都需要相同的时间来完成,所以即使每个工作者被赋予相同数量的工作,一些工作者最终可能比其他工作者更快地完成他们的工作。 与其坐等那些吸引了长期工作的线程完成,那些提前完成工作的线程应该帮助那些落伍者,以便整个操作更快完成。
要实现一个支持这种工作窃取的数据结构,同时又不会因为线程不断试图从对方那里窃取工作而产生大量的开销,这是一项相当艰巨的任务,但这个功能对于高性能的工作池来说是至关重要的。如果你发现自己需要一个工人池,你最好的选择通常是使用一个已经做了很多工作的工人池,或者至少是重用现有工人池的数据结构,而不是自己从头开始写一个。
当每个线程执行的工作相同,但执行的数据不同时,工人池就很适合。在 Rayon 并行映射操作中,每个线程执行相同的映射计算;他们只是在底层数据的不同子集上执行。在多线程异步运行时,每个线程都简单地调用 Future::poll;它们只是在不同的 futures 上调用它。如果你开始不得不区分你的线程池中的线程,那么不同的设计可能更合适。
连接池
连接池是一种共享内存结构,它保存一组已建立的连接,并将其分配给需要连接的线程。这是管理与外部服务连接的库中的一种常见设计模式。如果一个线程需要一个连接,但没有可用的连接,要么建立一个新的连接,要么强迫该线程阻塞。当一个线程用完一个连接后,它就会把这个连接返回到池子里,从而使它对其他可能在等待的线程可用。
通常情况下,连接池最难的任务是管理连接的生存期。一个连接可以以最后使用它的线程所处的任何状态返回到池中。因此,连接池必须确保与连接相关的任何状态,无论是在客户端还是在服务器上,都已被重置,以便当连接随后被其他线程使用时,该线程可以像被赋予一个新的、专用的连接一样。
actors
角色并发模型在许多方面与工作者池模型相反。worker 池有许多共享作业队列的相同线程,而 actor 模型有许多独立的作业队列,每个作业主题对应一个。每个作业队列提供给一个特定的角色,该角色处理属于应用程序状态子集的所有作业。该状态可能是数据库连接、文件、指标集合数据结构,或任何其他您可以想象到的许多线程可能需要能够访问的结构。无论它是什么,单个角色拥有该状态,如果某个任务想要与该状态交互,它需要向拥有该状态的角色发送一条消息,总结它希望执行的操作。当拥有该消息的角色收到该消息时,它将执行指定的操作,并使用操作的结果(如果相关的话)响应查询任务。由于角色对其内部资源具有独占访问权,因此除了消息传递所需之外,不需要任何锁或其他同步机制。
角色模式的一个关键点是角色之间相互交谈。例如,如果负责记录日志的角色需要写入文件和数据库表,它可能会向负责这些的角色发送消息,要求他们执行各自的操作,然后继续进行下一个日志事件。这样,角色模型比轮子上的辐条,更像一个 web 用户请求到一个 web 服务器可能开始作为一个单独的请求的演员负责连接但也可能产生数十,数百,甚至数千演员更深层次的信息系统满足用户的请求之前。
角色模型中没有任何内容要求每个角色都是自己的线程。相反,大多数角色系统建议应该有大量的角色,因此每个角色应该映射到一个任务而不是一个线程。毕竟,角色只有在执行时才需要独占访问其包裹的资源,并不关心它们是否在自己的线程上。事实上,角色模型经常与工作者池模型结合使用--例如,一个使用多线程异步运行时 Tokio 的应用程序可以为每个角色生成一个异步任务,然后 Tokio 会将每个角色的执行作为其工作者池中的一个工作。因此,一个给定的角色的执行可能会在工作池中从线程移动到线程,因为角色的产生和恢复,但每次角色的执行都会保持对其包裹资源的独占访问。
当你有许多可以相对独立运行的资源,并且每个资源内几乎没有并发的机会时,角色并发模型就很适合。例如,一个操作系统可能有一个负责每个硬件设备的角色,而一个网络服务器可能有一个负责每个后端数据库连接的角色。如果你只需要几个角色,如果工作在角色之间有明显的倾斜,或者如果一些角色成长得很大,那么角色模型就不那么好用了--在所有这些情况下,你的应用程序最终可能会被系统中单个角色的执行速度所瓶颈。 而且,由于角色都期望独占他们的一小块世界,你不能轻易地将这个瓶颈角色的执行并行化。
异步和并行
正如我们在第 8 章中所讨论的,Rust 中的异步可以实现无并行的并发--我们可以使用 selects 和 joins 让一个线程轮询多个 future,并在其中一个、一些或所有 futures 完成时继续进行。因为没有涉及到并行性,所以使用 future 的并发性从根本上来说并不要求这些 future 被发送。即使 spawn 一个 future ,作为一个额外的顶级任务运行,从根本上说也不需要 Send,因为一个执行线程可以同时管理许多 future 的轮询。
然而,在大多数情况下,应用程序希望同时拥有并发性和并行性。例如,如果一个网络应用为每个传入的连接构建一个 future,因此同时有许多活动连接,它可能希望异步执行器能够利用主机上的一个以上的核心。这不会自然发生:你的代码必须明确地告诉执行器哪些 future 可以并行运行,哪些不能。
特别是,必须向执行者提供两个信息,让它知道它可以将 future 中的工作分散到一个工人线程池中。首先是有关的 future 是 Send--如果不是,执行者不允许将 future 发送给其他线程进行处理,而且不可能有并行;只有构建这种 future 的线程可以轮询它们。
第二条信息是如何将 future 分割成可以独立运行的任务。这与第 8 章中关于任务与 future 的讨论有关:如果一个巨大的 Future 包含一些 Future 实例,这些实例本身对应于可以并行运行的任务,那么执行者仍然必须在顶层 Future 上调用 poll,而且必须从一个单线程中调用,因为 poll 需要 &mut self。因此,为了实现与 Futures 的并行,你必须明确地生成你希望能够并行运行的 Futures。同时,由于第一个要求,你用于这样做的执行器函数将要求传入的 Future 是 Send。
异步同步原语
大多数存在于阻塞代码中的同步原语(想想
std::sync)也有异步的对应物。有异步的通道、互斥、读者/写者锁、障碍以及其他各种类似结构的变体。我们需要这些,因为正如第 8 章中所讨论的,在未来程序中进行阻塞会耽误执行者可能需要做的其他工作,所以是不可取的。然而,这些基元的异步版本往往比同步版本慢,因为执行必要的唤醒需要额外的机械。出于这个原因,只要不存在阻塞执行器的风险,即使在异步上下文中也可能要使用同步基元。例如,虽然一般来说,获取一个
Mutex可能会阻塞很长时间,但对于一个特定的Mutex来说,这可能不是真的,因为它可能只是很少被获取,而且只在很短的时间内。在这种情况下,阻断很短的时间,直到Mutex再次变得可用,实际上可能不会造成任何问题。你要确保在持有MutexGuard的时候永远不要屈服或执行其他长期运行的操作,但除此之外,你应该不会遇到问题。不过,与此类优化一样,请确保首先进行测量,如果同步原语能带来明显的性能改进,则只选择同步原语。如果没有,那么在异步上下文中使用同步基元所带来的额外脚步声可能就不值得了。
低级别的并发性(Lower-Level Concurrency)
标准库提供了 std::sync::atomic 模块,它提供了对底层 CPU 基元的访问,像通道和互斥这样的高级结构是用它构建的。这些基元以原子类型的形式出现,名字以 Atomic--AtomicUsize、AtomicI32、AtomicBool、AtomicPtr 等开头,还有两个名为 fence 和 compiler_fence 的函数。我们将在接下来的几节中分别讨论这些问题。
这些类型是用来构建任何需要在线程之间进行通信的代码的块。互斥、通道、屏障、并发哈希表、无锁栈以及所有其他同步构造最终都依赖于这几个基元来完成其工作。它们在线程之间的轻量级合作中也很方便,因为像互斥这样的重量级同步是过度的(没必要的)--例如,增加一个共享计数器或将一个共享布尔值设置为真。
原子类型很特别,因为它们对多个线程试图并发访问它们时发生的情况有定义的语义。这些类型都支持(大部分)相同的 API:load、store、fetch_*和 compare_exchange。在本节的其余部分,我们将研究这些类型的作用,如何正确使用它们,以及它们的用途。但首先,我们必须谈谈低级别的内存操作和内存排序。
内存操作
非正式地,我们经常把访问变量称为 "读" 或 "写" 内存。实际上,在代码使用变量和访问内存硬件的实际 CPU 指令之间有很多机制。为了理解并发内存访问的行为,至少在高层次上理解这种机制是很重要的。
编译器决定当你的程序读取一个变量的值或给它分配一个新的值时发出什么指令。它被允许对你的代码进行各种转换和优化,并可能最终重新排列你的程序语句,消除它认为多余的操作,或使用 CPU 寄存器而不是实际内存来存储中间计算。编译器对这些转换有一些限制,但最终只有一部分变量访问实际上是作为内存访问指令而结束的。
在 CPU 层面,内存指令有两种主要形式:加载(loads)和存储(stores)。加载是将字节从内存中的某个位置拉到 CPU 寄存器中,存储是将字节从 CPU 寄存器中存储到内存中的某个位置。加载和存储一次只能操作一小块内存:在现代 CPU 上通常是 8 字节或更少。如果一个变量的访问跨度超过了单次加载或存储所能访问的字节数,编译器会根据情况自动将其变成多条加载或存储指令。CPU 在如何执行程序指令方面也有一些回旋余地,以更好地利用硬件和提高程序性能。例如,现代 CPU 经常并行执行指令,甚至不按顺序执行,当它们之间没有相互依赖关系时。在每个 CPU 和你的计算机的 DRAM 之间还有几层缓存,这意味着对一个给定的内存位置的加载,不一定能看到该内存位置的最新存储,按壁时钟时间计算。
在大多数代码中,编译器和 CPU 只允许以不影响结果程序语义的方式来转换代码,所以这些转换对程序员来说是不可见的。 然而,在并行执行的背景下,这些转换会对应用行为产生重大影响。因此,CPU 通常提供多个不同的加载和存储指令的变体,每个指令对 CPU 如何重新排序以及如何与其他 CPU 上的并行操作交错进行不同的保证。同样,编译器(或者说,编译器编译的语言)提供了不同的注释,你可以用它来强制执行其内存访问的一些子集的特定执行约束。在 Rust 中,这些注释是以原子类型和它们的方法的形式出现的,我们将在本节剩下的时间里把它们挑出来。
原子类型
Rust 的原子类型之所以被称为原子类型,是因为它们可以被原子地访问--也就是说,原子类型变量的值是一次性写入的,绝不会使用多次存储来写入,这就保证了对该变量的加载不能观察到只有组成该值的一些字节发生了变化,而其他的字节还没有变化(尚未)。通过与非原子类型的对比,这一点最容易理解。例如,为一个类型为 (i64,i64) 的元组重新分配一个新的值,通常需要两条 CPU 存储指令,每个 8 字节的值一条。如果一个线程执行这两条存储指令,另一个线程可能会(如果我们暂时忽略借用检查器)在第一次存储后但在第二次存储前读取元组的值,从而最终得到元组值的不一致的观点。它最终会读取第一个元素的新值和第二个元素的旧值,一个从未被任何线程实际存储的值。
CPU 只能以原子方式访问特定大小的值,所以只有几种原子类型,所有这些都在原子模块中。每个原子类型都是 CPU 支持的原子访问的大小之一,有多种变化,比如值是否被签名,以及区分原子的 usize 和指针(与 usize 大小相同)。此外,原子类型有明确的方法来加载和存储它们所持有的值,还有一些更复杂的方法,我们将在后面讨论,这样,程序员编写的代码和产生的 CPU 指令之间的映射就更清楚了。例如,AtomicI32::load 执行一个有符号的 32 位值的单一加载,AtomicPtr::store 执行一个指针大小(64 位平台上的 64 位)值的单一存储。
内存排序
大多数关于原子类型的方法都有一个 Ordering 类型的参数,它决定了原子操作所受到的内存排序限制。在不同的线程中,一个原子值的加载和存储可能会被编译器和 CPU 排序,而这些排序与该原子值上的每个原子操作所要求的内存排序相一致。在接下来的几节中,我们将看到一些例子,说明为什么对排序的控制很重要,而且对于从编译器和 CPU 那里获得预期的语义是必要的。
内存排序往往是反直觉的,因为我们人类喜欢从上到下阅读程序,并想象它们是逐行执行的,但当代码进入硬件时,实际上不是这样执行的。内存访问可以被重新排序,甚至完全被忽略,一个线程的写入可能不会立即被其他线程看到,即使后来的程序顺序写入已经被观察到。
可以这样想:每个内存位置都会看到来自不同线程的修改序列,而不同内存位置的修改序列是独立的。如果两个线程 T1 和 T2 都写到内存位置 M,那么即使用户用秒表测量到 T1 先执行,T2 对 M 的写入仍然可能在两个线程的执行之间没有任何其他限制的情况下先发生。从本质上讲,计算机在确定某个内存位置的值时并不考虑壁钟时间,重要的是程序员对什么是有效的执行的约束。例如,如果 T1 写到 M,然后产生线程 T2,T2 再写到 M,计算机必须承认 T1 的写先发生,因为 T2 的存在取决于 T1。
如果这很难理解,不要着急,内存排序可能是令人费解的,而语言规范往往使用非常精确但不太直观的措辞来描述它。我们可以通过关注底层硬件架构来构建一个更容易掌握的心智模型,即使有点简化。基本上,计算机内存的结构是一个树状的存储层次,叶子是 CPU 寄存器,根部是物理内存芯片上的存储,通常称为主内存。两者之间有几层缓存,层次结构的不同层可以驻扎在不同的硬件上。当一个线程执行存储到一个内存位置时,真正发生的情况是,CPU 开始对特定 CPU 寄存器中的值进行写入请求,然后必须在内存层次结构中向主内存前进。当一个线程执行负载时,请求在层次结构上流动,直到它遇到一个有可用值的层,并从那里返回。问题就出在这里:在所有写入的内存位置的缓存被更新之前,写入的内容在任何地方都是不可见的,但是其他 CPU 可以在同一时间对同一内存位置执行指令,奇怪的事情随之而来。那么,内存排序是一种要求精确语义的方式,即当多个 CPU 访问一个特定的内存位置进行特定操作时,会发生什么。
考虑到这一点,让我们来看看 Ordering 类型,它是我们作为程序员可以规定哪些并发执行是有效的额外约束的主要机制。
Ordering 被定义为一个枚举,其变体在清单 10-1 中显示。
#![allow(unused)] fn main() { enum Ordering { Relaxed, Release, Acquire, AcqRel, SeqCst } // 清单 10-1:Ordering 的定义 }
每一种都对从源代码到执行语义的映射施加了不同的限制,我们将在本节的剩余部分依次探讨每一种。
Relaxed Ordering
放松的排序基本上不能保证对值的并发访问,除了访问是原子性的这一事实。特别是,宽松排序对不同线程之间的内存访问的相对顺序没有保证。这是内存排序的最弱形式。清单 10-2 显示了一个简单的程序,其中两个线程使用 Ordering::Relaxed 访问两个原子变量。
#![allow(unused)] fn main() { static X: AtomicBool = AtomicBool::new(false); static Y: AtomicBool = AtomicBool::new(false); let t1 = spawn(|| { let r1 = Y.load(Ordering::Relaxed); // (1) X.store(r1, Ordering::Relaxed); // (2) }); let t2 = spawn(|| { let r2 = X.load(Ordering::Relaxed); // (3) Y.store(true, Ordering::Relaxed); // (4) }); // 清单 10-2:使用 Ordering::Relaxed 的两个竞争线程 }
看一下作为 t2 产生的线程,你可能会期望 r2 永远不可能为真,因为所有的值都是假的,直到同一个线程在读取 X 之后的那一行将 Y 赋值为真。然而,在宽松的内存排序下,这种结果是完全可能的。原因是,CPU 被允许重新排列所涉及的加载和存储。让我们来看看这里到底发生了什么,使 r2=true 成为可能。
首先,CPU 注意到 4 不必发生在 3 之后,因为 4 不使用 3 的任何输出或副作用,也就是说,4 对 3 没有执行依赖性。所以,CPU 决定重新安排它们的顺序,因为 *挥手* 的原因会使你的程序走得更快。因此,CPU 先执行 4,设置 Y=true,尽管 3 还没有运行。然后,t2 被操作系统置入睡眠状态,线程 t1 执行几条指令,或者 t1 只是在另一个核心上执行。在 t1 中,编译器必须先运行 1,然后再运行 2,因为 2 依赖于在 1 中读取的值。因此,t1 从 Y(由 4 写入)中读取真,然后将其写回 X。
宽松的内存排序允许这种执行,因为它没有对并发执行施加额外的约束。也就是说,在宽松的内存排序下,编译器必须确保对任何特定线程的执行依赖性得到尊重(就像不涉及原子学一样);它不需要对并发操作的交错做出任何承诺。重排 3 和 4 在单线程执行中是允许的,所以在宽松排序下也是允许的。
在某些情况下,这种重新排序的方式是很好的。例如,如果你有一个计数器,它只是记录指标,那么相对于其他指令,它到底什么时候执行并不重要,Ordering::Relaxed 就可以了。在其他情况下,这可能是灾难性的:例如,如果你的程序使用 r2 来计算是否已经设置了安全保护,从而最终错误地认为它们已经设置了。
在编写没有花哨地使用原子学的代码时,你一般不会注意到这种重新排序--CPU 必须保证所写的代码和每个线程实际执行的代码之间没有可观察到的差异,所以一切看起来都是按照你写的顺序运行的。这被称为尊重程序顺序或评估顺序;这些术语是同义词。
获取/释放排序(Acquire/Release Ordering)
在内存排序层次的下一步,我们有 Ordering::Acquire、Ordering::Release 和 Ordering::AcqRel(Acquire 加 Release)。在高层次上,它们在一个线程的存储和另一个线程的加载之间建立了执行依赖关系,然后限制了如何对加载和存储进行重新排序的操作。最重要的是,这些依赖关系不仅建立了一个存储和一个单一值的负载之间的关系,而且还对所涉及的线程中的其他负载和存储进行了排序限制。这是因为每次执行都必须尊重程序的顺序;如果线程 B 中的负载与线程 A 中的某个存储有依赖关系(A 中的存储必须在 B 中的负载之前执行),那么在该负载之后 B 中的任何读或写也必须发生在 A 中的存储之后。
注意:Acquire 内存排序只能应用于加载,Release 只能应用于存储,而 AcqRel 只能应用于同时加载和存储的操作(如 fetch_add)。
具体来说,这些内存排序对执行有以下限制:
- 使用
Ordering::Release,加载和存储不能向前移动,超过一个存储。 - 通过
Ordering::Acquire,加载和存储不能在加载之前被移回。 - 一个变量的
Ordering::Acquire加载必须看到发生在Ordering::Release存储之前的所有存储,该存储存储了加载的内容。
为了看看这些内存顺序是如何改变的,清单 10-3 再次显示了清单 10-2,但是将内存顺序换成了获取和释放。
#![allow(unused)] fn main() { static X: AtomicBool = AtomicBool::new(false); static Y: AtomicBool = AtomicBool::new(false); let t1 = spawn(|| { let r1 = Y.load(Ordering::Acquire); X.store(r1, Ordering::Release); }); let t2 = spawn(|| { let r2 = X.load(Ordering::Acquire); // (1) Y.store(true, Ordering::Release); // (2) }); // 清单 10-3:清单 10-2 中的获取/释放内存排序 }
这些额外的限制意味着 t2 不再有可能看到 r2=true。要知道为什么,请考虑清单 10-2 中奇怪结果的主要原因:1 和 2 的重新排序。第一个限制,即对有 Ordering::Release 的 store 的限制,决定了我们不能把 1 移到 2 下面,所以我们一切都很好!
但这些规则的作用超出了这个简单的例子。例如,想象一下,你实现了一个互斥锁。你想确保一个线程在持有锁的时候所运行的任何加载和存储都只在它实际持有锁的时候执行,并且对后来取得锁的任何线程都是可见的。这正是 Release 和 Acquire 所能做到的。通过执行 Release 存储来释放锁,以及 Acquire 加载来获取锁,你可以保证关键部分的加载和存储不会被移动到锁被实际获取之前或被释放之后!
注意:在一些 CPU 架构上,比如 x86,
Acquire/Release排序是由硬件保证的,使用Ordering::Release和Ordering::Acquire而不是Ordering::Relaxed没有额外的成本。 在其他架构上,情况并非如此,如果你对可以容忍较弱内存排序保证的原子操作切换到Relaxed,你的程序可能会看到速度的提升。
顺序一致的排序(Sequentially Consistent Ordering)
顺序一致的排序(Ordering::SeqCst)是我们能接触到的最强的内存排序。它的确切保证有点难以确定,但从广义上讲,它不仅要求每个线程看到的结果与 Acquire/Release 一致,而且要求所有线程看到的排序都是一样的。具体来说,Acquire/Release 排序并不能保证,如果两个线程 A 和 B 原子化地加载另外两个线程 X 和 Y 写的值,A 和 B 会看到 X 相对于 Y 写的一致模式。之后,我们将看到顺序一致的排序如何避免这种特殊的意外结果。
#![allow(unused)] fn main() { static X: AtomicBool = AtomicBool::new(false); static Y: AtomicBool = AtomicBool::new(false); static Z: AtomicI32 = AtomicI32::new(0); let t1 = spawn(|| { X.store(true, Ordering::Release); }); let t2 = spawn(|| { Y.store(true, Ordering::Release); }); let t3 = spawn(|| { while (!X.load(Ordering::Acquire)) {} if (Y.load(Ordering::Acquire)) { // (1) Z.fetch_add(1, Ordering::Relaxed); } }); let t4 = spawn(|| { while (!Y.load(Ordering::Acquire)) {} if (X.load(Ordering::Acquire)) { // (2) Z.fetch_add(1, Ordering::Relaxed); } }); // 清单 10-4:用 `Acquire/Release` 排序的奇怪结果 }
两个线程 t1 和 t2 分别将 X 和 Y 设置为真。线程 t3 等待 X 为真;一旦 X 为真,它就检查 Y 是否为真,如果是,就给 Z 加 1。线程 t4 则等待 Y 为真,然后检查 X 是否为真,如果是,就给 Z 加 1。在我告诉你答案之前,考虑到上一节中 Release 和 Acquire 排序的定义,试着用你的方式解决这个问题。
首先,让我们回顾一下 Z 被递增的条件。 如果线程 t3 在观察到 X 是真的之后看到 Y 是真的,那么它就会递增 Z,这只有在 t2 在 t3 评估 1 处的负载之前运行时才会发生。相反,如果线程 t4 在观察到 Y 是真的之后看到 X 是真的,就会增加 Z,所以只有当 t1 在 t4 评估 `2 处的负载之前运行时才会发生。为了简化解释,我们暂且假设每个线程一旦运行就会运行到完成。
从逻辑上讲,如果线程按 1、2、3、4 的顺序运行,Z 可以被递增两次--X 和 Y 都被设置为真,然后 t3 和 t4 运行,发现它们递增 Z 的条件被满足。同样地,如果线程按 1、3、2、4 的顺序运行,Z 也只需递增一次。然而,让 Z 为 0 似乎是不可能的:如果我们想阻止 t3 递增 Z,t2 必须在 t3 之后运行。由于 t3 只在 t1 之后运行,这意味着 t2 在 t1 之后运行。然而,t4 在 t2 运行后才会运行,所以 t1 在 t4 运行时一定已经运行并将 X 设置为真,所以 t4 会增加 Z。
我们无法让 Z 为 0,主要是因为我们人类倾向于线性解释;这个发生了,然后这个发生了,然后这个发生了。计算机没有同样的限制,没有必要把所有的事件都框在一个单一的全局秩序中。在 "释放 "和 "获取 "的规则中,并没有说 t3 必须观察到 t1 和 t2 的执行顺序与 t4 观察到的相同。 就计算机而言,让 t3 观察到 t1 先执行,而让 t4 观察到 t2 先执行,是可以的。考虑到这一点,在一个执行过程中,t3 在观察到 X 是真的之后观察到 Y 是假的(意味着 t2 在 t1 之后运行),而在同一个执行过程中,t4 在观察到 Y 是真的之后观察到 X 是假的(意味着 t2 在 t1 之前运行),这是完全合理的,即使这在我们普通人看来是很过分的。
正如我们前面所讨论的,Acquire/Release 只要求 Ordering::Acquire 对一个变量的加载必须看到发生在 Ordering::Release 存储之前的所有存储,这些存储存储了加载的东西。在刚才讨论的排序中,计算机确实坚持了这一属性:t3 看到了 X == true,并且确实看到了 t1 在设置 X = true 之前的所有存储--没有。它也看到了 Y == false,这是由主线程在程序启动时存储的,所以没有任何相关的存储需要被关注。同样地,t4 看到了 Y = true,也看到了 t2 在设置 Y = true 之前的所有存储--同样,没有任何存储。它还看到了 X == false,它是由主线程存储的,没有前面的存储。没有任何规则被破坏,但这似乎是错误的,不知为何。
我们的直观期望是,我们可以把线程放在一些全局性的顺序中,使每个线程看到的和做的都有意义,但在这个例子中,Acquire/Release 的顺序不是这样的。 为了实现更接近直观期望的东西,我们需要顺序一致性。顺序一致性要求所有参与原子操作的线程进行协调,以确保每个线程观察到的东西对应于(或至少看起来对应于)一些单一的、共同的执行顺序。这使它更容易推理,但也使它成本很高。
标有 Ordering::SeqCst 的原子加载和存储指示编译器采取任何额外的预防措施(例如使用特殊的 CPU 指令)来保证这些加载和存储的顺序一致性。围绕这一点的确切形式是相当复杂的,但顺序一致性基本上可以确保,如果你从所有线程中查看所有相关的 SeqCst 操作,你可以把线程的执行放在某种顺序中,这样被加载和存储的值就会全部匹配起来。
如果我们用 SeqCst 替换清单 10-4 中的所有内存排序参数,那么在所有线程都退出后,Z 不可能是 0,就像我们最初预期的那样。在顺序一致性下,必须可以说 t1 肯定在 t2 之前运行,或者 t2 肯定在 t1 之前运行,所以 t3 和 t4 看到不同顺序的执行是不允许的,因此 Z 不可能为 0。
比较和交换(Compare and Exchange)
除了 load 和 store 之外,所有 Rust 的原子类型都提供了一个叫做 compare_exchange 的方法。这个方法是用来原子地和有条件地替换一个值的。你向 compare_exchange 提供你最后观察到的原子变量的值,以及你想用它替换原始值的新值,只有当它仍然与你最后观察到的值相同时,它才会替换该值。要知道为什么这一点很重要,请看清单 10-5 中互斥锁的(破损)实现。这个实现在静态原子变量 LOCK 中记录了锁是否被持有。 我们使用布尔值 true 来表示锁被持有。为了获得锁,线程等待 LOCK 为假,然后再次将其设置为真;然后进入其关键部分,将 LOCK 设置为假,以便在其工作(f)完成后释放锁。
#![allow(unused)] fn main() { static LOCK: AtomicBool = AtomicBool::new(false); fn mutex(f: impl FnOnce()) { // Wait for the lock to become free (false). while LOCK.load(Ordering::Acquire) { /* .. TODO: avoid spinning .. */ } // Store the fact that we hold the lock. LOCK.store(true, Ordering::Release); // Call f while holding the lock. f(); // Release the lock. LOCK.store(false, Ordering::Release); } // 清单 10-5:互斥锁的不正确实现 }
这基本上是可行的,但它有一个可怕的缺陷--两个线程可能同时看到 LOCK == false,并且都离开了 while 循环。 然后它们都将 LOCK 设置为 true,并且都进入了临界区,这正是互斥函数应该防止的事情!这就是所谓的临界区。
清单 10-5 中的问题是,在我们加载原子变量的当前值和随后更新它之间有一个间隙,在这期间,另一个线程可能会运行并读取或触摸它的值。compare_exchange 解决的正是这个问题--它只在原子变量的值仍然与之前的读取值相匹配的情况下,才会交换其背后的值,否则就会通知你该值已经改变。清单 10-6 显示了使用 compare_exchange 的正确实现。
#![allow(unused)] fn main() { static LOCK: AtomicBool = AtomicBool::new(false); fn mutex(f: impl FnOnce()) { // Wait for the lock to become free (false). loop { let take = LOCK.compare_exchange( false, true, Ordering::AcqRel, Ordering::Relaxed ); match take { Ok(false) => break, Ok(true) | Err(false) => unreachable!(), Err(true) => { /* .. TODO: avoid spinning .. */ } } } // Call f while holding the lock. f(); // Release the lock. LOCK.store(false, Ordering::Release); } // 清单 10-6:相互排斥锁的修正实现 }
这一次,我们在循环中使用了 compare_exchange,它同时负责检查当前是否有锁,并存储真值以适当地获取锁。这是通过 compare_exchange 的第一个和第二个参数实现的:在本例中,先是 false,然后是 true。你可以把这个调用理解为 "只有当当前值为 false 时才存储 true"。compare_exchange 方法返回一个 Result,表示该值被成功更新(Ok)或不能被更新(Err)。在任何一种情况下,它都会返回当前值。这对 AtomicBool 来说不是太有用,因为我们知道如果操作失败,值必须是什么,但是对于像 AtomicI32 这样的东西,更新的当前值可以让你快速地重新计算要存储的内容,然后再试一次,而不需要再次加载。
注意:注意,
compare_exchange只检查值是否与作为当前值传入的值相同。如果其他线程修改了原子变量的值,然后再次将其重置为原始值,那么对该变量的compare_exchange仍然会成功。这通常被称为 A-B-A 问题。
与简单的加载和存储不同,compare_exchange 需要两个订购参数。第一个是 "成功排序",它决定了在值被成功更新的情况下,compare_exchange 代表的加载和存储应该使用什么内存排序。第二个是 "失败排序",它决定了在加载的值与预期的当前值不一致的情况下,加载的内存排序。这两种排序是分开的,这样开发者就可以给 CPU 留有余地,在适当的时候通过重新排列负载和存储的顺序来提高执行性能,但在成功时仍然可以得到正确的排序。在这种情况下,在锁获取循环的失败迭代中重新排列负载和存储是可以的,但在关键部分内重新排列负载和存储是不可以的,因为它们最终会在关键部分之外。
尽管它的接口很简单,但是 compare_exchange 是一个非常强大的同步原语--以至于理论上已经证明,你可以只用 compare_exchange 来构建所有其他的分布式共识原语。由于这个原因,当你真正深入研究实现细节时,它是许多(如果不是大多数)同步构造的主力。
不过要注意的是,compare_exchange 要求单个 CPU 独占访问底层数值,因此它是硬件层面上的一种互斥形式。这又意味着 compare_exchange 很快就会成为一个可扩展性瓶颈:每次只有一个 CPU 可以取得进展,所以有一部分代码不会随着核心数量的增加而扩展。事实上,情况可能比这更糟糕--CPU 必须进行协调,以确保每次只有一个 CPU 成功地对一个变量进行 compare_exchange(如果你对其工作原理感到好奇,可以看一下 MESI 协议),而这种协调的成本会随着 CPU 数量的增加而呈四级增长。
比较_交换_弱化 (COMPARE_EXCHANGE_WEAK)
细心的读者会注意到
compare_exchange有一个名字可疑的表弟,compare_exchange_weak,并想知道有什么区别。compare_exchange的弱变体允许失败,即使原子变量的值仍然与用户传递的预期值一致,而强变体在这种情况下必须成功。这可能看起来很奇怪--除了值已经改变之外,原子值交换会失败吗?答案是系统架构中没有原生的
compare_exchange操作。例如,ARM 处理器有锁定加载和条件存储操作,如果相关的锁定加载所读取的值在加载后没有被写入,那么条件存储就会失败。Rust 标准库在 ARM 上实现了compare_exchange,它在一个循环中调用这对指令,并且只在条件存储成功后返回。这使得清单 10-6 中的代码不必要地低效--我们最终得到了一个嵌套的循环,这需要更多的指令,而且更难优化。由于我们在这种情况下已经有了一个循环,我们可以改用compare_exchange_weak,删除Err(false)上的unreachable!(),在 ARM 上得到更好的机器代码,在 x86 上得到同样的编译代码!
Fetch 方法
Fetch 方法(fetch_add、fetch_sub、fetch_and 等)被设计为允许更有效地执行交换的原子操作--也就是说,无论执行顺序如何,这些操作都有意义的语义。这样做的动机是,compare_exchange 方法很强大,但也很昂贵--如果两个线程都想更新一个原子变量,一个会成功,而另一个会失败,不得不重试。如果涉及到许多线程,它们都必须调解对底层值的顺序访问,而且在线程失败重试时,会有大量的旋转。
对于那些简单的操作,我们可以告诉 CPU 要对原子变量执行什么操作,而不是因为另一个线程修改了数值而失败和重试。然后,当 CPU 最终获得独占访问权时,它将对当前值执行该操作。想想看,一个 AtomicUsize 可以计算出一个线程池完成的操作数量。如果两个线程同时完成了一项工作,只要它们的增量都被计算在内,哪一个先更新计数器并不重要。
fetch 方法实现了这类交换性操作。它们在一个步骤中执行读取和存储操作,并保证存储操作是在原子变量上执行的,当时它正好持有该方法返回的值。举个例子,AtomicUsize::fetch_add(1, Ordering::Relaxed) 永远不会失败--它总是把 1 加到 AtomicUsize 的当前值上,不管它是什么,并准确地返回 AtomicUsize 的值,当这个线程的 1 被添加时。
fetch 方法往往比 compare_exchange 更有效率,因为当多个线程争夺对一个变量的访问时,它们不需要线程失败和重试。一些硬件架构甚至有专门的 fetch 方法实现,随着参与的 CPU 数量的增加,它的扩展性会更好。 然而,如果有足够多的线程试图对同一个原子变量进行操作,由于需要协调,这些操作会开始变慢,并表现出亚线性的扩展性。一般来说,显著提高并发算法性能的最好方法是将争夺的变量分割成更多的原子变量,而不是从 compare_exchange 切换到 fetch 方法。
注意:
fetch_update方法的名字有点欺骗性--在幕后,它实际上只是一个compare_exchange_weak的循环,所以它的性能概况会比其他 fetch 方法更接近compare_exchange。
理智的并发性(Sane Concurrency)
编写正确和高性能的并发代码比编写顺序代码更难;你不仅要考虑可能的执行交错,还要考虑你的代码如何与编译器、CPU 和内存子系统互动。面对如此多的脚步声,你很容易想把你的手扔到空中,完全放弃并发性。在本节中,我们将探讨一些技术和工具,这些技术和工具可以帮助确保你在编写正确的并发代码时没有(那么多)恐惧。
简单开始
生活中的一个事实是,简单、直接、易懂的代码更有可能是正确的。这个原则也适用于并发代码--总是从你能想到的最简单的并发设计开始,然后测量,只有当测量发现有性能问题时,你才应该优化你的算法。
要在实践中遵循这个提示,一开始就要采用不需要复杂地使用原子或大量细粒度锁的并发模式。从运行顺序代码并通过通道进行通信的多个线程开始,或者通过锁进行合作,然后用你关心的工作负载对结果的性能进行基准测试。与实施花哨的无锁算法或将锁分割成一千块以避免错误的共享相比,这样做犯错误的可能性要小得多。对于许多用例来说,这些设计已经足够快了;事实证明,为了使通道和锁表现良好,已经花费了大量的时间和精力如果简单的方法对你的用例来说已经足够快了,为什么还要引入更复杂和容易出错的代码呢?
如果你的基准测试表明有性能问题,那么要弄清楚你的系统到底是哪一部分扩展得不好。在可能的情况下,专注于修复这个瓶颈,并尽可能地通过小的调整来实现。也许把一个锁分成两个,而不是转移到一个并发的哈希表,或者引入另一个线程和一个通道,而不是实现一个无锁的工作窃取队列就足够了。如果是这样,就这么做。
即使你必须直接与原子学和类似的东西打交道,也要保持事情的简单性,直到证明有优化的需要--一开始使用 Ordering::SeqCst 和 compare_exchange,如果你发现具体的证据表明这些东西正在成为必须处理的瓶颈,再进行迭代。
编写压力测试
作为作者,你对你的代码中可能隐藏的错误有很多洞察力,但不一定知道这些错误是什么(无论如何,还没有)。编写压力测试是一个很好的方法,可以把一些隐藏的 bug 抖出来。压力测试不一定要执行复杂的步骤序列,而是有很多线程并行地进行相对简单的操作。
例如,如果你正在编写一个并发的 HashMap,一个压力测试可能是让 N 个线程插入或更新键,M 个线程读取键,这样,这些 M+N 线程可能经常选择相同的键。这样的测试并不是为了测试一个特定的结果或值,而是试图触发许多可能的操作交错,希望有问题的交错能暴露出来。
压力测试在很多方面都类似于模糊测试;模糊测试会对一个给定的函数产生许多随机输入,而压力测试则会产生许多随机的线程和内存访问时间表。就像模糊测试一样,压力测试也只能和你代码中的断言一样好;它们不能告诉你一个没有以某种容易发现的方式表现出来的错误,比如断言失败或其他类型的恐慌。出于这个原因,在你的低级并发代码中加入断言是个好主意,如果你担心在特别热的循环中的运行时间成本,可以使用 debug_assert_*。
使用并发测试工具
编写并发代码的主要挑战是处理不同线程执行的所有可能的交错方式。 正如我们在清单 10-4 中的 Ordering::SeqCst 例子中所看到的,重要的不仅仅是线程调度,还有特定线程在任何特定时间点可能观察到的内存值。 编写执行所有可能的合法执行的测试不仅繁琐而且困难--你需要非常低级的控制,即哪些线程何时执行以及它们的读取返回什么值,操作系统可能不提供这些。
用 Loom 进行模型检查
幸运的是,已经有一个工具可以以 Loom crate 的形式为你简化这种执行探索。考虑到本书和 Rust crate 的相对发布周期,我不会在这里给出如何使用 Loom 的例子,因为当你阅读本书时,这些例子很可能已经过时了,但我将概述它的作用。
Loom 希望你以闭包的形式编写专门的测试用例,并将其传递到 Loom 模型中。该模型会跟踪所有的跨线程交互,并试图通过多次执行测试用例闭包来智能地探索这些交互的所有可能迭代。为了检测和控制线程交互,Loom 为标准库中所有允许线程相互协调的类型提供了替换类型;这包括 std::sync 和 std::thread 下的大多数类型,以及 UnsafeCell 和其他一些类型。Loom 希望你的应用程序在运行 Loom 测试时能使用这些替换类型。替换类型与 Loom 执行器相连,并执行双重功能:它们作为重新安排的点,以便 Loom 可以在每个可能的线程交互点之后选择下一个要运行的操作,并且它们告知 Loom 要考虑的新的可能的交织方式。从本质上讲,Loom 为每个可能存在多种执行交错的点建立了一个所有可能的未来执行的树,然后尝试一个接一个地执行所有这些执行。
Loom 试图完全探索你提供的测试用例的所有可能的执行情况,这意味着它可以发现只在极其罕见的执行情况下出现的错误,而压力测试在一百年内也不会发现。虽然这对较小的测试用例来说是很好的,但一般来说,将这种严格的测试应用于较大的测试用例是不可行的,这些测试用例涉及更多的操作序列或需要许多线程同时运行。Loom 会花费太多时间来获得代码的适当覆盖。因此,在实践中,你可能想告诉 Loom 只考虑可能执行的子集,Loom 的文档中有更多的细节。
就像压力测试一样,Loom 只能捕捉到表现为恐慌的 bug,所以这又是一个花时间在并发代码中放置战略断言的理由!在很多情况下,甚至值得在并发代码中添加额外的状态跟踪和记账指令,以提供更好的断言。在许多情况下,甚至值得在你的并发代码中添加额外的状态跟踪和记账指令,以提供更好的断言。
使用 ThreadSanitizer 进行运行时检查
对于较大的测试案例,你最好的选择是在谷歌优秀的 ThreadSanitizer(也称为 TSan)下通过几个迭代来运行测试。TSan 通过在每次内存访问之前放置额外的记账指令来自动增强你的代码。然后,当你的代码运行时,这些记账指令会更新并检查一个特殊的状态机,该状态机会标记出任何表明有问题的竞赛条件的并发内存操作。例如,如果线程 B 写入某个原子值 X,但没有与写入 X 的前一个值的线程同步(这里有很多挥手的动作),这表明有写/写竞赛,这几乎总是一个错误。
由于 TSan 只观察你的代码运行,而不像 Loom 那样反复执行,它通常只给你的程序运行时间增加一个恒定因素的开销。虽然这个因素可能很重要(在撰写本文时为 5-15 倍),但它仍然很小,甚至可以在合理的时间内执行最复杂的测试案例。
在写这篇文章的时候,要使用 TSan,你需要使用 Rust 编译器的夜间版本,并传入 -Zsanitizer=thread 命令行参数(或在 RUSTFLAGS 中设置),尽管希望在未来这将成为一个标准的支持选项。其他的净化器也是可用的,它们可以检查诸如越界内存访问、释放后使用、内存泄漏和未初始化内存的读取,你可能也想通过这些来运行你的并发测试套件!
HEISENBUGS
Heisenbugs 是在你试图研究它们时似乎消失的 bug。在试图调试高并发代码时,这种情况经常发生;调试问题的额外工具改变了并发事件的相对时间,可能导致触发错误的执行交错不再发生。
导致并发性错误消失的一个特别常见的原因是使用打印语句,这是迄今为止最常见的调试技术之一。有两个原因导致打印语句对并发性错误有如此大的影响。 第一个,也许是最明显的,是相对而言,打印东西到用户的终端(或标准输出的地方)需要相当长的时间,特别是当你的程序产生大量的输出时。写入终端至少需要往返于操作系统内核以执行写入,但写入也可能需要等待终端本身从进程的输出读入它自己的缓冲区。所有这些额外的时间可能会大大延迟之前与其他线程中的操作竞争的操作,以至于竞争条件消失了。
打印语句干扰并发执行模式的第二个原因是,对标准输出的写入(通常)是由锁来保护的。如果你看一下标准库中的
Stdout类型,你会发现它持有一个Mutex,用来保护对输出流的访问。这样做是为了在多个线程试图同时写入时,输出不会出现严重的混乱,如果没有锁,某一行可能会有多个线程写入的字符穿插在一起,但有了锁,线程就会轮流写入。 不幸的是,获取输出锁是另一个线程同步点,而且每个打印线程都会参与其中。这意味着,如果你的代码之前因为两个线程之间缺少同步而被破坏,或者只是因为两个线程之间的特定竞赛是可能的,添加打印语句可能会作为一个副作用来修复这个 bug!一般来说,当你发现一个看起来像 Heisenbug 的东西时,试着找到其他方法来缩小问题。这可能涉及到使用 Loom 或 TSan,使用 gdb 或 ldb,或使用一个只在最后打印的每线程内存日志。许多日志框架也努力避免在发布日志事件的关键路径上的同步点,所以切换到其中之一可能会使你的生活更轻松。作为额外的奖励,你在修复了某个特定的 bug 后留下的好的日志可能会在以后派上用场。就我个人而言,我是追踪 crate 的忠实粉丝,但也有很多好的选择。
总结
在这一章中,我们首先介绍了并发 Rust 中常见的正确性和性能陷阱,以及一些成功的并发应用程序倾向于使用的高级并发模式来解决这些问题。我们还探讨了异步 Rust 是如何实现无并行的并发的,以及如何在异步 Rust 代码中明确引入并行。然后,我们深入研究了 Rust 的许多不同的底层并发原语,包括它们是如何工作的,它们有什么不同,以及它们都是为了什么。最后,我们探讨了编写更好的并发代码的技术,并研究了 Loom 和 TSan 等工具,它们可以帮助你审查这些代码。在下一章中,我们将继续我们在 Rust 低层的旅程,深入研究外部函数接口,它允许 Rust 代码直接与其他语言编写的代码相连接。
第十一章 外部函数接口
不是所有的代码都是用 Rust 编写的。这很令人震惊,我知道。每隔一段时间,你就需要与用其他语言编写的代码进行交互,要么从 Rust 中调用这些代码,要么让这些代码调用你的 Rust 代码。你可以通过外部函数接口(FFI)来实现这一点。
在这一章中,我们将首先看一下 Rust 为 FFI 提供的主要机制:extern 关键字。我们将看到如何使用 extern 将 Rust 的函数和静态变量暴露给其他语言,并让 Rust 访问来自 Rust 气泡之外的函数和静态变量。然后,我们将探讨如何将 Rust 类型与其他语言中定义的类型对齐,并探索允许数据跨越 FFI 边界的一些复杂情况。最后,我们将讨论一些你可能想要使用的工具,如果你正在做任何不重要的 FFI。
注意:虽然我经常提到 FFI 是关于跨越一种语言和另一种语言之间的边界,但 FFI 也可以完全发生在 Rust-land 内部。如果一个 Rust 程序与另一个 Rust 程序共享内存,但这两个程序并没有编译在一起--例如,如果你在你的 Rust 程序中使用一个动态链接的库,而这个库恰好是用 Rust 编写的,但你只有 C 语言兼容的 .so 文件--同样的复杂情况就会出现。
用 extern 跨越边界
归根结底,FFI 是关于访问来自你的应用程序的 Rust 代码之外的字节的。为此,Rust 提供了两个主要的构件:符号(symbols),它是分配给二进制文件中特定地址的名称,允许你在外部来源和 Rust 代码之间共享内存(无论是数据还是代码),以及调用约定(call conventions),提供了对如何调用存储在这种共享内存中的函数的共同理解。我们将依次看一下其中的每一项。
符号(symbols)
编译器从你的代码中产生的任何二进制工件都充满了符号--你定义的每个函数或静态变量都有一个符号,指向它在编译的二进制中的位置。泛型函数甚至可以有多个符号,编译器生成的每个函数的单态都有一个符号。
通常情况下,你不必考虑符号--它们被编译器内部用来传递二进制中的函数或静态变量的最终地址。这就是编译器在生成最终的机器代码时,知道每个函数调用应该针对内存中的什么位置,或者如果你的代码访问静态变量,应该从哪里读取。由于你在代码中通常不直接引用符号,编译器默认为它们选择半随机的名字--你可能在代码的不同部分有两个叫 foo 的函数,但编译器会从它们中生成不同的符号,这样就不会有混淆。
然而,当你想调用一个函数或访问一个没有同时编译的静态变量时,使用随机名称的符号是行不通的,例如用不同的语言编写的代码,因此由不同的编译器编译。如果在 C 语言中定义的静态变量的符号有一个不断变化的半随机名称,你就不能告诉 Rust 这个变量。反过来说,如果你不能为一个 Rust 函数产生一个稳定的名字,你就不能告诉 Python 的 FFI 接口关于它的情况。
为了使用一个具有外部来源的符号,我们还需要用某种方式告诉 Rust 关于一个变量或函数的信息,使编译器能够寻找在其他地方定义的相同的符号,而不是定义自己的符号(我们将在后面讨论如何进行搜索)。否则,我们就会为该函数或静态变量设置两个相同的符号,而且不会发生共享。 事实上,很有可能编译会失败,因为任何引用该符号的代码都不知道该用哪个定义(也就是哪个地址)来定义它。
注意:关于术语的简要说明:一个符号可以被多次声明,但只能定义一次。符号的每一个声明都将在链接时链接到该符号的同一个定义。如果没有找到一个声明的定义,或者有多个定义,链接器会抱怨。
关于汇编和链接的一个插曲
编译器速成课程时间!对将代码转化为可运行的二进制文件的复杂过程有一个大致的了解,将有助于你更好地理解 FFI。你看,编译器并不是一个单一的程序,而是(通常)被分解成几个较小的程序,每个程序都执行不同的任务,并一个接一个地运行。在高层次上,编译有三个不同的阶段--编译、代码生成和连接,由三个不同的组件处理。
第一阶段是由大多数人倾向于认为是 "编译器" 来执行的;它处理类型检查、借用检查、单态化和其他我们与特定编程语言相关的功能。这个阶段不生成机器代码,而是生成代码的低级表示,使用大量注释的抽象机器操作。然后,这个低级表示被传递给代码生成工具,该工具生成的机器代码可以在特定的 CPU 上实际运行。
这两种操作加在一起,不一定要在整个代码库上一次性运行一大遍。相反,代码库可以被切成更小的块,然后通过编译并发运行。例如,只要它们之间没有依赖关系,Rust 通常会独立和并行地编译不同的 crates。它还可以分别调用独立 crate 的代码生成工具来并行处理它们。Rust 甚至经常可以分别编译一个 crate 的多个小片断!
一旦应用程序的每一个部分的机器代码被生成,这些部分就可以被连接起来。这是在连接阶段完成的,不出所料,就是连接器。链接器的主要工作是将代码生成时产生的所有二进制工件(称为对象文件),将它们拼接成一个文件,然后用该符号的最终内存地址替换对该符号的每个引用。这就是为什么你可以在一个 crate 中定义一个函数,并从另一个 crate 中调用它,但仍要分别编译这两个 crate。
链接器是使 FFI 工作的原因。它并不关心每个输入对象文件是如何构建的;它只是尽职尽责地将所有对象文件连接在一起,然后解析任何共享符号。 一个对象文件可能最初是 Rust 代码,一个最初是 C 代码,还有一个可能是从互联网上下载的二进制 blob;只要它们都使用相同的符号名称,链接器将确保产生的机器代码对任何共享符号使用正确的交叉引用地址。
符号可以被静态或动态地链接。静态链接是最简单的,因为对一个符号的每个引用都被简单地替换成该符号定义的地址。另一方面,动态链接将每个对符号的引用与一段生成的代码联系起来,在程序运行时试图找到该符号的定义。稍后我们会更多地讨论这些链接模式。Rust 通常默认 Rust 代码采用静态链接,而 FFI 采用动态链接。
使用 extern
extern 关键字是一种机制,它允许我们声明一个符号驻留在一个外部接口中。具体来说,它声明了一个在其他地方定义的符号的存在。在清单 11-1 中,我们在 Rust 中定义了一个名为 RS_DEBUG 的静态变量,我们通过 FFI 让其他代码可以使用。我们还声明了一个名为 FOREIGN_DEBUG 的静态变量,它的定义是未指定的,但会在连接时被解决。
#![allow(unused)] fn main() { #[no_mangle] pub static RS_DEBUG: bool = true; extern { static FOREIGN_DEBUG: bool; } // 清单 11-1:通过 FFI 暴露 Rust 静态变量,并访问在其他地方声明的变量 }
#[no_mangle] 属性确保 RS_DEBUG 在编译过程中保留这个名字,而不是让编译器给它分配另一个符号名,例如,将它与程序中其他地方的(非 FFI)RS_DEBUG 静态变量区分开来。这个变量也被声明为 pub,因为它是 crate 的 public API 的一部分,尽管这个注解对于标记为 #[no_mangle] 的项目来说并不是严格必要的。注意,我们没有为 RS_DEBUG 使用 extern,因为它是在这里定义的。它仍然可以从其他语言中被访问以进行链接。
FOREIGN_DEBUG 静态变量周围的 extern 块表示这个声明指的是一个位置,Rust 会在链接时根据相同符号的定义位置来学习。因为它是在其他地方定义的,所以我们不给它一个初始化值,只给它一个类型,这个类型应该与定义地点使用的类型相匹配。由于 Rust 对定义静态变量的代码一无所知,因此无法检查你是否为该符号声明了正确的类型,所以 FOREIGN_DEBUG 只能在 unsafe 块中访问。
注意:Rust 中的静态变量默认是不可变的,无论它们是否在一个外部块中。这些变量在任何线程中都是可用的,所以可变的访问会带来数据竞争的风险。你可以将静态变量声明为可变,但如果你这样做,它的访问就变得不安全了。
声明 FFI 函数的过程非常相似。在清单 11-2 中,我们使 hello_rust 能够被非 Rust 代码访问,并拉入外部的 hello_foreign 函数。
#![allow(unused)] fn main() { #[no_mangle] pub extern fn hello_rust(i: i32) { ... } extern { fn hello_foreign(i: i32); } // 清单 11-2 :暴露一个 Rust 函数,并通过 FFI 访问一个在其他地方定义的函数 }
这些构件都与清单 11-1 中的相同,只是 Rust 函数是用 extern fn 声明的,我们将在下一节中探讨这个问题。
如果一个给定的外部符号有多个定义,如 FOREIGN_DEBUG 或 hello_foreign,你可以使用 #[link] 属性明确指定该符号应该与哪个库链接。如果你不这样做,链接器会给你一个错误,说它发现了该符号的多个定义。例如,如果你用 #[link(name = "crypto")] 给外部块加前缀,你就告诉链接器要针对名为 "crypto" 的链接库解析任何符号(无论是静态还是函数)。你也可以在你的 Rust 代码中重命名一个外部静态或函数,方法是用 #[link_name = "<actual_symbol_name>"] 注释其声明,然后该项目链接到你希望的任何名称。同样,你可以用 #[export_name = "<export_symbol_name>"] 来重命名一个用于导出的 Rust 项目。
链接类型
#[link] 还接受参数 kind,它决定了块中的项目应该如何被链接。该参数默认为 dylib,表示与 C 语言兼容的动态链接。另一个种类值是 static,表示块中的项目应该在编译时被完全链接(也就是静态链接)。这基本上意味着外部代码被直接连接到由编译器产生的二进制文件中,因此不需要在运行时存在。还有一些其他类型,但它们不那么常见,也不在本书的讨论范围内。
在静态链接和动态链接之间有一些权衡,但主要考虑的是安全、二进制大小和分布。首先,动态链接倾向于更安全,因为它使独立升级库变得更容易。动态链接允许部署包含你的代码的二进制文件的人升级你的代码所链接的库,而不需要重新编译你的代码。比方说,如果 libcrypto 得到了安全更新,用户可以在主机上更新 crypto 库,然后重新启动二进制文件,更新后的库代码将被自动使用。在静态编译中,库的代码被硬塞进二进制文件中,所以用户必须针对库的升级版本重新编译你的代码以获得更新。
动态链接也倾向于产生更小的二进制文件。由于静态编译将任何链接的代码包括在最终的二进制输出中,以及该代码随后引入的任何代码,它产生的二进制文件更大。通过动态链接,每个外部项目只包括一小段包装代码,在运行时加载指定的库,然后转发访问。
到目前为止,静态链接可能看起来不是很有吸引力,但它比动态链接有一个很大的优势:易于分发。通过动态链接,任何想运行包括你的代码的二进制文件的人都必须拥有你的代码所链接的任何库。不仅如此,他们还必须确保他们拥有的每个库的版本与你的代码所期望的兼容。这对于像 glibc 或 OpenSSL 这样在大多数系统上可用的库来说,可能没有问题,但对于更多晦涩难懂的库来说,这就带来了问题。用户需要意识到他们应该安装这个库,并且必须寻找它以运行你的代码!这就是静态链接。通过静态链接,库的代码被直接嵌入到二进制输出中,所以用户不需要自己安装它。
最终,在静态链接和动态链接之间并没有一个正确的选择。动态链接通常是一个很好的默认值,但静态编译可能是一个更好的选择,适用于特别受限的部署环境或非常小的或小众的库依赖。请使用你的最佳判断!
调用约定(Calling Conventions)
符号决定了一个给定的函数或变量的定义位置,但这还不足以允许跨 FFI 边界的函数调用。要在任何语言中调用一个外部函数,编译器还需要知道它的调用约定,这决定了要使用汇编代码来调用该函数。我们不会在这里讨论每个调用约定的实际技术细节,但作为一个总体概述,该约定规定了:
- 调用的堆栈框架是如何设置的。
- 参数是如何传递的(是在堆栈中还是在寄存器中,是按顺序还是按反向传递)。
- 当函数返回时,如何告诉它要跳回哪里?
- 在函数完成后,CPU 的各种状态,如寄存器,如何在调用者中恢复。
Rust 有自己独特的调用约定,并没有标准化,允许编译器随着时间的推移而改变。只要所有的函数定义和调用是由同一个 Rust 编译器编译的,这就很好,但如果你想与外部代码互通有无,那就有问题了,因为外部代码不知道 Rust 的调用约定。
如果你没有声明其他东西,每个 Rust 函数都隐含地用 extern "Rust" 来声明。像清单 11-2 中那样单独使用 extern,是 extern "C" 的缩写,意思是 "使用标准的 C 调用约定"。之所以有这个速记,是因为在 FFI 的几乎所有情况下,C 的调用约定都是你想要的。
注意:解卷通常只对常规的 Rust 函数起作用。如果你在一个不是
extern "Rust"的 Rust 函数的结尾处解卷,你的程序将会中止。绕过 FFI 边界进入外部代码是未定义的行为。在 RFC 2945 中,Rust 获得了一个新的 extern 声明,extern "C-unwind";这允许在特殊情况下跨 FFI 边界解卷,但如果你想使用它,你应该仔细阅读 RFC。
Rust 还支持一些其他的调用约定,你可以在 extern 关键字后面提供一个字符串(在 fn 和 block 上下文中)。例如,extern "system"表示使用操作系统标准库接口的调用约定,在撰写本文时,除了 Win32 使用 stdcall 调用约定外,其他地方都与 "C" 相同。一般来说,你很少需要明确地提供一个调用约定,除非你正在处理特别是平台特定的或高度优化的外部接口,所以只需要 extern(也就是 extern "C")就可以了。
注意:一个函数的调用约定是其类型的一部分。也就是说,
extern "C" fn()的类型与fn()不一样(或extern "Rust" fn()),后者又与extern "system" fn()不同。
其他二进制构件(OTHER BINARY ARTIFACTS)
通常情况下,你编译 Rust 代码只是为了运行它的测试或构建一个二进制文件,然后你要分发或运行。与许多其他语言不同的是,你通常不会编译 Rust 库来向其他人分发它--如果你运行 cargo publish 这样的命令,它只是将你的 crate 的源代码包装起来并上传到 crates.io。这主要是因为除了源代码之外,很难将通用代码作为其他东西发布。由于编译器将每个泛型函数与所提供的类型参数进行单态化,而这些类型可能在调用者的 crate 中定义,编译器必须能够访问函数的泛型形式,这意味着没有优化的机器代码!
从技术上讲,Rust 确实编译了每个依赖的二进制库工件,称为 rlibs,它在最后结合在一起。这些 rlibs 包括解决泛型类型所需的信息,但它们是特定于所使用的具体编译器的,一般不能以任何有意义的方式分发。
那么,如果你想在 Rust 中编写一个库,然后想从其他编程语言中获得接口,你该怎么办?解决方案是以动态链接库(Unix 上的 .so 文件、macOS 上的 .dylib 文件和 Windows 上的 .dll 文件)和静态链接库(Unix/macOS 上的 .a 文件和 Windows 上的 .lib 文件)的形式产生兼容 C 的库文件。这些文件看起来像 C 代码产生的文件,所以它们也可以被其他知道如何与 C 语言互动的语言使用。
为了产生这些与 C 语言兼容的二进制工件,你要在 Cargo.toml 文件的 [lib] 部分设置 crate-type 字段。这个字段有一个数组的值,通常只有 "lib",表示一个标准的 Rust 库(rlib)。如果你的 crate 显然不是一个库(例如,如果它是一个过程性的宏),Cargo 会应用一些启发式方法来自动设置这个值,但最好的做法是,如果你生产的不是一个好的 Rust 库,就明确设置这个值。
有许多不同的 crate 类型,但这里相关的是 cdylib 和 staticlib,它们分别产生动态和静态链接的 C-兼容库文件。请记住,当你产生这些工件类型之一时,只有公开可用的符号是可用的--也就是说,公开和 #[no_mangle] 静态变量和函数。像类型和常量这样的东西是不可用的,即使它们被标记为 pub,因为它们在二进制库文件中没有意义的表示。
跨越语言界限的类型(Types Across Language Boundaries)
在 FFI 中,类型布局是至关重要的;如果一种语言以一种方式为某些共享数据铺设内存,而 FFI 边界另一侧的语言却希望以不同方式铺设,那么双方对数据的解释就会不一致。在这一节中,我们将看看如何使类型在 FFI 中匹配,以及当你跨越语言之间的边界时需要注意的类型的其他方面。
类型匹配
类型并不在 FFI 边界上共享。当你在 Rust 中声明一个类型时,该类型信息在编译时完全丢失。 所有传达给另一方的是构成该类型值的比特。因此你需要在边界的两边都声明这些比特的类型。当你声明类型的 Rust 版本时,你首先必须确保类型中包含的基元是匹配的。例如,如果在边界的另一边使用 C 语言,而 C 语言类型使用 int,那么 Rust 代码最好使用准确的 Rust 对应物:i32。为了消除这个过程中的一些猜测,对于使用类似 C 类型的接口,Rust 标准库在 std::os::raw 模块中为你提供了正确的 C 类型,它定义了 type c_int = i32, type c_char = i8/u8,取决于 char 是否是有符号的,type c_long = i32/i64 取决于目标指针宽度,等等。
注意:要特别注意 C 语言中古怪的整数类型,如
__be32。 这些类型通常不能直接转化为 Rust 类型,最好是留作类似[u8; 4]的类型。例如,__be32总是被编码为 big-endian,而 Rust 的i32使用当前平台的endianness。
对于像向量和字符串这样更复杂的类型,你通常需要手动进行映射。例如,由于 C 语言倾向于将字符串表示为以 0 字节结束的字节序列,而不是单独存储长度的 UTF-8 编码的字符串,所以你一般不能通过 FFI 使用 Rust 的字符串类型。 相反,假设对方使用 C 语言风格的字符串表示,你应该使用 std::ffi::CStr 和 std::CString 类型,分别用于借用和拥有字符串。对于向量,你可能想使用一个指向第一个元素的原始指针,然后单独传递长度--Vec::into_raw_parts 方法可能在这方面很有用。
对于包含其他类型的类型,如结构体和联合体,你也需要处理布局和对齐问题。正如我们在第 2 章中所讨论的,Rust 默认以未定义的方式布局类型,所以至少你要使用 #[repr(C)] 来确保类型有一个确定的布局和对齐方式,以反映 FFI 边界上(可能也希望)使用的方式。如果接口还指定了该类型的其他配置,例如手动设置其对齐方式或移除填充,你就需要相应地调整你的 #[repr]。
根据枚举是否包含数据,Rust 枚举有多种可能的 C 风格表示。考虑一个没有数据的枚举,像这样。
#![allow(unused)] fn main() { enum Foo { Bar, Baz } }
使用 #[repr(C)],Foo类型只用一个整数进行编码,其大小与 C 语言编译器为具有相同数量变体的枚举选择的大小相同。第一个变体的值是 0,第二个变体的值是 1,以此类推。你也可以为每个变体手动赋值,如清单 11-3 所示。
#![allow(unused)] fn main() { #[repr(C)] enum Foo { Bar = 1, Baz = 2, } // 清单 11-3: 为一个无数据的枚举定义显式变量值 }
注意:从技术上讲,规范说第一个变体的值是 0,每个后续变体的值都比前一个变体的值大 1。如果你手动设置了某些变量的值,但没有设置其他变量的值,这就有区别了--你没有设置的变量将从你设置的最后一个变量继续。
然而,你应该小心将 C 语言中的枚举类型映射到 Rust,因为只有定义的变体的值对枚举类型的实例有效。这往往会使你在处理 C 风格的枚举时遇到麻烦,这些枚举的功能通常更像比特集,其中变体可以被比特地或在一起,以产生一个同时封装了多个变体的值。例如,在清单 11-3 的例子中,通过取 Bar | Baz 产生的 3 的值在 Rust 中对 Foo 无效。如果你需要建立一个 C 语言的 API 模型,该模型使用一个枚举的比特标志集,可以单独设置和取消设置,可以考虑使用一个整数类型的新类型包装器,为每个变量提供相关的常量,并实现各种 Bit* 特性,以提高工效。 或者使用 bitflags crate。
注意:对于无字段枚举,你也可以给
#[repr]传递一个数字类型,以使用与isize不同的类型作为判别器。 例如,#[repr(u8)]将使用一个无符号字节对判别器进行编码。对于一个携带数据的枚举,你可以通过#[repr(C, u8)]来获得同样的效果。
在一个包含数据的枚举上,#[repr(C)] 属性会使该枚举用一个标记的联合来表示。也就是说,它在内存中由一个带有两个字段的 #[repr(C)] 结构表示,其中第一个字段是判别符,因为如果没有变体的字段,它将被编码,第二个字段是每个变体的数据结构的联合。对于一个具体的例子,请考虑清单 11-4 中的枚举和相关表示。
#![allow(unused)] fn main() { #[repr(C)] enum Foo { Bar(i32), Baz { a: bool, b: f64 } } // is represented as #[repr(C)] enum FooTag { Bar, Baz } #[repr(C)] struct FooBar(i32); #[repr(C)] struct FooBaz{ a: bool, b: f64 } #[repr(C)] union FooData { bar: FooBar, baz: FooBaz, } #[repr(C)] struct Foo { tag: FooTag, data: FooData } // 清单 11-4:带有#[repr(C)] 的 Rust 枚举被表示为标记的联合体。 }
FFI 中的利基(niche)优化
在第 9 章中,我们谈到了 niche 优化,即 Rust 编译器使用无效的比特模式来表示没有数据的枚举变体。这种优化是有保证的,这导致了与 FFI 的有趣互动。具体来说,这意味着在 FFI 类型中总是可以使用 Option 包装的指针类型来表示可空指针。 例如,可空函数指针可以表示为
Option<extern fn(..)>,而可空数据指针可以表示为Option<*mut T>。如果提供了一个全零的比特模式值,这些将透明地做正确的事情,并在 Rust 中表示为无。
内存分配 (Allocations)
当你分配内存时,该分配属于其分配器,并且只能由同一分配器释放。如果你在 Rust 中使用多个分配器,或者你在 Rust 中和 FFI 边界另一端的某个分配器中分配内存,情况就是这样。你可以自由地跨越边界发送指针,并尽情地访问该内存,但当需要再次释放该内存时,需要将其返回给适当的分配器。
大多数 FFI 接口会有两种处理分配的配置之一:要么调用者提供指向大块内存的数据指针,要么接口暴露出专门的释放方法,当不再需要这些资源时,应将其返回。清单 11-5 显示了 OpenSSL 库中使用实现管理的内存的一些签名的 Rust 声明的例子。
#![allow(unused)] fn main() { // One function allocates memory for a new object. extern fn ECDSA_SIG_new() -> *mut ECDSA_SIG; // And another accepts a pointer created by new // and deallocates it when the caller is done with it. extern fn ECDSA_SIG_free(sig: *mut ECDSA_SIG); // 清单 11-5:一个实施管理的内存接口 }
ECDSA_SIG_new 和 ECDSA_SIG_free 这两个函数形成了一对,调用者被期望调用 new 函数,根据需要使用返回的指针(可能是通过依次传递给其他函数),然后在完成对引用资源的处理后,将指针传递给 free 函数。据推测,实现者会在新函数中分配内存,并在 free 函数中取消分配。如果这些函数是在 Rust 中定义的,新函数可能会使用 Box::new,而 free 函数会调用 Box::from_raw,然后丢弃值来运行其析构器。
清单 11-6 显示了一个调用者管理的内存的例子。
#![allow(unused)] fn main() { // An example of caller-managed memory. // The caller provides a pointer to a chunk of memory, // which the implementation then uses to instantiate its own types. // No free function is provided, as that happens in the caller. extern fn BIO_new_mem_buf(buf: *const c_void, len: c_int) -> *mut BIO // 清单 11-6:一个调用者管理的内存接口 }
在这里,BIO_new_mem_buf 函数反而让调用者提供支持内存。调用者可以选择在堆上分配内存,或者使用它认为合适的其他机制来获得所需的内存,然后将其传递给库。然后,调用者有责任确保该内存后来被释放,但只有在 FFI 实现不再需要它的时候。
你可以在你的 FFI API 中使用这两种方法,如果你愿意,甚至可以混合使用。作为一般的经验法则,在可行的情况下,允许调用者传递内存,因为这给了调用者更多的自由来管理它认为合适的内存。例如,调用者可能在某些定制的操作系统上使用高度专业化的分配器,并且可能不想被迫使用你的实现所使用的标准分配器。如果调用者可以传入内存,它甚至可以完全避免分配,如果它可以使用栈内存或重新使用已经分配的内存。然而,请记住,调用者管理的接口的人机工程学往往更加复杂,因为调用者现在必须做所有的工作来弄清楚要分配多少内存,然后在调用到你的库之前进行设置。
在某些情况下,调用者甚至不可能提前知道要分配多少内存--例如,如果你的库的类型是不透明的(因此不为调用者所知),或者会随时间变化,调用者将无法预测分配的大小。同样,如果你的代码在运行时需要分配更多的内存,比如你在运行中构建一个图形,那么需要的内存量可能在运行时动态变化。在这种情况下,你将不得不使用实现管理的内存。
当你不得不做出取舍时,对于任何大的或频繁的内存,都要选择调用者分配的内存。在这些情况下,调用者可能最关心控制分配本身。对于其他的东西,你的代码可能可以分配,然后为每个相关的类型提供解构函数。
回调
你可以跨越 FFI 边界传递函数指针,并通过这些指针调用被引用的函数,只要函数指针的类型有一个符合函数调用惯例的 extern 注释。也就是说,你可以在 Rust 中定义一个 extern "C" fn(c_int) -> c_int,然后将该函数的引用作为回调传递给 C 代码,C 代码最终会调用该函数。
在恐慌周围使用回调时,你确实需要小心,因为如果恐慌超过了除 extern "Rust" 以外的函数的结尾,就是未定义行为。Rust 编译器目前会在检测到这种恐慌时自动中止,但这不一定是你想要的行为。相反,你可能想使用 std::panic::catch_unwind 来检测任何标记为 extern 的函数中的恐慌,然后将恐慌转化为一个与 FFI 兼容的错误。
安全
当你写 Rust FFI 绑定时,大多数实际与 FFI 接口的代码都是不安全的,主要围绕着原始指针。然而,你的目标应该是最终在 FFI 之上呈现一个安全的 Rust 接口。要做到这一点,主要是要仔细阅读你所包装的不安全接口的不变性,然后确保你在安全接口中通过 Rust 类型系统维护它们。安全封装外部接口的三个最重要的因素是准确地捕获 & 和 &mut,适当地实现 Send 和 Sync,并确保指针不会被意外地混淆。接下来我将介绍如何执行这些内容。
引用和生存期
如果外部代码有可能修改给定指针后面的数据,请确保安全的 Rust 接口有一个对相关数据的独占引用,即采取 &mut。否则,你的安全封装器的用户可能会意外地从内存中读取外部代码同时修改的数据,然后一切都会变得很糟糕!
你还想很好地利用 Rust 的生存期,以确保所有指针在 FFI 要求的时间内都有效。例如,设想一个外部接口,它让你创建一个 Context,然后让你从该 Context 中创建一个 Device,要求 Context 在 Device 存在的时间内保持有效。在这种情况下,该接口的任何安全包装器都应该在类型系统中强制执行这一要求,让 Device 持有一个与 Context 的借用相关的寿命,该 Context 是由 Device 创建的。
Send 和 Sync
不要为外部库中的类型实现 Send 和 Sync,除非该库明确说明这些类型是线程安全的。安全的 Rust 封装器的工作是确保安全的 Rust 代码不能违反外部代码的不变性,从而引发未定义的行为。
有时,你甚至可能想引入假类型来强制执行外部不变性。例如,假设你有一个事件循环库,其接口在清单 11-7 中给出。
#![allow(unused)] fn main() { extern fn start_main_loop(); extern fn next_event() -> *mut Event; // 清单 11-7:一个期望单线程使用的库 }
现在,假设外部库的文档指出,next_event 只能由调用 start_main_loop 的同一线程调用。然而,在这里我们没有可以避免实现 Send 的类型!相反,我们可以借鉴第三章的做法,引入额外的标记状态来强制执行该不变性,如清单 11-8 所示。
#![allow(unused)] fn main() { pub struct EventLoop(std::marker::PhantomData<*const ()>); pub fn start() -> EventLoop { unsafe { ffi::start_main_loop() }; EventLoop(std::marker::PhantomData) } impl EventLoop { pub fn next_event(&self) -> Option<Event> { let e = unsafe { ffi::next_event() }; // ... } } // 清单 11-8:通过引入辅助类型来执行 FFI 的不变量 }
空类型的 EventLoop 实际上并不与底层外部接口中的任何东西相连接,而是强制执行合同,即只有在调用 start_main_loop 后才能调用 next_evesnt,而且只能在同一个线程上调用。你通过让 EventLoop 持有一个幽灵般的原始指针(它本身既不是 Send 也不是 Sync),来执行 "同一线程" 的部分。
使用 PhantomData<*const ()> 来 "撤销" Send 和 Sync 的自动特性,就像我们在这里做的那样,有点丑陋和间接。Rust 确实有一个不稳定的编译器特性,可以实现负属性的实现,比如 impl !Send for EventLoop {}, 但令人惊讶的是,它很难得到正确的实施,而且可能在一段时间内不会稳定下来。
你可能已经注意到,没有什么能阻止调用者多次调用 start_main_loop,无论是从同一个线程还是从另一个线程。你如何处理这个问题将取决于相关库的语义,所以我把它留给你作为一个练习。
指针的混乱(Pointer Confusion)
在许多 FFI API 中,你不一定想让调用者知道你给它的每一块内存指针的内部表示。该类型可能有内部状态,调用者不应该摆弄这些状态,或者这些状态可能很难以跨语言兼容的方式表达。对于这类情况,C 风格的 API 通常暴露出无效指针,写成 C 类型的 void*,相当于 Rust 中的*mut std::fi::c_void。像这样的类型消除的指针,实际上只是一个指针,并不传达任何关于它所指向的东西的信息。由于这个原因,这些类型的指针经常被称为不透明的。
不透明指针有效地起到了跨 FFI 边界的类型的可见性修改器的作用--因为方法签名没有说明被指向的是什么,调用者没有选择,只能将指针原封不动地传递给周围的人,并使用任何可用的 FFI 方法来提供被引用数据的可见性。不幸的是,由于一个*mut c_void 和另一个是无法区分的,所以没有什么可以阻止用户从一个 FFI 方法返回不透明的指针,并将其提供给一个期望指向不同不透明类型的指针的方法。
在 Rust 中我们可以做得更好。为了减少这种指针类型的混淆,我们可以避免在 FFI 中对不透明的指针直接使用 *mut c_void,即使实际的接口要求使用 void*,而是为每个不同的不透明类型构造不同的空类型。例如,在清单 11-9 中,我使用了两个不同的不透明指针类型,它们不会被混淆。
#![allow(unused)] fn main() { #[non_exhaustive] #[repr(transparent)] pub struct Foo(c_void); #[non_exhaustive] #[repr(transparent)] pub struct Bar(c_void); extern { pub fn foo() -> *mut Foo; pub fn take_foo(arg: *mut Foo); pub fn take_bar(arg: *mut Bar); } // 清单 11-9:不能混淆的不透明指针类型 }
因为 Foo 和 Bar 都是零大小的类型,所以它们可以在 extern 方法签名中代替 ()。更棒的是,因为它们现在是不同的类型,Rust 不允许你在需要另一个的地方使用一个,所以现在不可能使用从 foo 得到的指针调用 take_bar。添加 #[non_] 注释可以确保 Foo 和 Bar 类型不能在这个 crate 之外构造。
bindgen 和构建脚本
为一个较大的外部库绘制 Rust 类型和 externs 可能是一件相当麻烦的事情。大的库往往有足够多的类型和方法签名需要匹配,所以写出所有的 Rust 等价物是很耗时的。它们也有足够多的角落案例和 C 语言的怪异之处,因此一些模式的翻译必然需要更仔细的思考。
幸运的是,Rust 社区开发了一个叫做 bindgen 的工具,它大大简化了这一过程,只要你有 C 语言头文件,你想与之对接的库就可以。bindgen 本质上编码了我们在本章中讨论的所有规则和最佳实践,还有其他一些,并将它们包装在一个可配置的代码生成器中,它接收 C 语言头文件并吐出适当的 Rust 对应物。
bindgen 提供了一个独立的二进制文件,可以为 C 语言头文件生成一次 Rust 代码,这在你想检查绑定的时候很方便。这个过程允许你手动调整生成的绑定,如果有必要的话。另一方面,如果你想在每次构建时自动生成绑定,并在你的源代码中包含 C 头文件,bindgen 也作为一个库,你可以在软件包的自定义构建脚本中调用。
注意:如果你直接检查绑定,请记住它们只在为其生成的平台上是正确的。 在构建脚本中生成绑定将为当前的目标平台专门生成它们,这不太可能导致与平台有关的布局不一致。
你可以在 Cargo.toml 的 [package] 部分添加 build = "<some-file.rs>"来声明一个构建脚本。这告诉 Cargo,在编译你的 crate 之前,它应该把 <some-file.rs> 编译成一个独立的 Rust 程序并运行它;只有这样,它才能编译你的 crate 的源代码。构建脚本也有自己的依赖,你可以在 Cargo.toml 的 [build-dependencies] 部分声明这些依赖。
注意:如果你把你的构建脚本命名为 build.rs,你就不需要在 Cargo.toml 中声明它。
构建脚本在 FFI 中非常方便--它们可以从源代码中编译一个捆绑的 C 库,动态地发现和声明额外的构建标志以传递给编译器,声明 Cargo 应该为重新编译而检查的额外文件,而且,你猜对了,可以在运行中生成额外的源文件!这就是 FFI。
虽然构建脚本的用途非常广泛,但要小心让它们对运行的环境过于了解。虽然你可以用构建脚本来检测 Rust 编译器的版本是否是质数,或者明天伊斯坦布尔是否会下雨,但让你的编译依赖于这些条件可能会使其他开发者的构建意外失败,从而导致糟糕的开发体验。
构建脚本可以将文件写到一个通过 OUT_DIR 环境变量提供的特殊目录中。同样的目录和环境变量在编译时也可以在 Rust 源代码中访问,这样它就可以接收由构建脚本生成的文件了。为了从 C 头文件中生成和使用 Rust 类型,你首先让你的构建脚本使用 bindgen 的库版本来读入一个 .h 文件,并把它变成一个文件,比如说,在 OUT_DIR 里面的 bindings.rs。然后你在你的 crate 中的任何 Rust 文件中添加以下一行,以便在编译时包含 bindings.rs。
#![allow(unused)] fn main() { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); }
由于 bindings.rs 中的代码是自动生成的,通常最好的做法是将绑定放在自己的 crate 中,并给 crate 起一个与绑定的库相同的名字,加上后缀 -sys(例如,openssl-sys)。如果你不遵循这种做法,发布你的库的新版本将更加痛苦,因为两个通过 Cargo.toml 中的链接键链接到同一个外部库的 crate 共存于一个特定的构建中是非法的。你将不得不一次性将整个生态系统升级到你的库的新主要版本。将绑定分离到自己的 crate 中,可以让你发布新的主要版本的封装 crate,从而可以逐步采用。这种分离还允许你在 Rust 绑定发生变化时,例如头文件本身升级或 bindgen 升级导致生成的 Rust 代码发生轻微变化时,削减带有这些绑定的 crate 的中断版本,而不必同时削减安全包裹 FFI 绑定的 crate 的中断版本。
注意:记住,如果你在你的主库的公共接口中包含了来自
-syscrate 的任何类型,将对-syscrate 的依赖性改变到一个新的主要版本仍然构成对你的主库的破坏性改变。
如果你的 crate 产生了一个库文件,你想让别人通过 FFI 来使用,那么你也应该为它的接口发布一个 C 头文件,以便更容易从其他语言生成与你的库的本地绑定。然而,这个 C 头文件需要随着你的 crate 的变化而保持更新,这可能会随着你的库的规模的增长而变得很麻烦。幸运的是,Rust 社区也开发了一个工具来自动完成这项任务:cbindgen。 和 bindgen 一样,cbindgen 也是一个构建工具,而且它也是以二进制文件和库的形式出现,用于构建脚本。它不是接收一个 C 头文件并产生 Rust,而是接收 Rust 并产生一个 C 头文件。由于 C 头文件代表了你的 crate 的 FFI 的主要计算机可读描述,我建议手动查看它,以确保自动生成的 C 代码不会太笨重,尽管一般来说 cbindgen 倾向于产生相当合理的代码。如果不是这样,请提交一个 bug!
C++
在本章中,我主要关注的是 C 语言,因为它是最常用于描述跨语言接口的库,你可以与之链接。几乎每一种编程语言都提供了一些与 C 语言库交互的方式,因为它们是如此的普遍。虽然 C++感觉与 C 密切相关,而且许多高知名度的库是用 C++编写的,但在 FFI 方面,它是一个非常不同的野兽。生成类型和签名以匹配 C 头是相对简单的,但对于 C++来说,情况完全不是这样。在写这篇文章的时候,bindgen 对生成与 C++的绑定有很好的支持,但它们往往缺乏人体工程学。例如,你通常必须手动调用构造函数、析构函数、重载运算符等。一些 C++的特性,如模板专业化,也根本不被支持。如果你确实需要与 C++接口,我建议你试试 cxx crate。
总结
在这一章中,我们已经介绍了如何使用 extern 关键字从 Rust 中调用外部代码,以及如何使用它来使 Rust 代码能够被外部代码访问。我们还讨论了如何将 Rust 类型与 FFI 边界另一端的类型对齐,以及在试图让两种不同语言编写的代码很好地融合时的一些常见陷阱。最后,我们谈到了 bindgen 和 cbindgen 工具,它们使保持 FFI 绑定的经验更加愉快。在下一章中,我们将研究如何在更多的环境中使用 Rust,比如嵌入式设备,在这些环境中,标准库可能无法使用,甚至连分配内存这样的简单操作也无法实现。
第十二章 没有标准库的 Rust
Rust 旨在成为一种系统编程语言,但并不总是很清楚这到底意味着什么。 至少,一种系统编程语言通常被期望允许程序员编写不依赖操作系统的程序,并能直接在硬件上运行,不管是千核超级计算机还是带有时钟速度为 72MHz、内存为 256KiB 的单核 ARM 处理器的嵌入式设备。
在这一章中,我们将看看如何在非正统的环境中使用 Rust,比如那些没有操作系统的环境,或者那些甚至没有动态分配内存能力的环境!我们的大部分讨论将集中在 #![no_std] 属性上,但我们也会研究 Rust 的 alloc 模块、Rust 运行时(是的,Rust 在技术上确实有一个运行时),以及一些你为在这种环境中使用 Rust 二进制文件而必须玩的技巧。
选择不使用标准库(Opting Out of the Standard Library)
作为一种语言,Rust 由多个独立部分组成。首先是编译器,它决定了 Rust 语言的语法,并实现了类型检查、借用检查,以及最终转换为机器可运行的代码。然后是标准库,std,它实现了大多数程序需要的所有有用的通用功能--像文件和网络访问、时间概念、打印和读取用户输入的设施,等等。但 std 本身也是一个复合体,它建立在另外两个更基本的库之上,称为 core 和 alloc。事实上,std 中的许多类型和函数都是从这两个库中重新输出的。
core 库位于标准库金字塔的底部,包含了除了 Rust 语言本身和所产生的程序运行的硬件之外的任何功能--如排序算法、标记类型、基本类型(如 Option 和 Result )、低级操作(如原子内存访问方法)和编译器提示。core 库的工作方式就像操作系统不存在一样,所以没有标准输入,没有文件系统,也没有网络。同样,也没有内存分配器,所以像 Box、Vec 和 HashMap 这样的类型也无处可寻。
core 部分的上方是 alloc,它拥有所有依赖于动态内存分配的功能,比如集合、智能指针和动态分配的字符串(String)。我们将在下一节回到 alloc。
大多数情况下,由于 std 重新输出了 core 和 alloc 中的所有东西,开发者不需要知道这三个库之间的区别。这意味着即使 Option 在技术上存在于 core::option::Option 中,你也可以通过 std::option::Option 访问它。
然而,在一个非正统的环境中,例如在没有操作系统的嵌入式设备上,这种区别是至关重要的。虽然使用 Iterator 或对数字列表进行排序没有问题,但嵌入式设备可能根本就没有访问文件(因为这需要一个文件系统)或打印到终端(因为这需要一个终端)的有意义的方法--所以没有 File 或 println! 此外,设备的内存可能非常小,动态内存分配是你无法承受的奢侈品,因此任何在飞行中分配内存的东西都是不可能的--和 Box 和 Vec 说再见吧。
与其强迫开发者在这样的环境中小心翼翼地避开那些基本结构,Rust 提供了一种方法来选择不使用任何东西,但语言的核心功能除外:#![no_std] 属性。这是一个 crate 级的属性(#!),它将 crate 的 prelude(见第 213 页的方框)从 std::prelude 切换到 core::prelude,这样你就不会意外地依赖任何在目标环境中可能无法工作的核心之外的东西。
然而,这就是 #![no_std] 属性的全部作用--它不会阻止你用 extern std 明确地引入标准库。这可能令人惊讶,因为这意味着标记为 #![no_std] 的 crate 事实上可能与不支持 std 的目标环境不兼容,但这个设计决定是有意的:它允许你将你的 crate 标记为不兼容 std,但在启用某些特性时仍然使用标准库的特性。例如,许多 crate 都有一个名为 std 的特性,当它被启用时,可以访问更复杂的 API,并与 std 中的类型进行整合。这使得 crate 作者既可以为受限制的使用案例提供核心实现,又可以为更多标准平台上的消费者添加一些小玩意。
注意:由于特性应该是相辅相成的,所以最好是启用
std的特性而不是禁用std的特性。否则,如果消费者的依赖关系图中的任何 crate 启用了no-std特性,所有消费者将只能访问没有 std 支持的原始 API,这可能意味着他们所依赖的 API 不可用,导致他们不再编译。
预导入(prelude)
你有没有想过,为什么有些类型和特性--如 Box、Iterator、Option 和 Clone--在每个 Rust 文件中都有,而你却不需要 use 它们?或者为什么你不需要 use 标准库中的任何宏(比如 vec![])?原因是每个 Rust 模块都会自动导入 Rust 标准 prelude,并隐含使用 std::prelude::rust_2021::*(或其他版本的类似内容),这就把 crate 所选版本的 prelude 的所有输出都纳入了范围。前奏模块本身除了自动包含之外并没有什么特别之处--它们只是关键类型、特征和宏的 pub use 语句的集合,Rust 的开发者希望它们能被普遍使用。
动态内存分配
正如我们在第一章中所讨论的,一台机器有许多不同的内存区域,每一个区域都有不同的用途。静态内存用于存放程序代码和静态变量,栈用于存放函数本地变量和函数参数,而堆则用于存放其他东西。堆支持在运行时分配不同大小的内存区域,而且这些分配的内存可以在你希望的时间内存在。这使得堆内存的用途非常广泛,因此,你会发现它无处不在。Vec、String、Arc 和 Rc 以及集合类型都是在堆内存中实现的,这使得它们可以随着时间的推移而增长和缩小,并且可以从函数中返回而不需要借用检查器。
在幕后,堆实际上只是一大块连续的内存,由分配器管理。分配器提供了堆中不同分配的假象,确保这些分配不会重叠,不再使用的内存区域会被重新使用。默认情况下,Rust 使用系统分配器,这通常是由标准 C 库决定的。这对大多数使用情况来说是很好的,但如果有必要,你可以通过 GlobalAlloc 特性和 #[global_allocator] 属性来覆盖 Rust 将使用的分配器,这需要实现一个 alloc 方法来分配一个新的内存段,并通过 dealloc 将过去的分配返回给分配器来重新使用。
在没有操作系统的环境中,标准 C 库一般也无法使用,所以标准系统分配器也无法使用。由于这个原因,#![no_std] 也排除了所有依赖动态内存分配的类型。但是,由于完全有可能在没有完整的操作系统的情况下实现内存分配器,Rust 允许你只选择进入 Rust 标准库中需要分配器的部分,而不需要通过 alloc crate 选择进入所有的 std。alloc crate 是标准 Rust 工具链的一部分(就像 core 和 std 一样),包含了大多数你喜欢的堆分配类型,比如 Box、Arc、String、Vec 和 BTreeMap。HashMap 不在其中,因为它的密钥散列依赖于随机数生成,而随机数生成是操作系统的一种设施。要在 no_std 上下文中使用来自 alloc 的类型,你所要做的就是把这些类型的任何导入都替换成 use std::,而不是 use alloc::。但请记住,依赖 alloc 意味着你的 #![no_std] crate 将不再能被任何不允许动态内存分配的程序使用,因为它没有分配器,或者因为它的内存太少,首先就不允许动态内存分配。
注意:一些编程领域,比如 Linux 内核,可能只允许动态内存分配,但必须优雅地处理超出内存的错误(也就是说,不恐慌)。 对于这样的用例,你要为你暴露的可能分配的任何方法提供
try_版本。try_方法应该使用任何内部类型的易错方法(比如目前不稳定的Box::try_new或Vec::try_reserve),而不是那些只是恐慌的方法(比如Box::new或Vec::reserve),并将这些错误传播给调用者,后者可以适当地处理它们。
你可能会觉得很奇怪,写出只使用 core 的非实质性的板块是可能的。毕竟,它们不能使用集合、String 类型、网络或文件系统,而且它们甚至没有时间的概念。只用 core 的 crate 的诀窍是利用栈和静态分配。例如,对于一个无堆的向量,你在前面分配足够的内存--无论是在静态内存中还是在函数的栈帧中--用于你期望向量能够容纳的最大数量的元素,然后用一个跟踪它目前容纳的元素数量的 usize 来增加它。要向向量推送,你要写到(静态大小的)数组中的下一个元素,并递增一个跟踪元素数量的变量。如果向量的长度达到了静态大小,下一次推送就会失败。 清单 12-1 给出了一个使用 const 泛型实现的无堆向量类型的例子。
#![allow(unused)] fn main() { struct ArrayVec<T, const N: usize> { values: [Option<T>; N], len: usize, } impl<T, const N: usize> ArrayVec<T, N> { fn try_push(&mut self, t: T) -> Result<(), T> { if self.len == N { return Err(t); } self.values[self.len] = Some(t); self.len += 1; return Ok(()); } } // 清单 12-1:一个无堆的矢量类型 }
我们使 ArrayVec 在其元素的类型 T 和元素的最大数量 N 上通用,然后将向量表示为 N 个可选 T 的数组。这个结构总是存储 N 个 Option<T>,所以它的大小在编译时就已经知道了,并且可以存储在堆栈中,但是它仍然可以通过使用运行时信息来告知我们如何访问数组而像一个矢量一样行动。
注意:我们可以使用
[MaybeUninit<T>; N]来实现ArrayVec,以避免Option的开销,但这需要使用不安全的代码,这对这个例子来说是不值得的。
Rust 运行时
你可能听说过 Rust 没有运行时的说法,虽然这在很大程度上是真的--它没有垃圾收集器,没有解释器,也没有内置的用户级调度器--但从严格意义上来说,这并不是真的。具体来说,Rust 确实有一些特殊的代码,在你的 main 函数之前运行,并对你的代码中的某些特殊条件做出反应,这确实是一种裸奔的运行时间。
恐慌的处理程序(The Panic Handler)
这种特殊代码的第一部分是 Rust 的恐慌处理程序。当 Rust 代码通过调用 panic! 或 panic_any 发生恐慌时,恐慌处理程序决定了接下来会发生什么。当 Rust 运行时可用时--就像大多数提供 std 的目标一样--恐慌处理程序首先调用通过 std::panic::set_hook 设置的恐慌钩子,默认情况下,它会打印一条消息和一个回溯到标准错误。然后,它要么展开当前线程的堆栈,要么中止进程,这取决于当前编译时选择的恐慌设置(通过 Cargo 配置或直接传递给 rustc 的参数)。
然而,并不是所有的目标都提供了一个恐慌处理程序。例如,大多数嵌入式目标都不提供,因为不一定有一个单一的实现能让这样的目标在所有用途中都有意义。对于那些没有提供恐慌处理程序的目标,Rust 仍然需要知道当恐慌发生时应该怎么做。为此,我们可以使用 #[panic_handler] 属性来装饰程序中的一个签名为 fn(&PanicInfo) -> ! 的函数。这个函数在程序调用恐慌时被调用,并以 core::panic::PanicInfo 的形式传递关于恐慌的信息。该函数如何处理这些信息是完全没有规定的,但它永远不能返回(正如 ! 返回类型所表示的)。这一点很重要,因为 Rust 编译器假定不会运行恐慌之后的代码。
恐慌处理程序有许多有效的方法来避免返回。 标准的恐慌处理程序会展开线程的栈,然后终止线程,但恐慌处理程序也可以使用 loop{} 来停止线程,中止程序,或者做任何其他对目标平台有意义的事情,甚至可以重置设备。
程序初始化(Program Initialization)
与人们的想法相反,main 函数并不是 Rust 程序中第一个运行的东西。相反,Rust 二进制文件中的 main 符号实际上指向了标准库中一个名为 lang_start 的函数。 该函数为 Rust 运行时进行了(相当少的)设置,包括将程序的命令行参数存放在 std::env::args 可以获取的地方,设置主线程的名称,在主函数中处理恐慌,在程序退出时刷新标准输出,并设置信号处理程序。lang_start 函数反过来调用你的 crate 中定义的主函数,这样就不需要考虑 Windows 和 Linux 在命令行参数传递方式上的不同。
这种安排在所有这些设置都是合理的并被支持的平台上效果很好,但在嵌入式平台上却出现了问题,因为程序启动时可能无法访问主内存。在这样的平台上,你通常希望使用 #![no_main] crate 级别属性来选择完全不使用 Rust 初始化代码。这个属性完全省略了 lang_start,这意味着你作为开发者必须弄清楚程序应该如何启动,比如通过声明一个 #[export_name = "main"] 的函数来匹配目标平台的预期启动顺序。
注意:在跳转到定义的开始符号之前真正不运行任何代码的平台上,如大多数嵌入式设备,静态变量的初始值甚至可能与源代码中指定的内容不一致。在这种情况下,你的初始化函数将需要用程序二进制中指定的初始数据值明确地初始化各种静态内存段。
内存不足的处理程序(The Out-of-Memory Handler)
如果你写的程序希望使用 alloc,但它所处的平台没有提供分配器,你必须使用本章前面提到的 #[global_allocator] 属性来决定使用哪个分配器。但是你还必须指定如果全局分配器不能分配内存会发生什么。具体来说,你需要定义一个内存不足的处理程序,说明如果像 Vec::push 这样的无懈可击的操作需要分配更多的内存,但分配器不能提供,应该怎么办。
在支持 std 的平台上,内存不足处理程序的默认行为是向标准错误打印一条错误信息,然后中止进程。然而,在一个平台上,例如,没有标准错误,这显然是行不通的。在撰写本文时,在这样的平台上,你的程序必须使用不稳定属性 #[lang = "oom"] 明确定义一个内存不足处理程序。 请记住,处理程序几乎肯定会阻止将来的执行,否则,试图分配内存的代码将继续执行,而不知道它没有收到它所请求的内存!
注意:当你读到这篇文章时,内存外处理程序可能已经稳定在一个永久的名字之下(
#[alloc_error_handler],很可能)。我们也正在努力为默认的 std 内存外处理程序提供与 Rust 的 panic 处理程序相同的 "钩子" 功能,这样代码就可以通过set_alloc_error_hook这样的方法来改变内存外行为。
低级别的内存访问(Low-Level Memory Accesses)
在第 10 章中,我们讨论了这样一个事实:编译器在如何将你的程序语句转化为机器指令方面有相当大的回旋余地,而 CPU 也被允许有一些回旋余地来执行不按顺序的指令。通常情况下,编译器和 CPU 可以利用的捷径和优化对程序的语义来说是不可见的--你一般无法知道,比如说,两个读数是否被重新排序,或者从同一内存位置的两个读数是否实际导致了两条 CPU 的加载指令。这是设计上的问题。语言和硬件设计者仔细地规定了程序员在代码运行时通常期望的语义,因此你的代码通常会做你期望的事情。
然而,no_std 编程有时会让你超越 "隐形优化" 的通常边界。特别是,你经常会通过内存映射与硬件设备进行通信,设备的内部状态在内存中被精心选择的区域内提供。例如,当你的计算机启动时,内存地址范围 0xA0000-0xBFFFF 映射到一个粗糙的图形渲染管道;对该范围内的单个字节的写入将改变屏幕上的特定像素(或块,取决于模式)。
当你与设备映射的内存进行交互时,设备可能会对该内存区域的每个内存访问实施自定义行为,因此你的 CPU 和编译器对常规内存加载和存储的假设可能不再成立。例如,硬件设备的内存映射寄存器在被读取时被修改是很常见的,这意味着读取有副作用。在这种情况下,如果你连续两次读取相同的内存地址,编译器就不能安全地避开内存存储操作!
当程序的执行突然以代码中没有体现的方式被转移,从而使编译器无法预期时,就会出现类似的问题。如果没有底层操作系统来处理处理器的异常或中断,或者如果一个进程收到中断执行的信号,那么执行就可能被转移。在这些情况下,活动代码段的执行被停止,而 CPU 开始执行事件处理程序中的指令,以代替触发分流的事件。通常情况下,由于编译器可以预测所有可能的执行,它安排了它的优化,使执行不能观察到操作已经被执行的顺序错误或被优化掉。然而,由于编译器不能预测这些特殊的跳转,它也不能计划让它们对它的优化视而不见,所以这些事件处理程序实际上可能会观察到与原始程序代码中不同顺序运行的指令。
为了处理这些特殊情况,Rust 提供了易失性内存操作,这些操作不能与其他易失性操作一起被省略或重新排序。这些操作的形式是 std::ptr::read_volatile 和 std::ptr::write_volatile。易失性操作正好适合访问内存映射的硬件资源:它们直接映射到内存访问操作,不需要编译器的技巧,而且易失性操作相对于其他操作不排序的保证,确保了有可能产生副作用的硬件操作不会不按顺序发生,即使它们通常看起来是可以互换的(比如一个地址的加载和一个不同地址的存储)。只要任何触及在特殊情况下访问的内存的代码只使用易失性内存操作,无排序保证也有助于特殊情况的执行。
注意:还有一个
std::sync::atomic::compiler_fence函数可以防止编译器对非易失性内存访问进行重新排序。你很少需要编译器栅栏,但它的文档是一个有趣的阅读。
包括汇编代码
这些天来,你很少需要下降到编写汇编代码来完成任何特定的任务。但是对于那些需要在启动时初始化 CPU 或发出奇怪指令来操作内存映射的低级硬件编程,有时仍然需要汇编代码。在写这篇文章的时候,有一个 RFC,并且在夜间的 Rust 上有一个基本完整的内联汇编语法的实现,但是还没有稳定下来,所以我不会在这本书中讨论这个语法。
在稳定的 Rust 上编写汇编仍然是可能的,你只需要有一点创造力。特别是,还记得第 11 章的构建脚本吗?好吧,Cargo 的构建脚本可以向标准输出发出某些特殊指令,以增强 Cargo 的标准构建过程,包括 cargo:rustc-link-lib=static=xyz 来链接静态库文件 libxyz.a 到最终的二进制文件,以及 cargo:rustc-link-search:/some/path 来将 /some/path 加入链接对象的搜索路径中。利用这些,我们可以在项目中添加一个 build.rs,用目标平台的编译器将独立的汇编文件(.s)编译成对象文件(.o),然后用适当的归档工具(通常是 ar)将其重新打包成静态档案(.a)。然后,项目发出这两条 Cargo 指令,指出它在 OUT_DIR 中放置静态归档文件的位置,我们就可以开始工作了 如果目标平台没有变化,你甚至可以在发布你的 crate 时包括预编译的 .a,这样消费者就不需要重建它了。
抗误用的硬件抽象(Misuse-Resistant Hardware Abstraction)
Rust 的类型系统擅长于将不安全的、多毛的和其他不愉快的代码封装在安全的、符合人体工程学的接口后面。 这一点在臭名昭著的复杂的低级系统编程世界中更为重要,它充满了从晦涩的手册中提取的神奇的硬件定义值和神秘的无文档的汇编指令咒语,以使设备进入正确的状态。而所有这一切都发生在一个运行时错误可能会导致不止一个用户程序崩溃的空间里。
在 no_std 程序中,正如我们在第三章中讨论的那样,使用类型系统来使非法状态不可能被表示出来,这是非常重要的。如果某些寄存器值的组合不能同时出现,那么就创建一个单一的类型,其类型参数表示相关寄存器的当前状态,并在其上只实现合法的转换,就像我们在清单 3-2 中的火箭例子那样。
注意:请确保同时回顾第三章中关于 API 设计的建议--所有这些建议也适用于
no_std程序的背景。
例如,考虑一对寄存器,在任何给定的时间点上,最多只有一个寄存器应该是 "打开 "的。清单 12-2 显示了你如何在一个(单线程)程序中表示这一点,使之不可能写出违反该不变性的代码。
#![allow(unused)] fn main() { // raw register address -- private submodule mod registers; pub struct On; pub struct Off; pub struct Pair<R1, R2>(PhantomData<(R1, R2)>); impl Pair<Off, Off> { pub fn get() -> Option<Self> { static mut PAIR_TAKEN: bool = false; if unsafe { PAIR_TAKEN } { None } else { // Ensure initial state is correct. registers::off("r1"); registers::off("r2"); unsafe { PAIR_TAKEN = true }; Some(Pair(PhantomData)) } } pub fn first_on(self) -> Pair<On, Off> { registers::set_on("r1"); Pair(PhantomData) } // .. and inverse for -> Pair<Off, On> } impl Pair<On, Off> { pub fn off(self) -> Pair<Off, Off> { registers::set_off("r1"); Pair(PhantomData) } } // .. and inverse for Pair<Off, On> // 清单 12-2:静态地确保正确的操作 }
在这段代码中,有几个值得注意的模式。首先,我们通过在唯一的构造函数中检查一个私有的静态布尔值并使所有的方法都消耗 self 来确保 Pair 的单一实例永远存在。然后,我们确保初始状态是有效的,并且只有有效的状态转换才有可能被表达出来,因此该不变式必须在全局上成立。
清单 12-2 中第二个值得注意的模式是,我们使用 PhantomData 来利用零大小的类型,静态地表示运行时信息。也就是说,在代码的任何给定点,这些类型告诉我们运行时的状态必须是什么,因此我们不需要在运行时跟踪或检查任何与寄存器有关的状态。当我们被要求启用 r1 时,不需要检查 r2 是否已经打开,因为这些类型可以防止编写出这样的程序。
交叉编译
通常,你会在一台运行着成熟的操作系统和现代硬件的所有好处的计算机上编写 no_std 程序,但最终会在一个只有 93/4 比特的内存和一个 CPU 的袜子的小硬件设备上运行它。这就需要交叉编译--你需要在你的开发环境中编译代码,但要为袜套编译。不过,这并不是交叉编译很重要的唯一情况。例如,让一个构建管道为所有消费者平台产生二进制工件,而不是为你的消费者可能使用的每一个平台都有一个构建管道,这意味着使用交叉编译,这种情况越来越普遍。
注意:如果你实际上是为内存有限的类似袜子的东西进行编译,甚至是像土豆一样花哨的东西,你可能想把
opt-levelCargo 配置设置为 "s",以优化较小的二进制大小。
交叉编译涉及两个平台:主机平台和目标平台。主机平台是进行编译的平台,而目标平台是最终将运行编译输出的平台。我们将平台指定为目标三要素,其形式为 machine-vendor-os 。machine部分决定了代码将在哪种机器架构上运行,例如 x86_64、armv7 或 wasm32,并告诉编译器对发出的机器代码使用什么指令集。vendor部分通常在 Windows 上取值为 pc,在 macOS 和 iOS 上取值为 apple,在其他地方取值为 unknown,并且不会以任何有意义的方式影响编译;它大部分是不相关的,甚至可以不取。os 部分告诉编译器对最终的二进制工件使用什么格式,所以 linux 的值决定了 Linux .so 文件,windows 决定了 Windows .dll 文件,等等。
注意:默认情况下,Cargo 假设目标平台与主机平台相同,这就是为什么你通常不需要告诉 Cargo,例如,当你已经在 Linux 上时,为 Linux 进行编译。有时你可能想使用
--target,即使目标的 CPU 和操作系统是相同的,例如,目标是libc的musl实现。
要告诉 Cargo 进行交叉编译,你只需将 --target <targettriple> 参数和你选择的三元组传给它。然后,Cargo 会把这些信息转发给 Rust 编译器,使其生成的二进制文件能在指定的目标平台上运行。Cargo 也会注意为该平台使用适当版本的标准库--毕竟,标准库包含很多条件性编译指令(使用 #[cfg(..)]),以便调用正确的系统调用并使用正确的特定架构实现,所以我们不能在目标平台上使用主机平台的标准库。
目标平台也决定了标准库的哪些组件是可用的。例如,虽然 x86_64-unknown-linuxgnu 包括完整的 std 库,但像 thumbv7m-one-eabi 这样的东西却没有,甚至没有定义一个分配器,所以如果你在没有明确定义分配器的情况下使用 alloc,你会得到一个构建错误。这对于测试你写的代码是否真的不需要 std 很有帮助(记得即使使用 #![no_std],你仍然可以使用 std::,因为 no_std 只选择了 std 的 prelude)。如果你让你的持续集成管道用 --target thumbv7m-non-eabi 来构建你的 crate,那么任何试图从核心以外的地方访问组件的行为都会触发构建失败。 重要的是,这也会检查你的 crate 是否意外地引入了那些本身使用 std(或 alloc)项目的依赖。
平台支持
标准的 Rust 安装程序 Rustup 并没有为 Rust 默认支持的所有目标三元组安装标准库。相反,你必须使用
rustup target add命令来为额外的目标安装相应的标准库版本。如果你的目标平台没有标准库的版本,你就必须自己从源代码中编译它,方法是添加rust-srcRustup 组件,并使用 Cargo 的(目前还不稳定)build-std 功能,在构建任何 crate 时也构建 std(和/或 core and alloc)。如果你的目标不被 Rust 编译器支持,也就是说,如果 Rustc 甚至不知道你的目标三元组,你就必须更进一步,用自定义目标规范来教 Rustc 关于三元组的属性。如何做到这一点,目前还不稳定,也超出了本书的范围,但搜索 "自定义目标规范 json "是一个不错的开始。
总结
在这一章中,我们已经介绍了标准库下面的东西,或者更准确地说,是 std 下面的东西。我们已经介绍了 core 的内容,如何用 alloc 来扩展 no-std 的范围,以及 Rust 运行时(很小)为你的程序添加了什么来使 fn main 工作。 我们还看了如何与设备映射的内存进行交互,以及如何处理在硬件编程的最底层可能发生的非正统的执行模式,以及如何在 Rust 类型系统中安全地封装至少一些硬件的怪异现象。接下来,我们将通过讨论如何浏览、理解,甚至为更大的 Rust 生态系统做出贡献,来从很小的地方走向很大的地方。
Rust 生态系统
如今,编程很少发生在真空中--几乎每一个你建立的 Rust crate 都有可能依赖一些不是你写的代码。这种趋势是好是坏,还是两者兼而有之,这是一个激烈争论的话题,但无论如何,这都是当今开发者体验的一个现实。
在这个勇敢的相互依存的新世界里,牢固掌握哪些库和工具是可用的,并及时了解 Rust 社区所提供的最新和最伟大的东西,这比任何时候都重要。本章专门讨论了如何利用、跟踪、了解和回馈 Rust 生态系统。由于这是最后一章,在结尾部分我还会提供一些建议,让你可以探索更多的资源来继续发展你的 Rust 技能。
那里有什么?(What’s Out There?)
尽管 Rust 相对年轻,但它已经有了一个足够大的生态系统,很难跟踪所有可用的东西。如果你知道自己想要什么,你也许可以通过搜索找到一组合适的 crate,然后使用下载统计和对每个 crate 的仓库进行肤浅的检查,以确定哪些 crate 可能成为合理的依赖。然而,还有大量的工具、工具箱和一般的语言特性,你可能不一定知道要寻找这些工具、工具箱和语言特性,它们有可能为你节省无数的时间和困难的设计决策。
在本节中,我将介绍一些我多年来发现的有帮助的工具、库和 Rust 特性,希望它们在某些时候也能对你有所帮助。
工具
首先,这里有一些我发现自己经常使用的 Rust 工具,你应该加入你的工具箱:
-
cargo-deny
提供了一种对依赖关系图进行提示的方法。在写这篇文章的时候,你可以使用 cargo-deny 来只允许某些许可证,拒绝列出板条或特定板条的版本,检测有已知漏洞或使用 Git 源的依赖关系,以及检测在依赖关系图中多次出现不同版本的板条。当你读到这里的时候,可能已经有了更多方便的衬垫。
-
cargo-expand
展开给定 crate 中的宏,并让你检查输出结果,这使你更容易发现宏转录器或程序性宏深处的错误。当你自己编写宏时,cargo-expand 是一个宝贵的工具。
-
cargo-hack
帮助你检查你的货箱在启用任何功能组合的情况下都能工作。该工具的界面与 Cargo 本身的界面相似(如 cargo check、build 和 test),但让你能够用所有可能的组合(poweret)来运行一个给定的命令,即箱子的特性。
-
cargo-llvm-lines
分析从 Rust 代码到中间表示(IR)的映射,该映射被传递给 Rust 编译器中实际生成机器代码的部分(LLVM),并告诉你 Rust 代码的哪些部分产生了最大的 IR。这很有用,因为更大的 IR 意味着更长的编译时间,所以确定哪些 Rust 代码产生了更大的 IR(由于,例如,单态化)可以突出减少编译时间的机会。
-
cargo-outdated
检查你的任何依赖关系,无论是直接的还是横向的,是否有更新的版本。重要的是,与 cargo update 不同,它甚至会告诉你新的主要版本,所以它是检查你是否由于主要版本指定器过期而错过新版本的重要工具。请记住,如果你在你的接口中暴露了一个依赖的类型,那么提高该依赖的主要版本可能会对你的 crate 造成破坏性的变化!
-
cargo-udeps
识别 Cargo.toml 中列出的任何从未实际使用的依赖关系。也许你过去使用过它们,但后来它们变得多余了,或者它们应该被移到 dev-dependencies 中;无论如何,这个工具可以帮助你减少依赖关系闭合中的臃肿。
虽然它们不是专门用于开发 Rust 的工具,但我也强烈推荐 fd 和 ripgrep--它们比它们的前辈 find 和 grep 有了很好的改进,而且刚好是用 Rust 自己写的。我每天都在使用这两个工具。
库
接下来是一些有用但鲜为人知的箱子,我经常拿出来用,而且我怀疑我将继续长期依赖这些箱子。
-
bytes
提供了一种有效的机制来传递单块连续内存的子片,而不需要复制或处理生命期。这在低级别的网络代码中是非常好的,因为你可能需要在一个字节块中有多个视图,而复制是不可能的。
-
criterion
一个统计学驱动的基准测试库,使用数学来消除基准测量中的噪音,并可靠地检测性能随时间的变化。如果你在你的工具箱中包括微观的基准测试,你几乎肯定应该使用它。
-
cxx
为从 Rust 中调用 C++代码和从 C++中调用 Rust 代码提供了一个安全且符合人体工程学的机制。如果你愿意投入一些时间,提前更彻底地声明你的接口,以换取更好的跨语言兼容性,这个库非常值得你关注。
-
flume
实现了一个多生产者、多消费者的通道,比 Rust 标准库中的通道更快、更灵活、更简单。它还支持异步和同步操作,因此是这两个世界之间的一个伟大的桥梁。
-
hdrhistogram
高动态范围(HDR)直方图数据结构的 Rust 端口,它提供了一个跨越广泛价值范围的直方图的紧凑表示。在你目前追踪平均值或最小/最大值的任何地方,你都应该使用 HDR 直方图来代替;它可以让你更好地了解你的指标的分布。
-
heapless
提供不使用堆的数据结构。相反,heapless 的数据结构都是由静态内存支持的,这使得它们非常适合于嵌入式环境或其他不希望分配的情况。
-
itertools
扩展了标准库中的 Iterator 属性,为重复数据处理、分组和计算权力集提供了很多新的方便方法。这些扩展方法可以大大减少代码中的模板,比如你在一个数值序列上手动实现一些常见的算法,比如同时找到最小和最大(Itertools::minmax),或者你使用一种常见的模式,比如检查一个迭代器是否正好有一个项目(Itertools::exact_one)。
-
nix
在类似 Unix 的系统上提供与系统调用的习惯性绑定,这比在直接使用类似 libc 的东西时试图拼凑与 C 兼容的 FFI 类型要好得多。
-
pin-project
提供了为注释类型强制执行引脚安全不变式的宏,这反过来又为这些类型提供了一个安全的引脚接口。这使得你可以避免为你自己的类型获得 Pin 和 Unpin 的大部分麻烦。还有一个 pinproject-lite,它避免了(目前)对程序性宏机制的严重依赖,但代价是人机工程学方面略差。
-
ring
从 C 语言编写的密码学库 BoringSSL 中提取好的部分,并通过一个快速、简单和难以滥用的接口将其引入 Rust。如果你需要在你的程序箱中使用密码学,它是一个很好的起点。你很可能已经在 rustls 库中遇到过了,它使用 ring 来提供一个现代的、安全的默认 TLS 栈。
-
slab
实现一个高效的数据结构来代替
HashMap<Token, T>,其中 Token 是一个不透明的类型,仅用于区分地图中的条目。这种模式在管理资源时经常出现,当前的资源集必须被集中管理,但单个资源也必须以某种方式被访问。 -
static_assertions
提供静态断言--即在编译时评估的断言,因此在编译时可能失败。你可以用它来断言一些事情,比如一个类型实现了一个给定的特性(如 Send)或具有一个给定的大小。我强烈建议在这些保证可能很重要的代码中添加这些类型的断言。
-
structopt
包裹了著名的参数解析库 clap,并提供了一种完全使用 Rust 类型系统(加上宏注释)来描述你的应用程序的命令行界面的方法。当你解析你的应用程序的参数时,你会得到一个你定义的类型的值,因此你会得到所有类型检查的好处,比如穷举匹配和 IDE 自动补全。
-
thiserror
使得编写自定义枚举错误类型,如我们在第 4 章讨论的那些,成为一种乐趣。它负责实现推荐的特征并遵循既定的惯例,让你只需定义你的应用程序所特有的关键部分。
-
tower
有效地采用了函数签名
async fn(Request) -> Response,并在其之上实现了整个生态系统。其核心是服务特性,它代表了一种可以将请求转化为响应的类型(我猜想有一天它可能会进入标准库)。这是一个很好的抽象,可以在其上建立任何看起来像服务的东西。 -
tracing
它提供了有效追踪你的应用程序的执行所需的所有管道。最重要的是,它与你要追踪的事件类型以及你想用这些事件做什么无关。这个库可以用于记录、指标收集、调试、剖析和明显的跟踪,所有这些都有相同的机制和接口。
Rust 工具
Rust 工具链有一些你可能不知道要寻找的功能。这些功能通常是针对非常特殊的用例,但如果它们与你的用例相匹配,它们就会成为救命稻草。
Rustup
Rustup 是 Rust 工具链的安装程序,它的工作非常高效,以至于它往往会淡出后台,被人遗忘。你偶尔会用它来更新你的工具链,设置一个目录覆盖,或者安装一个组件,但仅此而已。然而,Rustup 支持一个非常方便的技巧,值得了解:工具链覆盖速记。你可以把+toolchain 作为第一个参数传递给任何 Rustup 管理的二进制文件,二进制文件将像你为给定的工具链设置覆盖一样工作,运行该命令,然后将覆盖重新设置为之前的样子。因此,cargo +nightly miri 将使用 nightly 工具链运行 Miri,cargo +1.53.0 check 将检查代码是否用 Rust 1.53.0 编译。后者在检查你有没有破坏你的最小支持的 Rust 版本合同时特别方便。
Rustup 还有一个漂亮的子命令,doc,它可以在你的浏览器中打开当前版本 Rust 编译器的 Rust 标准库文档的本地拷贝。如果你在没有互联网连接的情况下进行开发,这一点是非常宝贵的。
Cargo
Cargo 也有一些不容易被发现的功能。首先是 cargo tree,这是 Cargo 本身的一个子命令,用于检查箱子的依赖关系图。 这个命令的主要目的是把依赖关系图打印成树状。这本身就很有用,但 cargo tree 真正的闪光点在于 --invert 选项:它接收一个装箱的标识符,并产生一个反转的树形图,显示从当前装箱带来的所有依赖路径。因此,例如,cargo tree -i rand 会打印出当前 crate 依赖任何版本的 rand 的所有方式,包括通过传递性依赖关系。如果你想消除一个依赖关系,或者一个依赖关系的特定版本,并想知道为什么它仍然被拉进来,这就非常有价值了。你也可以通过-e features 选项来包括有关箱体的每个 Cargo 特性被启用的信息。
说到 Cargo 的子命令,编写自己的子命令真的很容易,不管是为了和别人分享还是为了自己的本地开发。当 Cargo 被调用一个它不认识的子命令时,它会检查是否存在一个名字为 cargo-$subcommand 的程序。如果存在,Cargo 就会调用该程序,并把命令行上的参数传给它--因此,cargo foo bar 就会调用 cargo-foo,参数为 bar。Cargo 甚至会把这个命令和 cargo help 结合起来,把 cargo help foo 翻译成对 cargo-foo --help 的调用。
当你在更多的 Rust 项目上工作时,你可能会注意到 Cargo(以及更普遍的 Rust)在磁盘空间方面并不宽松。每个项目都有自己的目标目录用于编译工件,随着时间的推移,你最终会积累几份相同的编译工件用于共同的依赖。将每个项目的工件分开是一个明智的选择,因为它们不一定能在不同的项目间兼容(比如,如果一个项目使用的编译器标志与另一个不同)。但在大多数开发者环境中,共享构建工件是完全合理的,在项目间切换时可以节省大量的编译时间。幸运的是,配置 Cargo 来共享构建工件很简单:只要在你的 ~/.cargo/config.toml 文件中设置 [build] target 为你希望共享工件的目录,Cargo 就会处理剩下的事情。再也看不到目标目录了!只要确保你偶尔清理一下该目录,并注意 cargo clean 会清理你所有项目的构建工件。
注意:使用共享构建目录会给那些假定编译器工件总是在
target/子目录下的项目带来问题,所以要注意这一点。还要注意的是,如果一个项目确实使用了不同的编译器标志,那么每次你进入或离开该项目时,你都会重新编译受影响的依赖项。在这种情况下,你最好在项目的 Cargo 配置中把目标目录覆盖到一个不同的位置。
最后,如果你觉得 Cargo 花了很长时间来编译你的程序,你可以使用目前不稳定的 Cargo -Ztimings 标志。使用该标志运行 Cargo 时,会输出以下信息:处理每个 crate 需要多长时间,构建脚本需要多长时间,哪些 crate 需要等待其他 crate 完成编译,以及其他大量有用的衡量标准。这可能会突出一个特别慢的依赖链,然后你可以努力消除它,或者揭示一个从头开始编译本地依赖的构建脚本,你可以使用系统库来代替。如果你想更深入地研究,还有 rustc -Ztime-passes,它可以发出关于每个编译器内部花费的时间的信息--尽管这些信息可能只有在你想对编译器本身做出贡献时才有用。
rustc
Rust 编译器也有一些鲜为人知的功能,可以证明对有进取心的开发者很有用。第一个是当前不稳定的 -Zprint-type-sizes 参数,它可以打印出当前编译器中所有类型的大小。除了最微小的 crate 外,这将产生大量的信息,但在试图确定调用 memcpy 所花费的意外时间的来源时,或者在分配大量特定类型的对象时寻找减少内存使用的方法时,这是非常有价值的。 -Zprint-type-sizes 参数还显示了每个类型的计算对齐方式和布局,这可能会给你指出一些地方,例如,把 usize 变成 u32 可能会对一个类型的内存表示产生重大影响。在你调试了一个特定类型的大小、排列和布局之后,我建议添加静态断言,以确保它们不会随着时间的推移而退步。你也可能对 variant_size_differences lint 感兴趣,如果一个 crate 包含枚举类型,其变体在大小上有很大差异,它会发出警告。
注意:要用特定的标志来调用 rustc,你有几个选择:你可以在
RUSTFLAGS环境变量中设置这些标志,或者在你的。cargo/config.toml 中设置[build] rustflags,让它们适用于 Cargo 中的每一次 rustc 调用,或者你可以使用cargo rustc,它将把你提供的任何参数只传递给当前 crate 的 rustc 调用。
如果你的剖析样本看起来很奇怪,栈帧被重新排序或完全丢失,你也可以试试 -Cforce-frame-pointers = yes。帧指针提供了一种更可靠的方式来展开栈--这在剖析过程中经常进行--但代价是一个额外的寄存器被用于函数调用。尽管在启用常规调试符号的情况下,栈展开应该可以正常工作(记得在使用发布配置文件时设置 debug = true),但情况并非总是如此,帧指针可以解决你遇到的任何问题。
标准库
与其他编程语言相比,Rust 的标准库通常被认为是很小的,但是它在广度上的不足,在深度上得到了弥补;你不会在 Rust 的标准库中找到网络服务器的实现或 X.509 证书解析器,但是你会在 Option 类型上找到 40 多种不同的方法和 20 多种特性的实现。对于它所包含的类型,Rust 尽力提供任何相关的功能,以有意义地提高工效,所以你可以避免所有的冗长的模板,否则很容易出现。在本节中,我将介绍标准库中的一些类型、宏、函数和方法,这些类型、宏、函数和方法你可能以前没有遇到过,但它们通常可以简化或改进(或同时)你的代码。
宏和函数
让我们从几个独立的实用程序开始。首先是 write! 宏,它可以让你使用格式化字符串向文件、网络套接字或其他任何实现 Write 的东西写入。你可能已经对它很熟悉了,但是 write! 的一个鲜为人知的特点是,它可以与 std::io::Write 和 std::fmt::Write 一起工作,这意味着你可以用它直接将格式化的文本写入一个 String 中。也就是说,你可以 use std::fmt::Write; write! (&mut s, "{}+1={}", x, x + 1); 将格式化的文本追加到String s!
iter::once 函数接收任何值,并产生一个产生该值的迭代器。如果你不想分配迭代器,在调用接收迭代器的函数时,或者与 Iterator::chain 结合,向现有的迭代器追加一个单项时,这个函数就很方便。
我们在第 1 章中简要地谈到了 mem::replace,但是如果你错过了它,还是值得再次提起的。这个函数接收一个 T 的独占引用和一个拥有的 T,将两者互换,使引用者现在是拥有的 T,并返回先前引用者的所有权。当你需要在只有独占引用的情况下取得一个值的所有权时,这个函数很有用,比如在 Drop 的实现中。参见 mem::take,当 T:Default。
类型
接下来,让我们看一下一些方便的标准库类型。BufReader 和 BufWriter 类型对于向底层 I/O 资源发出许多小的读或写调用的 I/O 操作是必须的。这些类型包裹了各自的底层读或写,并实现了读和写本身,但它们还对 I/O 资源的操作进行了缓冲,这样许多小的读只做一个大的读,许多小的写只做一个大的写。这可以极大地提高性能,因为你不需要经常跨越操作系统的系统调用障碍。
第 3 章中提到的 Cow 类型,在你想灵活掌握什么类型或需要灵活掌握返回的类型时很有用。你很少将 Cow 作为一个函数参数使用(请记住,如果有必要,你应该让调用者分配),但它作为一个返回类型是非常宝贵的,因为它允许你准确地表示可能分配或不分配的函数的返回类型。它也很适合那些可以作为输入或输出的类型,比如类似 RPC 的 API 中的核心类型。假设我们有一个像清单 13-1 中的 EntityIdentifier 类型,它被用于一个 RPC 服务接口。
#![allow(unused)] fn main() { struct EntityIdentifier { namespace: String, name: String, } // 清单 13-1:一个需要分配的组合输入/输出类型的表示方法 }
现在想象一下两个方法:get_entity 需要一个 EntityIdentifier 作为参数,而 find_by 根据一些搜索参数返回一个 EntityIdentifier。get_entity 方法只需要一个引用,因为该标识符在被发送到服务器之前(大概)会被序列化。但是对于 find_by,实体将从服务器的响应中被反序列化,因此必须被表示为一个自有的值。如果我们让 get_entity 接受&EntityIdentifier,这将意味着调用者仍然必须分配自有的 Strings 来调用 get_entity,尽管接口并不要求这样做,因为首先需要构建一个 EntityIdentifier 我们可以为 get_entity 引入一个单独的类型,即 EntityIdenifierRef,它只容纳 &str 类型,但这样我们就有两个类型来代表一件事。Cow 来救场了!清单 13-2 显示了一个内部持有奶牛的 EntityIdentifier。
#![allow(unused)] fn main() { struct EntityIdentifier<'a> { namespace: Cow<'a, str>, name: Cow<'a str>, } // 清单 13-2:不需要分配的组合输入/输出类型的表示方法 }
通过这种结构,get_entity 可以接受任何 EntityIdentifier<'_>,这允许调用者只使用引用来调用该方法。 而 find_by 可以返回 EntityIdentifier<'static>,其中所有字段都是 Cow::Owned。一个类型在两个接口上共享,没有不必要的分配要求。
注意:如果你这样实现一个类型,我建议你也提供一个
into_owned方法,通过调用Cow::into_owned在所有字段上将一个<'a>实例变成一个<'static>实例。 否则,当用户只有一个<'a>时,他们将没有办法对你的类型做更持久的克隆。
std::sync::Once 类型是一个同步基元,可以让你在初始化时精确地运行一段给定的代码。这对于作为 FFI 一部分的初始化来说是非常好的,因为 FFI 边界的另一端的库要求只执行一次初始化。
VecDeque 类型是 std::collection 中一个经常被忽视的成员,我发现自己经常会用到它--基本上,每当我需要一个栈或一个队列时。它的接口类似于 Vec,和 Vec 一样,它在内存中的表示是单一的内存块。 不同的是,VecDeque 在单一的分配中保持对实际数据的开始和结束的跟踪。这允许从 VecDeque 的任何一边不断地推送和弹出,这意味着它可以作为一个栈,作为一个队列,甚至可以同时使用。你所付出的代价是,这些值在内存中不一定是连续的(它们可能已经被包裹起来),这意味着 VecDeque<T>没有实现 AsRef<[T]>。
方法
让我们来快速浏览一下一些整洁的方法。首先是 Arc::make_mut,它接收一个&mut Arc<T>并给你一个&mut T。如果该 Arc 是最后一个存在的,它给你该 Arc 后面的 T;否则,它分配一个新的 Arc<T>来保存 T 的克隆,把它换成当前引用的 Arc,然后把&mut 给新单子 Arc 中的 T。
Clone::clone_from 方法是。clone() 的另一种形式,它让你重新使用你所克隆的类型的实例,而不是分配一个新的实例。换句话说,如果你已经有一个 x:T,你可以做 x.clone_from(y) 而不是 x = y.clone(),这样你就可以为自己节省一些分配。
std::fmt::Formatter::debug_* 是迄今为止自己实现 Debug 的最简单的方法,如果 #[derive(Debug)] 对你的使用情况不起作用的话,比如你只想包括一些字段,或者暴露出你的类型的字段的 Debug 实现没有暴露的信息。当实现 Debug 的 fmt 方法时,只需在传入的 Formatter 上调用适当的 debug_方法(例如 debug_struct 或 debug_map),在生成的类型上调用所包含的方法来填写关于该类型的细节(如添加一个字段的 field 或添加一个键/值条目的 entries),然后再调用 finish。
Instant::elapsed 返回创建 Instant 后的持续时间。这比创建一个新的 Instant 并减去先前的实例的常见方法要简洁得多。
Option::as_deref 接收一个 Option<P>,其中 P:Deref 并返回 Option<&P::Target>(还有一个 as_deref_mut 方法)。这个简单的操作可以使对 Option 进行操作的函数转换链更加简洁,因为它避免了不可捉摸的.as_ref().map(|r| &**r)。
Ord::clamp 让你可以把任何实现 Ord 的类型,夹在给定范围的两个其他值之间。也就是说,给定一个下限 min 和一个上限 max,如果 x 小于 min,x.clamp(min, max) 返回 min,如果 x 大于 max,返回 max,否则返回 x。
Result::transpose 和其对应的 Option::transpose 反转了嵌套 Result 和 Option 的类型。也就是说,将一个 Result<Option<T>, E> 转置成一个 Option<Result<T, E>>,反之亦然。当与 ? 结合时,这个操作可以在易变的环境中使用 Iterator::next 和类似的方法时使代码更简洁。
Vec::swap_remove 是 Vec::remove 的孪生兄弟。Vec::remove 保留了向量的顺序,这意味着要移除中间的一个元素,它必须将向量中所有后面的元素向下移动一个。这对大的向量来说可能非常慢。另一方面,Vec::swap_remove 将要删除的元素与最后一个元素交换,然后将向量的长度截断一个,这是一个常时操作。不过要注意的是,它将会对你的向量进行洗牌,从而使旧的索引失效!
野外的模式(Patterns in the Wild)
当你开始探索不是你自己的代码库时,你很可能会遇到一些常见的 Rust 模式,而我们在书中到目前为止还没有讨论过。了解它们将使你在遇到它们时更容易识别它们,从而理解它们的目的。甚至有一天,你可能会在自己的代码库中发现它们的用处。
索引指针(Index Pointers)
索引指针允许你在一个数据结构中存储对数据的多个引用,而不会触犯借用检查器。例如,如果你想存储一个数据集合,以便能以多种方式有效地访问它,例如通过保留一个以一个字段为键的 HashMap 和一个以另一个字段为键的 HashMap,你不想把底层数据也存储多次。你可以使用 Arc 或 Rc,但它们使用的动态引用计数会带来不必要的开销,而且额外的记账需要你为每个条目存储额外的字节。你可以使用引用,但由于数据和引用生活在同一个数据结构中(这是一个自引用的数据结构,我们在第 8 章中讨论过),所以生存期变得难以管理。你可以使用原始指针与 Pin 相结合,以确保指针保持有效,但这引入了很多复杂性和不安全因素,你需要仔细考虑。
大多数 crate 使用索引指针--或者,我喜欢叫它们不确定(indeferences)--来代替。这个想法很简单:将每个数据条目存储在一些可索引的数据结构中,比如 Vec,然后只将索引存储在一个派生数据结构中。然后执行一个操作,首先使用派生数据结构来有效地找到数据索引,然后使用索引来检索引用的数据。不需要生存期--如果你愿意,你甚至可以在派生数据表示中设置周期!
indexmap crate 提供了一个 HashMap 实现,其中迭代顺序与地图插入顺序相匹配,它提供了这种模式的一个很好的例子。该实现必须将键存储在两个地方,既要存储在键到值的映射中,又要存储在所有键的列表中,但是它显然不想在键类型本身很大的情况下保留两个副本。所以,它使用了索引指针。具体来说,它把所有的键/值对保存在一个 Vec 中,然后保存一个从键哈希到 Vec 索引的映射。要遍历映射中的所有元素,它只需遍历 Vec。为了查找一个给定的键,它对该键进行散列,在映射中查找该散列,从而得到键在 Vec 中的索引(索引指针),然后使用该索引从 Vec 中获取键的值。
实现图数据结构和算法的 petgraph crate 也使用这种模式。该工具箱存储了一个所有节点值的 Vec 和另一个所有边缘值的 Vec,然后只使用这些 Vec 的索引来引用一个节点或边缘。因此,例如,与一条边相关的两个节点被简单地作为两个 u32 存储在该边中,而不是作为引用或引用计数的值。
诀窍在于你如何支持删除。要删除一个数据条目,你首先需要在所有的派生数据结构中搜索它的索引并删除相应的条目,然后你需要从根数据存储中删除数据。如果根数据存储是一个 Vec,删除该条目也会改变其他一个数据条目的索引(当使用 swap_remove 时),所以你需要去更新所有的派生数据结构,以反映被移动的条目的新索引。
析构警卫(Drop Guards)
Drop guards 提供了一种简单而可靠的方法来确保一段代码即使在出现恐慌的情况下也能运行,这在不安全的代码中往往是必不可少的。一个例子是一个函数,它接收了一个闭包 f: FnOnce,并使用原子学在互斥下执行它。假设该函数使用 compare_exchange(在第 10 章中讨论过)将一个布尔值从 false 设置为 true,调用 f,然后将布尔值设置为 false 以结束互斥。但是考虑到如果 f 惊慌失措会发生什么--函数将永远无法运行它的清理工作,而且没有其他调用能够再次进入互斥部分。
使用 catch_unwind 可以解决这个问题,但是 drop guards 提供了一个替代方案,通常更符合人体工程学。清单 13-3 显示了在我们当前的例子中,我们如何使用一个回避器来确保布尔值总是被重置。
#![allow(unused)] fn main() { fn mutex(lock: &AtomicBool, f: impl FnOnce()) { // .. while lock.compare_exchange(false, true).is_err() .. struct DropGuard<'a>(&'a AtomicBool); impl Drop for DropGuard<'_> { fn drop(&mut self) { lock.store(true, Ordering::Release); } } let _guard = DropGuard(lock); f(); } // 清单 13-3:使用析构警卫确保代码在解锁恐慌后被运行 }
我们引入了实现 Drop 的本地类型 DropGuard,并将清理代码放在其实现的 Drop::drop 中。任何必要的状态都可以通过 DropGuard 的字段传递进来。然后,我们在调用可能发生恐慌的函数之前,构造一个守护类型的实例,这里是 f。当 f 返回时,不管是由于恐慌还是因为它的正常返回,卫兵被丢弃,它的析构器运行,锁被释放,一切都很好。
重要的是,守护被分配给一个变量,在用户提供的代码被执行后,该变量会在作用域的末端被放弃。这意味着,即使我们不再引用该防护的变量,也需要给它一个名字,因为 let _ = DropGuard(lock) 会在用户提供的代码运行之前立即放弃该防护。
注意:与 catch_unwind 一样,drop guards 只在恐慌解除时起作用。 如果代码在编译时带有
panic=abort,那么在恐慌发生后没有代码可以运行。
这种模式经常与线程局部结合使用,当库代码可能希望设置线程局部状态,使其仅在闭包的执行期间有效,因此需要在事后清除掉。例如,在写这篇文章时,Tokio 使用这种模式来提供关于调用 Future::poll 的执行者的信息给像 TcpStream 这样的叶子资源,而不必通过用户可见的函数签名来传播这些信息。如果在 Future::poll 因恐慌而返回后,线程的本地状态仍然显示某个特定的执行器线程是活跃的,那就不好了,所以 Tokio 使用一个 drop guard 来确保线程的本地状态被重置。
注意:你经常会看到
Cell或Rc<RefCell>被用于线程局部。这是因为线程局部只能通过共享引用来访问,因为一个线程可能会再次访问它已经在调用栈中更高位置引用的线程局部。这两种类型都提供了内部可变性,而不会产生太多的开销,因为它们只用于单线程的使用,所以是这种使用情况的理想选择。
扩展 Traits(Extension Traits)
扩展特质允许 crate 为实现不同 crate 特质的类型提供额外的功能。例如,itertools crate 为 Iterator 提供了一个扩展特质,它为常见的(或不太常见的)迭代器操作增加了一些方便的快捷方式。另一个例子是,tower 提供了 ServiceExt,它为 tower-service 特质中的低级接口增加了几个符合人体工程学的操作。
当你不控制基本特性时,扩展特性往往是有用的,比如 Iterator,或者当基本特性生活在自己的 crate 中时,这样它就很少看到破坏性的发布,从而不会造成不必要的生态系统分裂,比如 Service。
一个扩展特质扩展了它的基础特质(trait ServiceExt: Service),并且只由提供的方法组成。它还为任何实现了基属性的 T 提供了一个覆盖实现(implit<T> ServiceExt for T where T: Service {})。这些条件共同确保了扩展特质的方法在任何实现了基础特质的事物上都可用。
crate 预导入(Crate Preludes)
在第 12 章中,我们谈到了标准库的 prelude,它使一些类型和特性自动可用,而不需要你写任何 use 语句。沿着类似的思路,那些输出你经常一起使用的多种类型、特征或函数的板块,有时会以一个叫做 prelude 的模块的形式定义他们自己的前奏,它重新输出这些类型、特征和函数中某些特别常见的子集。这个模块的名字没有什么神奇之处,它也不会被自动使用,但它是一个信号,告诉用户他们很可能要在想使用这个模块的文件中加入 use somecrate::prelude::*。* 是一个 glob 导入,告诉 Rust 使用指定模块中所有公开可用的项目。当 crate 有很多项目时,这可以节省大量的输入工作,你通常需要为这些项目命名。
注意:通过
*使用的项目比通过名字明确使用的项目有较低的优先权。这就是允许你在自己的板块中定义与标准库前奏中的项目重叠的项目,而不需要指定使用哪一个。
preludes 对于暴露出大量扩展特性的板块来说也很好,因为特性方法只有在定义它们的特性处于范围内时才能被调用。例如,diesel crate,它提供了对关系数据库的人性化访问,大量使用了扩展特性,所以你可以写这样的代码。
#![allow(unused)] fn main() { posts.filter(published.eq(true)).limit(5).load::<Post>(&connection) }
只有当所有正确的特性都在范围内时,这一行才会起作用,而这是由 prelude 来处理的。
一般来说,当你在代码中加入 glob 导入时,你应该小心,因为它们有可能把对指定模块的添加变成向后不兼容的改变。例如,如果有人给你的 glob 导入的模块添加了一个新的特性,而这个新的特性使一个已经有其他 foo 方法的类型上的方法 foo 可用,那么在这个类型上调用 foo 的代码将不再被编译,因为现在对 foo 的调用是模糊的。有趣的是,虽然 glob 导入的存在使得任何模块的增加在技术上都是一种破坏性的改变,但 Rust 关于 API 进化的 RFC(RFC 1105;见 https://rust-lang.github.io/rfcs/1105-apievolution.html)并不要求一个库为这种改变发布一个新的主要版本。RFC 对原因做了很详细的说明,我推荐你去读一读,但主要内容是允许次要版本要求对依赖者进行最小限度的侵入性修改,比如在边缘情况下必须添加类型注释,因为否则很大一部分修改都需要新的主要版本,尽管它们实际上不太可能破坏任何消费者。
特别是在 preludes 的情况下,在自动售货(vending) crate 推荐的情况下,使用 glob 进口通常是没有问题的,因为它的维护者知道他们的用户会对 preludes 模块使用 glob 进口,因此在决定一个变化是否需要一个主要的版本升级时,会考虑到这一点。
保持更新
Rust,作为一种年轻的语言,正在迅速发展。语言本身、标准库、工具和更广泛的生态系统都还处于起步阶段,而且每天都有新的发展。虽然保持对所有变化的关注是不可行的,但值得你花时间去关注重大的发展,这样你就可以在你的项目中利用最新和最伟大的功能。
对于监测 Rust 本身的改进,包括新的语言特性、标准库的增加和核心工具的升级,https://blog.rust-lang.org/ 的 Rust 官方博客是一个很好的、数量不多的地方,可以开始。它主要是关于每个新的 Rust 版本的公告。我建议你养成阅读这些内容的习惯,因为它们往往包括一些有趣的花絮,会慢慢但肯定地加深你对该语言的了解。为了更深入地挖掘,我强烈建议你阅读 Rust 和 Cargo 的详细更新日志(链接通常可以在每个版本公告的底部找到)。更新日志中的变化并没有大到需要在发布说明中写上一段话,但这可能正是你两周后所需要的。如果想了解一个不那么频繁的新闻来源,可以到 https://doc.rust-lang.org/edition-guide/《版本指南》上看看,它概述了每个 Rust 版本中的新内容。Rust 版本往往每三年发布一次。
注意:Clippy 经常能够告诉你什么时候可以利用一种新的语言或标准库功能--总是启用 Clippy!
如果你对 Rust 本身的开发过程感到好奇,你可能也想订阅 Inside Rust 博客,网址是 https://blog.rustlang.org/inside-rust/。它包括来自各个 Rust 团队的更新,以及事件报告、较大的修改建议、版本规划信息等等。要想自己参与到 Rust 的开发中来--我非常鼓励这样做,因为这很有趣,也是一个很好的学习经验--你可以在 https://www.rust-lang.org/governance/,查看各种 Rust 工作组,它们各自专注于改进 Rust 的一个特定方面。找到一个对你有吸引力的工作组,在它开会的地方签到并询问你如何能够提供帮助。你也可以在 https://internals.rustlang.org/,加入关于 Rust 内部的社区讨论;这是另一个深入了解 Rust 设计和开发的每一部分的好方法。
正如大多数编程语言一样,Rust 的大部分价值来自于它的社区。Rust 社区的成员不仅不断地开发新的省力工具箱,发现新的 Rust 专用技术和设计模式,而且他们还集体地、持续地帮助彼此理解、记录和解释如何最好地利用 Rust 语言。我在本书中所涉及的一切,以及更多的内容,都已经被社区在成千上万的评论线程、博客文章、Twitter 和 Discord 对话中讨论过。即使只是偶尔接触一下这些讨论,也几乎可以保证让你对某个语言特性、某个技术或某个板块有新的认识。
Rust 社区有很多地方,但有一些好地方可以开始,如用户论坛(https://users.rust-lang.org/)、Rust subreddit(https://www.reddit.com/r/rust/)、Rust Community Discord(https://discord.gg/rust-lang-community)和 Rust Twitter 账户(https://twitter.com/rustlang)。你不需要参与所有这些,也不需要一直参与,只要选择一个你喜欢的氛围,并偶尔检查一下就可以了!
本周 Rust 博客 (https://this-week-inrust.org/) 是保持最新发展的一个很好的单一位置,它是 "[Rust's] 进展和社区的每周总结"。它链接到官方公告和更新日志,以及流行的社区讨论和资源,有趣的新板块,贡献的机会,即将到来的 Rust 活动,以及 Rust 工作机会。它甚至还列出了有趣的语言 RFC 和编译器 PR,所以这个网站真的是应有尽有。辨别哪些信息对你有价值,哪些没有价值可能有点令人生畏,但即使只是滚动浏览,偶尔点击一些看起来有趣的链接,也是保持新的 Rust 知识源源不断地进入你的大脑的一个好方法。
注意:想查询某项功能何时稳定落地? 我可以使用。.....(
https://caniuse.rs/)为您提供服务。
下一步是什么?(What Next?)
那么,你已经从头到尾读了这本书,吸收了它所传授的所有知识,并且仍然渴望得到更多?很好!有许多其他优秀的资源可以拓宽和加深你的知识和理解。有许多其他优秀的资源可以拓宽和加深你对 Rust 的认识和理解,在这最后一节,我将给你一个我最喜欢的调查,以便你可以继续学习。我根据不同人的学习方式将它们分为几个小节,这样你就可以找到适合你的资源了。
注意:自学的一个挑战,特别是在开始的时候,是很难察觉到进展。即使是最简单的东西,当你不得不不断地参考文档和其他资源,寻求帮助,或调试以了解 Rust 的某些方面是如何工作的时候,实现也会花费大量的时间。所有这些非编码工作会让你看起来像在踩水,而不是真正的进步。但是你在学习,这本身就是一种进步--只是更难注意和欣赏。
通过观察学习(Learn by Watching)
观察有经验的开发者的代码,本质上是一种生活黑客,可以弥补独自学习的缓慢起步阶段。它允许你观察设计和构建的过程,同时利用别人的经验。听有经验的开发者阐述他们的想法,并在他们出现的时候解释棘手的概念或技术,这是比你自己挣扎着解决问题的一个很好的选择。你还会学到各种辅助知识,如调试技术、设计模式和最佳实践。最终,你将不得不坐下来自己做事情--这是检查你是否真正理解你所观察到的东西的唯一方法--但借鉴别人的经验几乎肯定会使早期阶段更加愉快。如果这种经验是互动的,那就更好了。
因此,既然如此,这里有一些我推荐的 Rust 视频频道:
也许不出意料,我自己的频道:https://www.youtube.com/c/JonGjengset/。我有长篇的编码视频和短篇的基于代码的理论/概念解释视频,以及偶尔的视频,深入到有趣的 Rust 编码故事。
The Awesome Rust Streaming listing: https://github.com/jamesmunns/awesome-rust-streaming/. 这个资源列出了各种流传 Rust 编码或其他 Rust 内容的开发者。
Tim McNamara 的频道,《Rust in Action》: https://www.youtube.com/c/timClicks/ 的作者。蒂姆的频道和我的一样,在实施和理论之间分配时间,尽管蒂姆对创造性的视觉项目有特殊的诀窍,这使得观看很有趣。
Jonathan Turner 的 Systems with JT 频道:https://www.youtube.com/c/SystemswithJT/。乔纳森的视频记录了他们在 Nushell 上的工作,他们对 "新型 shell "的看法,提供了对在一个非琐碎的现有代码库上工作的伟大感觉。
Ryan Levick 的频道:https://www.youtube.com/c/RyanLevicksVideos/。Ryan 主要发布解决特定 Rust 概念的视频,并通过具体的代码实例进行讲解,但他偶尔也会做一些实现视频(比如用于微软飞行模拟器的 FFI!),以及深入研究著名的 crates 在引擎盖下是如何工作的。
鉴于我制作了 Rust 视频,我是这种教学方法的粉丝,这应该不足为奇。但这种接受式或互动式的学习不一定非要以视频的形式出现。 另一个向有经验的开发者学习的好途径是结对编程。如果你有一个在 Rust 某个方面有专长的同事或朋友,你想学习,问问你是否可以和他们进行结对编程,一起解决一个问题!
边做边学(Learn by Doing)
由于你的最终目标是更好地编写 Rust,所以编程经验是无可替代的。无论你从什么地方或多少资源中学习,你都需要把学习的东西付诸实践。然而,找到一个好的起点可能是很棘手的,所以在这里我将给出一些建议。
在我深入了解这个名单之前,我想就如何挑选项目提供一些一般性的指导。首先,选择一个你关心的项目,而不要太担心别人是否关心它。 虽然有很多流行的、成熟的 Rust 项目很希望你能成为贡献者,而且能够说 "我为著名的库 X 做出了贡献 "也很有趣,但你的首要任务必须是自己的兴趣。如果没有具体的动机,你会很快失去动力,发现贡献是一件苦差事。最好的目标是你自己使用的项目和遇到问题的项目--去修复它们吧!没有什么比摆脱一个长期存在的个人困扰,同时也为社区做出贡献更令人满意的了。
好吧,那么回到项目建议。首先,也是最重要的,考虑为 Rust 编译器及其相关工具做出贡献。这是一个高质量的代码库,有良好的文档和无穷无尽的问题(你自己可能也知道一些),而且有几个伟大的导师可以提供如何解决问题的大纲。如果你在问题追踪器中寻找标有 E-easy 或 E-mentor 的问题,你可能会很快找到一个好的候选人。随着你获得更多的经验,你可以不断提高水平,为更棘手的部分作出贡献。
如果这不是你的那杯茶,我建议找一些你经常使用的、用另一种语言写的东西,并把它移植到 Rust 中去--不一定是为了取代原来的库或工具,只是因为这种经验可以让你专注于写 Rust,而不必花太多时间自己想出所有功能。如果结果是好的,它已经存在的事实表明,其他人也需要它,所以你的移植可能也有更多的观众数据结构和命令行工具往往是很好的移植对象,但要找到一个吸引你的利基。
如果你是那种更喜欢 "从头开始" 的人,我建议回顾一下你迄今为止的开发经验,想想你在多个项目中最终写的类似代码(无论是用 Rust 还是用其他语言)。这样的重复往往是一个很好的信号,说明某些东西是可以重用的,可以变成一个库。如果你没有想到什么,David Tolnay 在 https://github.com/dtolnay/requestfor-implementation/,维护了一个其他 Rust 开发者要求的较小的实用工具箱的列表,这可能会提供一个灵感来源。如果你想找一些更实质性的、更有野心的东西,在 https://github.com/notyet-awesome-rust/not-yet-awesome-rust/,还有一个 Not Yet Awesome 列表,列出了应该存在于 Rust 中但还没有的东西。
通过阅读学习(Learn by Reading)
虽然情况在不断改善,但要找到超过初级水平的好的 Rust 阅读材料仍然很棘手。 这里收集了一些我最喜欢的资源的指针,这些资源不断教给我新的东西,或者在我有特别小的或细微的问题时作为良好的参考。
首先,我建议翻阅从 https://www.rust-lang.org/learn/ 链接的官方虚拟 Rust 书籍。有些书,比如《Cargo》,更像是参考书,而另一些,比如《Embedded》,更像是指南,但它们都是关于各自主题的坚实技术信息的深度来源。尤其是 Rustonomicon(https://doc.rust-lang.org/nomicon/),在你编写不安全代码时是一个救星。
还有两本值得一看的书是《Rustc 开发指南》(https://rustc-dev-guide.rust-lang.org/)和《标准库开发者指南》(https://std-dev-guide.rustlang.org/)。如果你对 Rust 编译器是如何工作的或者标准库是如何设计的感到好奇,或者在你尝试为 Rust 本身做贡献之前想要一些指导,这些都是非常好的资源。官方的 Rust 指南也是一个信息宝库;我已经在书中提到了 Rust API 指南(https://rust-lang.github.io/api-guidelines/),但也有一份 Rust 不安全代码指南参考(https://rust-lang.github.io/unsafe-code-guidelines/),当你读到这本书时,可能还有更多。
注意:
https://www.rustlang.org/learn/列出的资源之一是 Rust Reference,它基本上是 Rust 语言的完整规范。虽然它的部分内容相当枯燥,比如用于解析的确切语法或关于原始类型的内存表示的基础知识,但它的部分内容却很吸引人,比如关于类型布局和被认为是未定义行为的枚举的部分。
还有一些非官方的虚拟 Rust 书籍,是非常有价值的经验和知识集合。例如,The Little Book of Rust Macros (https://veykril.github.io/tlborm/),如果你想编写非实质性的声明性宏,它是不可或缺的;The Rust Performance Book (https://nnethercote.github.io/perf-book/) 充满了在微观和宏观层面上提高 Rust 代码性能的技巧和窍门。其他伟大的资源包括 Rust Fuzz Book(https://rust-fuzz.github.io/book/),它更详细地探讨了模糊测试,以及 Rust Cookbook(https://rust-langnursery.github.io/rust-cookbook/),它为常见的编程任务提供了习惯性的解决方案。甚至还有一个寻找更多书籍的资源,The Little Book of Rust Books(https://lborb.github.io/book/unofficial.html)!
如果你喜欢更多的实践性阅读,Tokio 项目已经发布了 mini-redis(https://github.com/tokio-rs/mini-redis/),这是一个不完整的,但却很容易理解的 Redis 客户端和服务器的实现,它的文档非常好,而且是专门写来作为编写异步代码的指南。如果你更喜欢数据结构,Learn Rust with Entirely Too Many Linked Lists (https://rust-unofficial.github.io/too-many-lists/) 是一本富有启发性和趣味性的读物,其中涉及到很多关于所有权和引用的棘手细节。如果你正在寻找更接近硬件的东西,Philipp Oppermann 的 Writing an OS in Rust(https://os.phil-opp.com/)会非常详细地介绍整个操作系统栈,同时在这个过程中教你一些好的 Rust 模式。如果你想了解更多以对话方式撰写的有趣的深入研究,我也强烈推荐 Amos 的文章集(https://fasterthanli.me/tags/rust/)。
当你对自己的 Rust 能力更有信心,并且需要快速参考而不是长篇大论的教程时,我发现 Rust Language Cheat Sheet(https://cheats.rs/)非常适合快速查找东西。它还为大多数主题提供了非常好的视觉解释,所以即使你在查找你不熟悉的东西,解释也是非常平易近人的。
最后,如果你想测试一下你对 Rust 的理解,可以去试试 David Tolnay 的 Rust 测验(https://dtolnay.github.io/rust-quiz/)。这里面有一些真正的头脑风暴,但每一个问题都有一个详尽的解释,所以即使你错了一个,你也会从这个经验中得到启发!"。
在教学中学习(Learn by Teaching)
我的经验是,到目前为止,要想把一件事学好、学透,最好的办法就是试着把它教给别人。我从写这本书中学到了很多东西,而且每次我制作新的 Rust 视频或播客节目时都会学到新东西。因此,我全心全意地建议你尝试向他人传授你从阅读本书中学到的或你从这里学到的一些东西。它可以采取你喜欢的任何形式:当面、写博文、发推特、制作视频或播客,或发表演讲。重要的是,你要尝试用自己的语言将你新发现的知识传达给那些还不了解这个主题的人--这样做,你也是对社会的一种回馈,这样,下一个出现的你就会更容易上手了。教书是一种谦卑和深刻的教育经历,我不能强烈推荐它。
注意:无论你是想教还是想被教,一定要访问 Awesome Rust Mentors(
https://rustbeginners.github.io/awesome-rust-mentors/)。
总结
在这一章中,我们已经涵盖了 Rust 的内容,超出了你本地工作区的范围。我们调查了有用的工具、库和 Rust 特性;研究了如何随着生态系统的不断发展而保持更新;然后讨论了如何让你的手变脏并为生态系统做出贡献。最后,我们讨论了在本书已经结束的情况下,你可以去哪里继续你的 Rust 之旅。就这样,除了宣布之外,没有什么可做的了。