Safe与Unsafe如何交互(How Safe and Unsafe Interact)

Safe Rust和Unsafe Rust之间是什么关系?它们如何交互?

Safe Rust和Unsafe Rust之间的分离由unsafe关键字控制,该关键字充当从一个到另一个的接口.这就是为什么我们可以说Safe Rust是一种安全的语言:所有不安全的部分都只保留在unsafe边界之后.如果你愿意,你甚至可以将#![forbid(unsafe_code)]折腾到你的代码库中,以静态保证你只编写Safe Rust.

unsafe关键字有两个用途:声明编译器无法检查的契约的存在,和声明程序员已检查这些契约已被维护.

你可以将unsafe用在 函数和traits声明(functions and trait declarations) 上来指示存在未经检查的契约.在函数上,unsafe意味着函数的用户必须检查该函数的文档,以确保它们以维护函数所需的契约的方式使用它.在trait声明上,unsafe意味着trait的实现者必须检查trait文档以确保它们的实现维持trait所需的契约.

你可以对块使用unsafe来声明在其中执行的所有不安全操作都经过验证,以维护这些操作的契约.例如,传递给slice::get_unchecked的索引在界限内(in-bounds).

你可以对trait实现使用unsafe来声明实现支持trait的契约.例如,实现Send的类型移动到另一个线程是真的安全的.

标准库有许多不安全的函数,包括:

  • slice::get_unchecked,执行未经检查的索引,允许自由地违反内存安全性.

  • mem::transmute将某些值重新解释为具有给定类型,以任意方式绕过类型安全(有关详细信息,请参阅转换).

  • 每个指向有大小类型的原始指针都有一个offset方法,如果传递的偏移量不在界限内”(in bounds)”,则产生未定义行为.

  • 所有FFI(外部函数接口)函数都是unsafe,因为另一种语言可以执行Rust编译器无法检查的任意操作.

从Rust 1.29.2开始,标准库定义了以下不安全traits(还有其他traits,但它们尚未稳定,其中一些可能永远不会稳定):

  • Send是一个标记trait(没有API的trait),它承诺实现者可以安全地发送(移动)到另一个线程.

  • Sync是一个标记trait,承诺线程可以通过共享引用安全地共享实现者.

  • GlobalAlloc允许自定义整个程序的内存分配器.

大多数Rust标准库也在内部使用Unsafe Rust.这些实现通常都经过严格的手动检查,因此可以假设构建在这些实现之上的Safe Rust接口是安全的.

所有这些分离的需要归结为Safe Rust的一个基本属性,可靠性属性(soundness property) :

无论如何,Safe Rust都不会导致未定义行为.

安全/不安全拆分的设计意味着Safe和Unsafe Rust之间存在不对称的信任关系.Safe Rust本身必须相信它所触及的任何Unsafe Rust都已正确编写.另一方面,Unsafe Rust在信任Safe Rust时必须非常小心.

例如,Rust具有PartialOrdOrdtrait来区分可以”仅(just)”进行比较的类型,以及提供”总(total)”排序的类型(这基本上意味着比较表现得相当合理).

BTreeMap对部分排序的类型没有意义,因此它需要它的键实现Ord.但是,BTreeMap在其实现中包含Unsafe Rust代码.因为对于导致未定义行为的草率Ord实现(写入Safe)是不可接受的,所以BTreeMap中的Unsafe代码必须编写得健壮,以对抗实际上不是全部(total)的Ord实现—尽管这是需要Ord的关键所在.

Unsafe Rust代码无法信任Safe Rust代码是正确编写的.也就是说,如果你输入没有总排序的值,BTreeMap仍然会完全不正常.它不会导致未定义行为.

有人可能会想,如果BTreeMap不能信任Ord,因为它是Safe,为什么它可以信任 任何(any) Safe代码?例如,BTreeMap依赖于整数和切片的正确实现.那些也很安全,对吧?

区别在于范围之一.当BTreeMap依赖于整数和切片时,它依赖于一个非常具体的实现.这是一个可以衡量风险,可以权衡利益.在这种情况下,基本上没有风险;如果整数和切片被破坏, 所有人(everyone) 都会被破坏.此外,它们由维护BTreeMap的人维护,因此很容易密切关注它们.

另一方面,BTreeMap的键类型是泛型.信任其Ord实现意味着信任过去,现在和未来的每个Ord实现.这里的风险很高:某个地方的某个人会犯错误并搞砸他们的Ord实现,甚至只是直接说谎提供总排序,因为”它似乎有效”.当发生这种情况时,BTreeMap需要准备好.

相同的逻辑适用于信任传递给你的闭包正常运行.

无界(unbounded)泛型信任的这个问题是unsafetraits存在要解决的问题.从理论上讲,BTreeMap类型可以要求键实现一个名为UnsafeOrd而不是Ord的新trait,它可能如下所示:

  1. use std::cmp::Ordering;
  2. unsafe trait UnsafeOrd {
  3. fn cmp(&self, other: &Self) -> Ordering;
  4. }

然后,类型将使用unsafe来实现UnsafeOrd,表明他们已经确保他们的实现维护了trait所期望的任何契约.在这种情况下,BTreeMap内部的Unsafe Rust在信任键类型的UnsafeOrd实现是正确的时候是合理的.如果不是,那就是不安全trait实现的错误,这与Rust的安全保证一致.

是否标记traitunsafe的决定是API设计选择.安全 trait 更容易实现,但任何依赖于它的不安全代码都必须防范不正确的行为。 将trait标记为unsafe会将这一责任转移到实现者身上。Rust传统上避免标记traitunsafe,因为它使得Unsafe Rust普遍存在,这是不可取的.

SendSync被标记为不安全,因为线程安全是一个 基本属性(fundamental property) ,不安全的代码不可能希望以它可以防御有bug的Ord实现的方式进行防御.类似地,GlobalAllocator会保留程序中所有内存的记录,以及在其上构建的BoxVec等其他东西. 如果它做了一些奇怪的事情(当它仍然在使用时将相同的内存块分配给另一个请求),那么就没有机会检测到它并对此采取任何措施.

是否标记自己的traitunsafe的决定取决于相同的考虑.如果unsafe代码不能合理地期望防止trait的破坏实现,那么标记traitunsafe是一个合理的选择.

顺便说一句,虽然SendSyncunsafetrait,但是当这些派生可以证明是安全的时候,它们 也(also) 会自动实现.对于仅由类型也实现Send的值组成的所有类型,将自动派生Send.对于仅由其类型也实现Sync的值组成的所有类型,将自动派生Sync.这最大限度地减少了使这两种traitsunsafe的普遍不安全性.而且没有多少人会 实现(implement) 内存分配器(或者直接使用它们).

这是Safe和Unsafe Rust之间的平衡.分离旨在使Safe Rust尽可能符合人体工程学,但在编写Unsafe Rust时需要额外的努力和小心.本书的其余部分主要讨论了必须采取的谨慎措施,以及Unsafe Rust必须遵守的契约.