当前位置:   article > 正文

unsafe rust_rust unsafe

rust unsafe

摘要:Rust的所有权类型系统对如何访问和共享内存位置实施了严格的约束。这个原则允许编译器静态地防止内存错误、数据竞争、通过混迭产生的无意副作用以及其他在传统命令式程序中经常发生的错误。然而,Rust的类型系统所施加的限制使得实现某些设计变得困难或不可能,例如需要别名的数据结构(例如双链表和共享缓存)。为了解决这个限制,Rust允许将代码块声明为不安全的,从而免除类型系统的某些限制,例如,操作c风格的原始指针。确保不安全代码的安全性是程序员的责任。然而,Rust语言的一个重要假设(我们称之为Rust假设)是,程序员使用Rust遵循三个主要原则:谨慎使用不安全代码,使其易于审查,并将其隐藏在安全抽象之后,以便客户端代码可以用安全的Rust编写。

了解Rust程序员如何使用不安全代码,特别是Rust假设是否成立,对于Rust开发人员和测试人员、语言和库设计人员以及工具开发人员都是至关重要的。本文通过分析大量Rust项目的语料库,实证研究了不安全代码在实践中的使用情况,以评估Rust假设的有效性,并对不安全代码的目的进行分类。我们通过自动检查程序的源代码,它的中间表示MIR,以及Rust编译器提供的类型信息来识别可以回答的查询;我们通过手工代码检查来补充结果。我们的研究部分支持Rust假设:虽然大多数不安全代码都很简单且封装良好,但不安全特性被广泛使用,特别是在与其他语言的互操作性方面。

1.介绍
Rust是一种系统编程语言,它的类型系统在编译时防止了许多常见的错误。在其他属性中,类型良好的Rust程序保证不会出现null指针解引用、从未初始化的内存中读取、悬空指针、数据竞争、内存泄漏以及通过别名产生的无意的副作用。这些强有力的保证是通过控制读取和修改内存位置的能力的所有权类型系统实现的。

在Rust中,每个内存位置都由一个变量拥有;当所属变量超出作用域时,内存将被释放。访问该位置的专属能力始于其所有者,但可以永久转让(连同所有权,通过移动分配)或临时转让(通过借用)。例如,常用的习惯用法是让函数从调用者那里借用参数,并在调用结束时恢复功能。除了独占(可变)借用之外,Rust还允许通过(借来的)共享引用来共享内存位置。为了防止数据竞争和无意的副作用,只要共享引用存在,可访问的内存位置就被强制为不可变的。为了确定何时(可变的或共享的)借用超出了作用域并恢复了功能,Rust将每个借用的引用与生命周期关联起来,并跟踪它们之间的约束。

所有权和借用规则确保,在执行的每个点上,内存位置要么由一个函数执行独占访问,要么不可变地共享。这个原则允许编译器消除许多其他普遍存在的内存错误。然而,这是有代价的:例如,Rust的所有权系统导致了树形的链接数据结构。其他可变数据结构,如双链表和图,如果不超出这个严格的规则,就无法表示。此外,实现可能会受到仅为了遵从Rust编译器而选择的次优数据表示的影响。例如,引用计数智能指针更灵活,但不遵循Rust的所有权系统。

为了解决这些限制,Rust提供了一个逃生舱口:不安全Rust支持编写不需要遵守所有Rust默认规则的代码。通过利用不安全的Rust,可以实现循环数据结构、硬件抽象层和无锁算法等功能,这些功能在纯安全的Rust中很难甚至不可能实现。然而,这种增加的表现力是有代价的。编译器不能强制执行上述保证;这种强制变成了开发人员的责任,即使对于不安全的Rust的一小部分,也需要大量的认知工作。例如,在Rust的不安全代码指南工作组[2020]的讨论中,可以找到承担这一责任所涉及的微妙之处的例子。重要的是,正如Jung[2016]所指出的那样,不安全代码的正确性可能依赖于不变量,这些不变量可能被修改相同结构字段的所有函数无效;因此,对不安全代码块是否可接受进行彻底的代码审查并不局限于块本身的代码,而必须至少包括所有可能修改这些字段的代码。

Rust提供了两种主要形式的不安全代码,它们有不同的用途。第一种形式允许程序员绕过编译器检查。它的核心特性是一个不安全块,它定义了这些检查被禁用的范围。例如,在图1a中,需要一个不安全的块来解引用c风格的原始指针p。默认情况下,假设这样一个不安全的块包含在,比如说,一个结构方法中,应该以一种从函数调用者封装的方式使用不安全的特征;这是函数的责任,而不是客户端代码的责任,这段代码将始终安全地执行。或者,可以显式声明不安全函数(用不安全关键字注释的函数)。该特性旨在表明函数体中不安全代码正确性的责任至少部分在于其调用者。图1b显示了这样一个不安全函数foo的语法,它只能从不安全块中调用。与任何不安全块一样,bar函数体中的调用结合bar不是不安全函数的事实表明,bar的实现者打算将不安全Rust的这种用法安全地封装在bar的调用者中:开发人员承诺foo(17)保留Rust的安全保证,即使编译器不能强制执行它们。

不安全Rust的第二种主要形式涉及Rust特征,这与Java接口类似。不安全的trait声明不是禁用编译器检查,而是作为一个文档特性:它警告开发人员,trait的所有实现都应该满足一些额外的语义属性,比如在编译时和运行时都不检查的前提条件、后置条件或不变量;此外,使用这些特性的客户端代码的安全性可能依赖于这些属性。对于trait实现,不安全充当了注释的角色,开发人员通过它承认他们有责任尊重所需的语义属性。我们将上述意义上的不安全用法视为文档特性,因为编译器不会检查上述属性。然而,在这些情况下使用不安全是不可选的。遵守所有文档属性对于维护Rust的安全保证至关重要:允许调用不安全trait函数的客户端依赖这些属性来证明自己代码的安全性。相反,对于未声明为不安全的特性,它的所有客户端必须确保它偏离其最初预期目的的程度traitÐregardless的每个可能的安全实现的安全性。

必须小心使用不安全代码,以保留Rust的强保证。通常提倡的做法是,程序员应该根据以下三个基本原则尽可能地使用不安全的Rust,这些原则旨在限制代码审查的必要范围(参见Rust Team [2019b], Klabnik和Nichols[2019,第19章],以及Rust社区的重要来源,例如Fuchsia Team [2020];Jung et al. [2020];Matsakis [2016];Rust团队[2019a]):

(1)应该谨慎使用不安全代码,以便最大程度地从安全Rust提供的固有保证中受益。
(2)不安全代码块应该是直接的和自包含的,以尽量减少开发人员必须保证的代码量,例如通过人工审查。
(3)不安全代码应该被很好地封装在安全抽象后面,例如,通过提供不向客户端暴露不安全Rust用法的库(通过公共不安全函数)。

理想情况下,这些原则是通过将不安全代码封装在经过仔细审查和测试的库中来实现的,这些库的客户端可以用安全的Rust编写,而不需要意识到不安全代码的存在。部分Rust语言文档[Klabnik和Nichols 2019;Rust Team 2019b]声称程序员可以根据这三个基本原则来使用不安全的代码,我们称之为Rust假设。

了解Rust程序员如何使用不安全代码,特别是Rust假设是否成立,对于Rust语言的用户来说是至关重要的。它允许项目经理判断他们可以在多大程度上依赖Rust消除某些错误的承诺,开发人员遵循(不断发展的)最佳实践,测试人员确定对代码库的哪些部分检查哪些属性,库设计人员确定可以安全封装的不安全代码的进一步习惯用法,语言设计人员为常用的不安全习惯用法设计安全解决方案。以及工具构建器,以支持不安全代码的常见习惯用法以及它们与安全代码的交互。

本文对不安全代码在实践中的应用进行了实证研究。它通过分析大量Rust项目的语料库来评估Rust假设的有效性,并对不安全代码的目的进行分类,从而大大超越了现有的研究。为了回答这些问题,我们通过自动检查程序的源代码、中间表示MIR以及Rust编译器提供的类型信息来确定可以回答的查询。例如,为了评估使用不安全代码实现自定义并发原语的频率,我们收集了与并发相关的编译器本质的信息,比如对compare和swap的调用。为了更深入地理解不安全代码的语义和意图,我们通过手动代码检查来补充这些自动收集的数据。

我们的结果部分支持Rust假说。大多数不安全代码都很简单,并且被很好地封装在安全抽象后面。然而,不安全代码被广泛使用,特别是在与其他编程语言进行互操作时。互操作性是使用不安全代码的最普遍动机,其次是需要复杂共享(通过原始指针或可变全局数据)的数据结构实现。其他目的,比如使用不安全的并发特性和应用不安全来记录对于维护Rust的安全保证至关重要的语义属性(上面提到的第二种形式的不安全代码),就不太常见了。

本文的贡献如下:
使用不安全代码的动机分类。我们确定了不安全代码的六个主要用途。这种分类作为我们经验研究的基础,但是对于不安全代码的系统文档和裁剪技术(例如针对不安全代码的特定用例的测试用例生成和程序分析)也很有用。
•关于不安全代码在实践中如何使用的实证研究。我们的研究表明,与互操作性无关的代码通常遵循Rust假设。
•讨论Rust代码推理的含义(在代码审查或验证期间)。
•我们可重用的开源基础设施和分析数据可在线获取[Qrates Team 2020]。

2.不安全代码的用法
Rust程序应该主要使用安全代码,以受益于Rust的安全保证。尽管如此,在某些情况下,不安全代码是必要的,或者至少是首选的解决方案。在本节中,我们将讨论六个这样的用例。理解它们以及它们在现实世界代码中的流行是我们研究的中心目标。

2.1 克服别名限制
Rust的类型系统对别名进行了严格的控制,以确保程序永远不会出现内存错误。它区分可变引用(对于每个内存地址必须是唯一的)和共享引用(但只读)。虽然类型系统对于大多数任务来说是足够宽松的,但是存在一些重要的场景,其中安全Rust的别名限制过于严格。我们的第一组用例由三个这样的场景组成。

具有复杂共享的数据结构。在Rust中,所有基于可变引用的链接数据结构本质上都是树形的,因为类型系统防止任何两个可变引用指向相同的位置。即使对于共享引用,编译器也不允许构造循环引用结构,因为这可能导致在回收过程中出现悬空引用。实现树以外的拓扑,如双链表、带父指针的树、dag或多图,因此需要开发人员绕过类型系统,例如通过使用原始指针,其解引用仅在不安全的Rust中被允许[Cameron et al. 2019, Ch. 11]。

其他数据结构主要依赖于共享引用,但允许通过其他不可变引用进行特定的更改。例如,基于引用计数的智能指针和一些缓存机制都依赖于通过多个共享引用之一更改值的能力[Rust Team 2020c;Libra协会[2020]。

Rust对共享引用的不变性要求提供了一个例外:UnsafeCell的内容可以通过共享引用发生变化。然而,这种内部可变性需要不安全的代码,因为UnsafeCell本质上公开了指向其内容的原始指针。标准库定义了各种方便的UnsafeCell包装器,如RefCell和Mutex,正如Jung等人[2018]所证明的那样,它们提供了安全的抽象。

除了使用原始指针或依赖标准库提供的现有抽象之外,实现复杂数据结构的第三种选择是使用整数表示向量的索引,而不是引用。换句话说,可以通过实现自定义矢量支持堆来回避Rust的默认别名结果。这种方法允许某种形式的混叠,两个向量条目可以存储相同的索引,同时保持在安全Rust的边界内。然而,它也消除了Rust对引用的许多强有力的保证。例如,编译器不能检测到索引是无效的,因为它在底层向量的边界之外;这样的程序反而会在运行时出现恐慌。

请注意,在所有权类型系统的一般文献中存在可以处理某些形式的可变混联的方法,并支持各种标准数据结构的实现,包括循环列表(例如[Clarke et al. 1998;Clebsch et al. 2015;戈登2014;穆勒2002;Potanin et al. 2013])。将这些替代系统中的一些想法整合到Rust的未来版本中,可能会提供另一种安全实现复杂数据结构的方法(尽管使它们与Rust现有的自动内存管理解决方案兼容似乎非常具有挑战性)。

不完备的问题。尽管Rust的类型系统在不断改进,但它必然是不完整的;一些有效的程序将被编译器拒绝。例如,考虑下面的程序,它将一个slice(集合中连续的元素序列)在某个指定的索引中分成两个:

这个程序被拒绝,因为编译器没有区分不同的切片元素;因此,它没有认识到分配给返回引用的功能不重叠。为了实现这个函数,标准库使用了不安全的代码:使用原始指针分割切片。

2.2强调契约和不变量

如第1节所述,不安全有两种不同的形式:前一小节中讨论的用例属于第一种形式;他们使用不安全的代码来绕过安全Rust的限制。这里我们关注第二种形式,其中不安全作为文档特性:开发人员可以将不安全附加到函数和特征上,以表明它们的实现依赖于编译器无法建立的某些契约或不变量。然后编译器强制不安全函数只能从不安全块(或其他不安全函数)中调用,并且不安全特性的实现也被标记为不安全。换句话说,通过使用不安全,开发人员承认他们有责任解释所有文档化的需求。

标准库中常见的习惯用法是使用不安全来强调函数的未检查前提条件,这些前提条件需要由调用者建立。例如,考虑下面的函数签名,它可以在Rust模块std::slice [Rust Team 2020d]中找到。

这个函数接受一个指针data和一个整数len作为参数。它的文档要求数据引用长度至少为len的片,这样内存中连续T值的数量才能被安全地读取。当编译器检测到不安全的函数在不安全块外被非法调用时,它会建议程序员łconsult documentation*,从而鼓励使用上述习惯用法。

类似地,标准库使用不安全特征来突出显示其他契约,如后置条件。例如,要求trait GlobalAlloc定义的函数(在trait的所有实现中)永远不会出现panic。另一个有趣的例子是不安全的trait TrustedLen,它的文档要求函数size_hint的结果返回当前活动的迭代器尚未返回的元素的精确数量,而不是近似值。

在TrustedLen的情况下,不安全的正确用法仍然是有争议的:Rustonomicon指出,有一些合理的情况可以将一个特征声明为不安全,但也指出,这在传统上是避免的[Rust Team 2019b, Ch. 1.1]。我们研究的目的之一是更好地理解开发人员是否以及如何为文档目的使用不安全的函数和特征(这方面将在第3.2节中与不安全Rust的其他用例一起解决)。

2.3 访问较低抽象层
Rust被设计成一种高效的系统编程语言。因此,它需要支持与在较低抽象层次上运行的环境进行交互,例如硬件、操作系统、设备驱动程序和用其他语言编写的库。虽然Rust在提供安全的高级抽象方面走了很长一段路,但这些环境中的许多最终都不在编译器的控制范围之内;它们必然是不安全的。为了覆盖这些情况,编译器暴露了不安全的函数,这些函数通常可以访问更快或更有表现力的低级操作。

2.3.1外部函数。Rust提供了extern关键字来定义(不安全的)绑定到用外国语言(通常是C语言)编写并通过共享库提供的函数。例如,下面的代码片段显示了如何从Z3库绑定并随后调用C函数Z3_mk_solver。这个函数使用一个Z3_context对象,它不是线程安全的。通过从不安全块调用该函数,开发人员保证他们正确地阻止了对Z3_context对象的任何可能的并发使用。Rust还支持通过asm!不安全块中的宏;对于任何其他不安全的块,程序员有责任确保内联汇编的行为不违反Rust的保证。

通过编译器内在实现并发性。虽然Rust在其原子类型模块中为线程之间的共享内存通信提供了安全的原语[Rust Team 2020e],但Rust编译器还提供了反映LLVM低级本质的并发性固有函数[LLVM Team 2020]。所有这些内在特性都被标记为不安全,因为它们的不正确使用可能导致内存错误或数据竞争。例如,atomic_xadd函数执行原子加法;结果存储在由作为参数传递的原始指针指定的目的地。如果该指针未对齐,则不能保证类型安全。需要实现自己的并发原语的项目需要低级并发本质。下面的代码片段来自Redox OS项目[Redox developers 2019],例如,依赖于内部的atomic_xadd来增加一个信号量计数器:

的性能。对于高性能应用程序,开发人员可能偶尔希望绕过Rust的内置安全检查以获得加速。主要的例子包括在访问数组元素时避免执行绑定检查,并向优化器提供提示,例如,如果没有验证实际情况,代码是不可访问的。此外,还可以修改数据结构的自定义内存布局以加快类型转换。下面的例子是Gjengset[2020]应用了这样的优化来快速地将字节数组(用Rust的内置切片类型表示)强制转换为Rust结构,而不执行任何安全检查:

本节提供的用例是基于从各种库中收集的轶事证据和示例。在本文的其余部分中,我们将系统地研究在真实的Rust代码中如何使用不安全代码。

3.方法
回想一下,我们学习的主要目标是深入了解不安全Rust在实践中是如何使用的。为此,我们希望回答以下高级别问题:

Rust假说成立吗?
程序员使用不安全代码最常见的用例是什么?

在本节中,我们首先将上述问题提炼为五个研究问题(rq),以指导我们更好地理解不安全Rust。之后,我们将这些问题分解成更具体的查询,从而可以推断出原始问题的答案。我们的目标是完全自动地回答每个特定的问题;我们的方法的细节将在第4节中介绍。这些查询收集的结果将在第5节中讨论。

3.1 Rust假设——开发者是否按照预期使用了不安全的Rust ?
正如第1节所介绍的,使用不安全Rust有三个被广泛提倡的基本原则:
(1)应节约使用不安全代码。
(2)不安全代码应该是直接和独立的。
(3)不安全代码应该被很好地封装在安全抽象后面。

Rust的假设是,开发人员通常能够并且确实遵循上述原则。为了检查常规Rust代码是否支持这一假设,我们通过专门的研究问题调查了每个原则。我们首先探讨不安全代码的普遍程度:

rq1(频率)。不安全代码在Rust crate中显式出现的频率是多少?

Rust假设的第一个原则预测,与数据集的总体大小相比,不安全代码很少出现在我们的数据集中。为了验证这一说法,我们采取了两种方法:首先,我们识别数据集中不安全Rust的每次使用,并具体计算有多少crate(即二进制文件或库)包含任何不安全代码。也就是说,我们计算有多少个箱子包含至少一个不安全块、函数、trait定义或实现;所有其他板条箱只包含安全Rust。即使是一小段不安全的代码也需要开发人员进行大量的认知工作:他们需要意识到自己有责任保证安全,而不是依赖于Rust编译器。确定哪些箱子是完全安全的,可以让我们衡量开发人员通过坚持使用安全的Rust来避免这种负担的频率,正如我们在第一条原则中所期望的那样。

其次,仅仅基于crate是否包含任何不安全代码来评估频率可能会导致粗略的印象。为了弥补这一点,我们通过测量每个crate中不安全代码的相对数量来补充这些数据,即不安全块和不安全函数体的大小与crate总大小的比例。我们将讨论如何具体衡量代码的大小以及下面的第二个研究问题。
根据第二个原则,不安全代码应该是直接的主观概念。为了评估开发人员是否喜欢保持他们的不安全块简单,我们测量每个不安全块的大小作为其复杂性的代理(其中较小的大小意味着较低的复杂性):

rq2(大小)。程序员编写的不安全块的大小是多少?
要解决这个问题,需要一种合理的方法来量化不安全块的大小。一个明显的备选方案是计算每个不安全块中的Rust代码行数。然而,代码行数是块复杂性的弱指标,因为一些特性,如闭包和宏,可能通过几行代码实现相当复杂的行为。此外,不同的缩进和空白模式会无意中使这些测量产生偏差。

为了获得对不同冗长的编程风格更健壮的度量,我们转向Rust编译器的中间CFG表示(MIR),其中许多(可能复杂的)高级Rust结构已经被转换为MIR指令。因此,我们的下一个查询检查编译器为不安全块生成了多少MIR语句。我们还使用此查询来确定crate中不安全代码的总数,以补充上面的二进制查询。

rq3(自包含)。不安全代码的行为是否只依赖于它自己的crate中的代码?
符合Rust假设第二条原则的不安全代码应该很少接触到其他箱子,以便尽可能简单地手动检查不安全代码。特别是,依赖于来自其他crate的代码的功能行为的不安全代码可能由于其编译依赖项的更新而变得脆弱。

评估此原则的naïve查询将计算不安全块中有多少函数调用具有当前crate之外的调用目标(低数量则表示高度自包含)。然而,并不是所有的调用目标都是相同的:标准库箱,即std、core、alloc和proc_macro,在各种各样的项目中被大量使用。由于它们经过了彻底的审查,依赖这些库的行为是可靠的,尽管在技术上仍然违反了自包含性,但可以说问题较少。此外,一些crate旨在提供对用其他语言编写的库的低级访问。例如,所谓的ł-sysž crate(按照惯例,其名称以-sys结尾)反映了C库的接口[Rust Team 2019c]。这可以看作是关注点的分离,因为同一个C库的多个安全抽象可能是合理的:-sys提供不受约束的访问,其他crate可以在其上实现安全抽象。自然地,这样的设计会导致箱子之间的依赖关系,这是合理的,但与自我包容直接冲突。

我们分别考虑上述两类的呼叫目标,因为它们相当于预期和故意违反自制力。因此,我们使用一个精细的查询来计算不安全块中有多少函数调用的调用目标位于(1)它自己的crate中,(2)属于标准库的crate中,(3)-sys crate中,或(4)任何其他crate中。第(4)类的调用可能无意中违反了第二个原则。

注意,上面的查询需要对函数调用的目标有详细的了解。我们对标准函数调用求值,这些函数的调用目标总是可以在编译时仅从调用表达式确定。因为不安全代码应该尽可能的简单,以便于手工审查,我们期望不安全块只包含一些其他的函数调用,包括trait方法,闭包,或者函数指针,这需要代码审查者更多的手工工作,因为他们必须跟踪所有可能的实现。为了验证我们的期望,并作为另一个简单的代理,我们测量了不安全块和不安全函数中有多少函数调用是(a)标准函数调用,(b) trait方法调用,或©闭包或函数指针调用。

rq4(封装)。不安全代码通常通过安全抽象对客户端屏蔽吗?
Rust假设的第三个原则要求程序员通过安全抽象对客户端屏蔽不安全的代码。换句话说,客户端应该忽略在crate内部使用不安全代码的事实。例如,检查开发人员是否成功地构建了合适的抽象,对于自动分析来说是一项困难的任务,因为他们必须检查执行是否会出现数据竞争。因此,我们关注的是开发人员明显的设计意图。也就是说,他们是否试图尽可能地隐藏其他板条箱的不安全功能?

为了回答这个问题,我们来仔细看看Rust的可见性概念。广义地说,有三个主要概念:默认值是私有的,意味着只在当前模块中可见——一个用户定义的Rust项集合,比如函数、特征等。或者,一个道具可以只在当前的箱子或所有的箱子中可见。

然后我们计算有多少不安全函数(1)声明为私有的,(2)在它们的crate中可见,(3)对其他crate可见。查询(1)和(2)涵盖了符合第三个原则的所有不安全函数。查询(3)收集不兼容的情况,即暴露给其他crate的不安全函数。

3.2衡量不安全的世界——开发人员如何使用不安全的代码?
现在,我们将进一步了解开发人员依赖不安全代码的可能原因。回想一下第2节中我们对编写不安全代码的用例的分类,从克服强调契约和不变量的混叠限制到访问较低的抽象层。我们的目标是识别这些用例应用的代码,以回答以下问题:
rq5(动机)。不安全代码最普遍的用例是什么?
在下面的段落中,我们将介绍用于标识单个用例的特定查询。所有这些查询都搜索具有相关用例特征的语法模式。因此,他们收集特定用例的证据,而不是精确地捕获它们。我们有意不试图找到完美的特征,因为我们的查询结果应该是自动收集的,可能需要手动的后续工作。与大多数自动化程序分析一样,我们依赖于近似值。

具有复杂共享的数据结构。由于Rust的所有权规则阻止了复杂的共享,一些数据结构在安全的Rust中很难甚至不可能实现。尽管Rust社区已经发展出一些模式,比如内部可变性模式,以处理所有权系统的限制,但它们最终都依赖于使用原始指针。因此,我们将原始指针解引用视为程序员意图绕过所有权系统以允许复杂共享的指示符。为了识别这个用例,我们收集所有包含原始指针解引用的函数。我们在函数级别探索指针解引用,而不是研究单个的不安全块,以获得安全函数(需要使用不安全块)和不安全函数(不需要使用不安全块)的统一结果。此外,一些开发人员提倡使用许多最小的不安全块,而另一些开发人员则倾向于使用更少更大的不安全块。通过在函数级别对查询进行措辞,我们可以使查询与这些不同的样式保持不可知。

上面的查询可能会高估开发人员依赖不安全代码来实现复杂数据结构的频率,因为原始指针也用于与C库进行互操作。为了获得更保守的估计,我们过滤掉了原始指针的用法,因为我们可以识别不同的意图:我们不计算出现在带有属性的结构体中的原始指针,比如#[repr©],表明它们用于互操作性。

不完备的问题。通常不可能精确地识别开发人员围绕Rust类型和所有权系统的不完整性问题进行工作的所有情况。因此,我们将重点放在不安全函数上,Rust文档将克服编译器的限制作为用例列出:我们收集所有涉及显式类型强制转换的不安全函数的调用。例如,难以置信的不安全函数transmute和它的近亲transmute_copy都将值的位重新解释为另一种类型,并且Rust文档建议解决生命周期的限制,例如延长生命周期或缩短不变生命周期[Rust Team 2020b]。

强调契约和不变量。回想一下,Rust中的不安全关键字在附加到函数或特征时也可以作为文档特性。要了解开发人员是否使用不安全来记录契约和不变量,我们运行两个查询:首先,我们搜索不安全的函数,其主体只包含安全的Rust代码。由于没有技术上的原因需要将这些函数声明为不安全,因此我们期望任何此类函数被声明为不安全,或者是偶然的,或者是为了记录一些对维护安全性至关重要的隐式契约或不变量(例如,同一模块中的不安全代码)。其次,我们计算已声明的安全和不安全特性的数量。对于特性来说,不安全总是一个文档特性。基于Rust标准库中不安全特性的数量很少(总共只有11个),我们希望只找到几个不安全的特性声明。

通过编译器内在实现并发性。Rust编译器通过专用的不安全函数提供对低级并发本质的访问。为了测量开发人员依赖它们的频率,我们在std::intrinsics模块中收集了一个不安全函数的列表,这些函数包装了并发性本质。然后运行一个查询,收集调用所收集函数之一的所有不安全块。外国的功能。Qin等人[2020]提到了与其他语言的互操作性,例如访问C/ c++库,这是编写不安全代码的常见用例。为了识别可能与外部代码互操作的代码,我们在查询中利用以下观察结果:

属性#[repr©]确保类型的内存布局与C编程语言可互操作;因此,依赖此类类型的不安全代码很可能与外部代码交换数据。因此,我们计算有多少类型配备了#[repr©]。
•包装C系统库的crate的名称按惯例以后缀-sys结尾。因此,我们将所有的-sys crate归类为属于这个用例。
•Rust允许使用自定义应用程序二进制接口(ABI)在外部声明函数,这样具有指定ABI的外部代码可以调用它们。因此,我们确定使用外部ABI声明了多少不安全函数。
•最后,通过asm!查找内联汇编的用法。宏。对使用内联汇编的动机进行更仔细的分析,例如低级优化或与硬件设备交互,需要进行人工检查。

的性能。我们考虑两种使用不安全代码来提高性能的标准优化:首先,我们搜索未检查的函数(名称中含有łuncheckedž的函数)。按照惯例,这些函数牺牲运行时检查以获得更好的性能;因此,它们是不安全的。例如,标准库遵循这种命名约定,并且经常提供相同功能的安全(已检查)和不安全(未检查)变体。
其次,确定不安全块是否包含特殊的联合类型MaybeUninit。Rust文档将此类型描述为可选类型的高度不安全变体,避免在运行时进行任何安全检查(参见[Rust Team 2018])。实际上,它可能会重新引入悬空引用,因为开发人员可能会使用它在不安全块中定义未初始化的引用。

5.实验

在本节中,我们将展示通过查询自动收集的结果,并辅以一些手动检查,并提供第3节中研究问题的答案。我们首先讨论我们的数据集,然后是实验设置,最后是每个研究问题的结果。
我们在第6节中详细讨论了我们的发现。

5.1数据集和实验设置
我们在一个数据集上评估了我们的查询,该数据集包含了发布在中央Rust存储库crate .io上的所有34445个包的最新版本(截至2020-01-14)。一个包的实现可以由多个crate组成,其中一个通常是主要的,它决定了包的名称。我们排除了5,459个软件包(15.8%),它们的最新版本没有成功编译。对于具有条件编译功能的包,我们使用清单中指定的默认标志。如果一个包使用默认标志编译失败,但使用不同的标志编译成功(当作为另一个包的依赖编译时),我们选择随机构建进行分析。因此,我们的数据集由31867个箱子组成。大多数这些crate被编译成Rust库(76.0%)或二进制文件(20.0%)。剩下的箱子是程序宏(4.0%)。

我们的实验是在一台配备英特尔至强E5-4627处理器(3.30GHz, 16核),252gb RAM,运行Ubuntu 16.04.6作为操作系统,Rust编译器版本为night-2020-02-03的计算机上进行的。由于我们的实验不依赖于时间或性能,因此在不同的硬件上重现我们的结果应该很简单。我们将所有结果收集在Jupyter笔记本中,以便使用Python进行后续分析[Jupyter Team 2020];这些也可以在网上找到[Qrates Team 2020]。

5.2 Rust假设——开发人员是否按照预期使用不安全的Rust ?
我们首先回答与Rust假设相关的研究问题,即声称开发人员通常会(1)谨慎地使用不安全Rust,并且其行为是(2)直接且自包含的,以及(3)被很好地封装在安全抽象后面。

5.2.1 RQ 1(频率):不安全代码在Rust crate中显式出现的频率是多少?表1显示了包含不安全代码的箱子的绝对数量和相对数量,以及它们使用的不安全特性(我们的第一个查询),而图2显示了(1)所有箱子和(2)至少包含一个不安全语句的箱子(我们的第二个查询)中不安全块和函数中的语句的相对数量。大多数板条箱(76.4%)根本不包含不安全特性。即使在大多数包含不安全块或函数的crate中,也只有一小部分代码是不安全的:对于92.3%的crate,不安全语句的比例最多为10%,也就是说,高达10%的代码库由不安全块和不安全函数组成。然而,由于21.3%的crate中包含一些不安全语句,而这些crate中有24.6%的crate中不安全语句的比例至少为20%,我们不能说开发人员很少使用不安全的Rust,也就是说,他们并不总是遵循Rust假设的第一原则。

然而,如果我们将我们的结果与Evans等人[2020]的结果进行比较,我们可以看到他们在实验中报告的所有特征中不安全板条箱的百分比更高。他们的实验基于中央Rust存储库crate .io的16个月前的快照(从2018年9月开始)。与此同时,超过10,000个板条箱被添加到仓库中,特别是不安全板条箱的比例从29%下降到23.6%。这一发现与Evans等人[2020]的发现不同,Evans等人观察到,在下载最多的箱子中,不安全代码的数量在10个月内略有增加。对这些观察结果的一种可能解释是,下载最多的crate提供了对语言或标准库的必要扩展(例如,一个有效的随机数生成器),这些扩展无法在安全代码中实现,因此,最流行的crate中的不安全数量不会改变,而新添加的crate中有很大一部分是不需要使用不安全的应用程序代码。对这些观察结果的另一种可能的解释是,它们可能反映了Rust社区内部为减少不安全代码的总体使用而做出的一致努力,例如Rust团队安全工作组的łRust Safety dancekv项目[2019a]。

从表1中,我们还可以看到使用最多的不安全特性是不安全块和不安全函数声明。不安全的trait声明和不安全的trait实现都很少见,前者在所有crate中不到1%;考虑到实现通常具有有趣的契约和不变量,这个低数字表明程序员发现通过不安全关键字突出显示这些并不有用。

5.2.2 rq2 (Size):程序员编写的不安全块的大小是多少?回想3.1节,我们测量编译器为不安全块(简称为#MIR)生成的MIR语句的数量,作为其代码复杂性的代理。图3显示了为每个不安全块生成的MIR语句的累积分布,在100 #MIR处裁剪以提高可读性;所描绘的图形涵盖了97.4%的所有不安全块。大多数区块的大小相当小:75%的不安全区块最多包含21 #MIR,这几乎与22.0 #MIR的平均值一致。为了比较,编译器已经生成了12条MIR语句,比图4所示的小不安全块的总中位数10条要多。经过更仔细的人工检查,有很大一部分(即14.4%)小的不安全块要么包装一个表达式(没有函数调用),要么调用单个不安全函数,其参数在不安全块之前计算。相反,有一小部分(78或0.02%)巨大的异常值,其大小范围从2000到21,306 #MIR。这些不安全的块大多是自动生成的,例如通过用户编写的宏或外部脚本。

总之,不安全块的大小通常很小。假设MIR语句的数量足够接近不安全块的复杂性,我们得出结论,大多数开发人员保持他们的不安全块简单,这支持Rust假设的第二个原则。

5.2.3 rq3(自包含):不安全代码的行为是否仅依赖于其自身crate中的代码?在我们的数据集中,我们在不安全块中总共有772228个调用。如图5所示,其中超过四分之三是对标准函数的调用,而对trait方法的调用和对闭包和函数指针的调用分别只有18.0%和3.6%。如果我们与包括编译器生成的代码在内的整个数据集的分布(如图6所示)进行比较,我们可以看到,在不安全块中的调用在标准函数调用中所占的比例要大得多(在不安全块中占78.3%,而在整个数据集中占56.3%),相应地,对trait方法的调用比例要小得多(在不安全块中占18.0%,而在整个数据集中占42.9%)。尽管18.0%仍然是一个相当大的比例,但相对较低的数字证实了我们的期望,即开发人员避免在不安全的块中调用这些调用,以保持代码更简单和更独立。特别是,对不安全块中随机选择的100个trait方法调用的人工检查显示,在82种情况下,只需查看包含不安全块的函数,就可以静态地确定调用目标。因此,这些调用不会大大增加不安全代码的复杂性。

为了理解为什么在不安全块中调用闭包和函数指针的比例比在所有代码中都要大(3.6%对0.7%),我们手动查看了几个示例并观察到三种主要模式。第一种方法是使用闭包将不安全代码的行为参数化,该闭包作为参数传递给安全包装器。这种模式的一个典型例子是基本类型slice上的sort_by函数,它接受比较函数作为参数。第二种模式是使用函数指针从动态加载的库中调用函数(这只能在不安全的块中完成),第三种模式是使用函数指针实现对系统库的回调。

除了不同形式的调用之外,我们还分析了标准调用的调用目标是在哪里实现的,以评估不安全代码的自包含程度。图7显示了标准函数调用目标的分布,分为四类。大多数(52.1%)的函数调用都是在标准库中调用的;如第3.1节所述,我们认为这样的函数调用只是对自包含性的轻微违反。大多数剩余的调用(25.9%)留在同一个板条箱中。只有7.4%的呼叫目标位于其他箱子中。我们手动检查了这些crate中的一些,发现它们中的大多数,类似于-sys crate,封装了系统库。总之,对于纯用Rust编写的代码库,很少有调用实际上违反了Rust假设的自包含原则。

我们还分析了当我们只考虑对不安全函数的调用时,调用目标的分布是如何变化的(正如我们将在第5.2.5节中看到的,这是使用不安全块的最常见动机)。如图8所示,对-sys crate的调用份额明显更高,而留在同一crate中的调用份额几乎保持不变。这表明开发人员不愿意调用不安全的函数,除非他们明确地希望与系统库进行交互。

5.2.4 rq4(封装):不安全的代码通常通过安全抽象对客户端屏蔽吗?
在表2中,我们根据不安全函数的可见性对其进行分类,这些函数可能是私有的(只能从该子模块调用),在受限制的模块中可见,或者是公共的。我们使用可见性作为程序员意图从客户端代码封装不安全实现的指示。我们的度量基于函数声明中的信息,并且不区分使用公共修饰符来启用来自同一crate中的其他子模块的调用,还是完全来自不同的crate。对于后者,还需要在crate的根模块中声明函数是可见的,这是一个单独的决定。请注意,一旦函数被声明为public,它的调用地点通常是未知的,并且可能随着时间的推移而改变。我们从这个分析中删除了所有不安全的trait方法(只有687种,占所有不安全函数的0.1%),因为它们的可见性是隐式的。

我们观察到,只有12.0%的不安全函数对任意代码不可见(比如,在其他箱子中),因为它们要么是私有的,要么被限制在一个模块中。绝大多数(88.0%)不安全函数被声明为公共函数。乍一看,这表明程序员很少对客户端保护他们的不安全代码。为了调查这一点,我们还研究了公共不安全函数与每个包含至少一个不安全函数的crate中所有不安全函数的比例。也就是说,接近1的比率表明crate封装了不安全的函数(因为所有这些函数都是公共的)。相反,接近0的比率表示强封装,因为几乎所有不安全函数都不是公开可见的。结果如图9所示。基于这个指标,我们得到了一个更清晰的画面:大多数箱子(78.5%)要么将所有不安全函数声明为公共,要么没有。特别是,34.7%的板条箱似乎封装得很好:它们声明了不安全的函数,但从外部看不到它们。

此外,43.8%的板条箱公开其所有不安全功能;更准确地说,这些箱子包含274,434个(49.2%)不安全函数。继续我们的调查,我们查询了这些箱子中有多少公共的不安全函数提供了到系统库的原始绑定,因为通常的做法是将这些绑定设置为公共的。首先,我们检查了函数的abi。我们发现163,650(59.6%)具有外部项ABI,这意味着它们是外部项的绑定(很可能是C函数)。我们还发现571个函数(0.2%)具有C ABI,这意味着它们可以从C代码中调用,因此将它们设为公共是有意义的。剩下的绝大多数函数(110,212或40.2%)都有Rust ABI,因此很难自动判断它们是否是绑定。因此,我们检查了包含这些函数的crate的元信息,发现9,642(3.5%)被分配到类别,表明它们是包装系统库的crate, 49,363(18.0%)被分配到与嵌入式编程相关的类别。最后,我们从剩下的列表中手动检查了30个具有最不安全功能的箱子(总共41,063个函数,占15.0%),发现它们要么为微控制器提供api,要么为OpenGL绑定提供api。经过分析,我们只剩下10,148个函数(3.7%)是公共的,它们可能不是来自提供绑定的crate。

综上所述,尽管大量提供绑定的crate让我们很难得出明确的结论,但Rust程序员似乎至少试图不向客户暴露不安全函数,因为我们发现34.7%的使用不安全函数的crate没有声明一个公共函数;相反,声明大量公共不安全函数的crate通常可以归因于不打算封装的情况。

5.2.5 RQ 5(动机):不安全代码最普遍的用例是什么?为了回答这个问题,我们首先确定了编译器要求将不安全块和函数声明为不安全的一系列独立原因。我们从Rust编译器的源代码中提取了这些原因[Rust Team 2020a];因此,它们是完整的。然后,我们收集了适用于每个函数实现的原因(要么是不安全函数体,要么是安全函数内部的不安全块)。结果总结在表3中。如数据所示,对不安全函数的调用是不安全代码不安全的主要原因,其次是对原始指针的解引用。

由于多种原因,块或函数可能不安全。我们发现,在所有至少有一个不安全原因的函数中,83.5%的函数调用不安全的函数是不安全的唯一原因。在93.6%的函数中,不安全的原因仅仅是表的前2项,在99.4%的函数中,所有不安全的原因都是表的前3项。例如,这些数据将使Rust静态分析器和验证工具的开发人员能够优先考虑不安全Rust代码的哪些特性应该在他们的工具中得到支持。

表3中的分类说明了为什么编译器要求将块或函数声明为不安全的,但没有解释为什么程序员选择使用这些不安全的特性。为了理解他们的动机,我们研究了3.2节中提出的特定用例的流行情况,我们将在下面讨论。

具有复杂共享的数据结构。为了评估使用不安全代码实现带有可变别名的数据结构的频率,我们测量了有多少函数解引用原始指针,以及有多少结构体具有原始指针字段。我们的数据库包含7,385,690个函数定义,其中只有46,263(0.6%)在其实现中解引用原始指针。特别是,在总共557,380个不安全函数中有9,273个(1.7%),在6,221,053个安全函数中有35,761个(0.6%),在607,257个闭包声明中有1,229个(0.2%)。总的来说,7.0%的板条箱有不安全的代码,至少取消了一个原始指针的引用。关于原始指针字段,我们发现6.6%的板条箱具有带有原始指针字段的类型。在过滤掉结构(其属性表明它们可能用于互操作性)中的原始指针后,这个数字减少到所有crate的4.6%。

考虑到对树形数据结构的限制似乎是安全Rust的主要限制,原始指针解引用的数量相当大,但相当低。似乎原始指针很少用于实现更复杂的数据结构。一种可能的解释是,共享尤其发生在标准数据结构中,例如循环列表、双链表、智能指针和带有父指针的树。然而,这样的数据结构是由标准库提供的,因此在应用程序代码中不经常以自定义实现的形式出现。另一种可能的解释是,Rust程序员选择在安全的Rust中实现不需要可变共享的设计,而不是诉诸于对原始指针的不安全操作。最后,开发人员可以绕过所有权系统,同时保持在安全的Rust中,依靠自定义向量支持堆和使用约束较少的整数索引而不是引用。

即使不一定与数据结构相关,使用可变静态变量(表3中第三个最常见的不安全原因)也是一种共享形式,因为全局数据可以被多个函数访问和修改,可以通过别名原始指针来实现。

不完备的问题。在安全的Rust中,类型系统能够防止,例如,使用那些目标可能已经在前面的某个条件分支中被释放的引用。这些检查是不完整的静态分析的一种形式,因为它们会保守地拒绝一些其他有效的程序。由于不完备的情况不能被精确地自动识别,我们转而度量对涉及显式类型强制转换(transmute和copy_transmute)的不安全函数的调用,以此作为代理来评估程序员需要处理类型检查器的不完备问题的频率。我们发现,在319,600个不安全区块中,有28,469个(8.9%)调用了变形函数,而所有箱子中有4.5%至少包含一次对变形函数的调用。有趣的是,只有1.7%的板条箱有超过3个不安全块并调用这些函数。这证实了我们的期望,转换函数的调用,包括编译器不完整的变通方法,是罕见的,并且当crate必须进行这些调用时,他们会谨慎地使用它们。然而,仍然存在一些异常值,它们发出成千上万的转化召唤。经过手工检查,我们发现这些板条箱包含由脚本或递归宏生成的代码,这解释了异常。

强调契约和不变量。为了检查不安全作为文档特性使用的普遍程度,我们的查询收集了具有安全实现的不安全特征和不安全函数的数据。

我们发现了1093个不安全的trait声明,仅占所有trait声明的2.5%。我们得出结论,开发人员很少使用不安全特征,可能是因为(1)编译器从未强迫他们这样做,(2)没有使用不安全特征的决定性指导方针。相反,Rust文档似乎不鼓励开发人员频繁地将特征声明为不安全[Rust Team 2019b, Ch. 1.1]。值得注意的是,我们观察到一些开发人员热情地接受了不安全特性:五个crate负责所有不安全特性声明的40.4%。

关于不安全函数,我们的实验表明36.1%的不安全函数是用完全安全的Rust编写的。我们发现这个数字高得惊人。毕竟,与其他不安全的特性相比,编译器不会强迫开发人员将这些函数声明为不安全的。相反,程序员必须有意地键入一个额外的关键字。因此,乍一看,似乎开发人员经常使用不安全函数的原因与不安全特性相同:记录属性,例如对于维护Rust的安全保证至关重要的不变量。

为了找到使用安全实现的大量不安全函数的解释,我们执行了手动检查:我们手动检查了具有完全安全主体的最高不安全函数总数的10个箱子。所有这些板条箱都是自动生成的,以提供对各种微控制器的外围访问。所涉及的代码生成似乎是保守的,并且经常使用不安全的函数,即使它没有必要。此外,我们还随机抽取了一些带有安全体的不安全函数进行人工检测。在这些函数中,有一些配备了明确记录的不变量。其他函数被标记为不安全似乎主要是由于遗留原因。因此,这些额外的检查表明,大多数函数几乎是无意中被声明为不安全的,而不是故意强调契约和不变量。因此,不安全特征和不安全函数之间的差异似乎比最初的数字所显示的要小得多。

总的来说,没有明确的证据表明不安全函数经常用于记录契约和不变量,除非这些契约与在函数实现中使用不安全Rust特性的情况重叠(并且可能防止这种情况)。

通过编译器内在实现并发性。为了衡量编译器内在性在多大程度上被用于实现细粒度并发性,我们收集了调用core::intrinsics模块中定义的89个编译器并发性内在性中的一个的所有不安全块,或者它们从std::intrinsics重新导出的所有不安全块。这些函数在我们的数据集中只被4个crate使用:core(8次调用)、compiler_builtins(7次调用)、rs_lockfree(6次调用)和hsa(1次调用)。因此,我们得出结论,编译器的内在特性没有被广泛使用,可能是因为它们仍然被标记为实验性的,并且需要编译器的夜间版本。

有趣的是,在分析结果时,我们发现compiler_builtins crate公开的并发性本质没有被错误地标记为不安全,即使它们在内部解引用作为参数传递的原始指针。因此,安全代码可以通过调用这些内在函数从安全Rust代码中解引用空指针。我们在API中报告了这种不健全,这得到了库开发人员的证实[编译器内置开发人员2020]。

外国的功能。为了检测与其他语言的互操作性,我们首先测量了有多少类型配备了#[repr©],以获得与C结构兼容的内存布局。在1,486,978个结构和枚举定义中,我们发现只有3.9%的定义带有#[repr©]注释。这个注释在6.2%的板条箱中使用。

作为第二个查询,我们收集了名称以-sys结尾的所有crate,以找到那些遵循-sys命名约定为C系统库提供公共绑定的crate。我们发现有650个箱子(占所有箱子的2.0%)的名字以-sys结尾,但我们也注意到其他箱子使用不同的命名约定:24个箱子的名字以-ffi结尾,13个箱子以-bindings结尾,10个箱子以-bindgen结尾。这些后缀都清楚地标记了一个提供公共绑定到C库的crate,就像-sys crate应该做的那样。通过进一步手工检查流行的crate,我们还发现了各种各样的crate,如libc、gl和winapi,它们提供了到系统库的绑定,而不使用任何命名约定。如此多的案例表明-sys约定是已知的,但库开发人员并没有一致地应用它。

作为第三个查询,我们测量了使用外部ABI声明了多少不安全函数,以检测到系统库函数的绑定。我们发现,在557,380个不安全函数定义中,有248,522个(44.6%)实际上是对外部项的静态绑定。这个很大的百分比不包括为动态加载的库提供绑定的函数,这表明与外部函数的互操作性实际上是不安全代码的一种非常常见的模式。

总的来说,1,599个crate(占所有crate的5.0%)至少包含一个带有外部ABI的函数。这加强了一个假设,即-sys命名约定本身不足以完全检测包装系统库的crate。

最后,作为第四个查询,我们测量了内联汇编的使用情况。在700多万个函数定义中,我们只发现了493个使用汇编的函数。特别是,我们发现10个低级和硬件相关的箱子实际上包含了使用内联汇编的所有函数的69.8%。这强烈表明内联汇编通常很少使用。

的性能。关于我们预期使用不安全代码来提高性能,我们发现不安全块中5.9%的不安全调用涉及未检查的函数,这些函数分布在4.3%的crate中。避免运行时检查似乎并不是所有开发人员都经常使用的。然而,它在一些性能导向型箱子中扮演着重要的角色。例如,X窗口系统的Rust绑定在单个crate中调用4,852个未检查的函数。

开发人员很少使用联合MaybeUninit,它允许声明未初始化的变量:我们只在1816个不安全块中检测到它,它们出现在0.55%的crate中。

总之,使用不安全Rust的性能优化似乎是一个小众问题:它们主要集中在几个箱子中。然而,在这些板条箱中,它们被大量使用。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/代码探险家/article/detail/909609
推荐阅读
相关标签
  

闽ICP备14008679号