不安全的 Rust

到目前为止,我们讨论过的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 内部隐藏着另一种语言,它不强制执行这些内存安全保证:它被称为*不安全的 Rust*,其工作原理与常规 Rust 相同,但赋予了我们额外的超能力。

不安全的 Rust 之所以存在,是因为从本质上讲,静态分析是保守的。当编译器试图确定代码是否遵守保证时,它宁愿拒绝一些有效的程序,也不愿接受一些无效的程序。尽管代码*可能*没问题,但如果 Rust 编译器没有足够的信息来确定,它就会拒绝代码。在这些情况下,您可以使用不安全的代码来告诉编译器,“相信我,我知道我在做什么”。但请注意,您使用不安全的 Rust 需要自担风险:如果您错误地使用不安全的代码,则可能会由于内存不安全(例如空指针解引用)而出现问题。

Rust 之所以有一个不安全的另一面,是因为底层计算机硬件本身就是不安全的。如果 Rust 不允许您执行不安全的操作,您就无法完成某些任务。Rust 需要允许您进行低级系统编程,例如直接与操作系统交互,甚至编写自己的操作系统。使用低级系统编程是该语言的目标之一。让我们探讨一下我们可以使用不安全的 Rust 做什么,以及如何做。

不安全的超能力

要切换到不安全的 Rust,请使用 unsafe 关键字,然后启动一个包含不安全代码的新块。您可以在不安全的 Rust 中执行五项操作,而这些操作在安全的 Rust 中是无法执行的,我们称之为*不安全的超能力*。这些超能力包括:

  • 解引用原始指针
  • 调用不安全的函数或方法
  • 访问或修改可变的静态变量
  • 实现不安全的 Trait
  • 访问 union 的字段

务必了解,unsafe 不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果您在不安全的代码中使用引用,它仍然会被检查。unsafe 关键字只会让您访问这五种功能,然后编译器不会检查这些功能的内存安全性。在 unsafe 块中,您仍然可以获得一定程度的安全性。

此外,unsafe 并不意味着块内的代码必然是危险的,也不意味着它一定会出现内存安全问题:其意图是,作为程序员,您将确保 unsafe 块内的代码以有效的方式访问内存。

人非圣贤,孰能无过,但通过要求这五种不安全的操作位于标有 unsafe 的块内,您就会知道,任何与内存安全相关的错误都必须出现在 unsafe 块内。保持 unsafe 块较小;当您以后调查内存错误时,您会为此感到庆幸。

为了尽可能地隔离不安全的代码,最好将不安全的代码封装在一个安全的抽象中,并提供一个安全的 API,我们将在本章后面讨论不安全的函数和方法时讨论这一点。标准库的某些部分是作为经过审计的不安全代码的安全抽象来实现的。将不安全的代码封装在一个安全的抽象中,可以防止 unsafe 的使用泄漏到您或您的用户可能想要使用使用 unsafe 代码实现的功能的所有地方,因为使用安全的抽象是安全的。

让我们依次看看这五种不安全的超能力。我们还将介绍一些为不安全的代码提供安全接口的抽象。

解引用原始指针

在第 4 章“悬空引用”一节中,我们提到编译器会确保引用始终有效。不安全的 Rust 有两种称为*原始指针*的新类型,它们类似于引用。与引用一样,原始指针可以是不可变的,也可以是可变的,分别写成 *const T*mut T。星号不是解引用运算符;它是类型名称的一部分。在原始指针的上下文中,*不可变*意味着指针在被解引用后不能直接赋值。

与引用和智能指针不同,原始指针

  • 允许通过同时拥有指向同一位置的不可变指针和可变指针或多个可变指针来忽略借用规则
  • 不保证指向有效的内存
  • 允许为空
  • 不实现任何自动清理

通过选择不使用 Rust 来强制执行这些保证,您可以放弃保证的安全性,以换取更高的性能或与 Rust 的保证不适用的其他语言或硬件交互的能力。

清单 19-1 显示了如何从引用创建不可变和可变的原始指针。

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;
}

清单 19-1:从引用创建原始指针

注意,我们没有在此代码中包含 unsafe 关键字。我们可以在安全代码中创建原始指针;我们只是不能在 unsafe 块之外解引用原始指针,您稍后会看到。

我们已经通过使用 as 将不可变和可变引用转换为其相应的原始指针类型来创建原始指针。因为我们直接从保证有效的引用创建它们,所以我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针都做出这样的假设。

为了证明这一点,接下来我们将创建一个我们无法确定其有效性的原始指针。清单 19-2 显示了如何创建指向内存中任意位置的原始指针。尝试使用任意内存是未定义的:该地址可能存在数据,也可能不存在数据,编译器可能会优化代码,因此不会进行内存访问,或者程序可能会因段错误而出错。通常,没有充分的理由编写这样的代码,但这是可能的。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

清单 19-2:创建指向任意内存地址的原始指针

回想一下,我们可以在安全代码中创建原始指针,但我们不能*解引用*原始指针并读取指向的数据。在清单 19-3 中,我们在需要 unsafe 块的原始指针上使用解引用运算符 *

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

清单 19-3:在 unsafe 块内解引用原始指针

创建指针不会造成任何危害;只有当我们尝试访问它指向的值时,我们才可能最终处理无效值。

还要注意,在清单 19-1 和 19-3 中,我们创建了 *const i32*mut i32 原始指针,它们都指向同一个内存位置,即存储 num 的位置。如果我们改为尝试创建对 num 的不可变和可变引用,则代码将无法编译,因为 Rust 的所有权规则不允许在存在任何不可变引用的同时存在可变引用。使用原始指针,我们可以为同一个位置创建可变指针和不可变指针,并通过可变指针更改数据,这可能会导致数据竞争。小心!

存在所有这些危险,为什么还要使用原始指针?一个主要的用例是在与 C 代码交互时,您将在下一节“调用不安全函数或方法”中看到。另一种情况是构建借用检查器无法理解的安全抽象。我们将介绍不安全函数,然后查看一个使用不安全代码的安全抽象示例。

调用不安全函数或方法

您可以在 unsafe 块中执行的第二种操作是调用不安全函数。不安全函数和方法看起来与常规函数和方法完全一样,但在定义的其余部分之前有一个额外的 unsafe。在这种情况下,unsafe 关键字表示该函数有一些我们在调用该函数时需要维护的要求,因为 Rust 无法保证我们已经满足了这些要求。通过在 unsafe 块中调用不安全函数,我们是在说我们已经阅读了该函数的文档,并负责维护该函数的约定。

这是一个名为 dangerous 的不安全函数,它在其主体中什么也不做

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

我们必须在一个单独的 unsafe 块中调用 dangerous 函数。如果我们尝试在没有 unsafe 块的情况下调用 dangerous,我们会收到一个错误

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

使用 unsafe 块,我们向 Rust 断言我们已经阅读了函数的文档,我们了解如何正确使用它,并且我们已经验证我们正在履行函数的约定。

不安全函数的主体实际上是 unsafe 块,因此要在不安全函数中执行其他不安全操作,我们不需要添加另一个 unsafe 块。

在不安全代码之上创建安全抽象

仅仅因为一个函数包含不安全的代码并不意味着我们需要将整个函数标记为不安全的。事实上,将不安全的代码包装在一个安全的函数中是一种常见的抽象。例如,让我们研究标准库中的 split_at_mut 函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全方法是在可变切片上定义的:它接受一个切片,并通过在作为参数给出的索引处拆分切片来将其分成两个切片。清单 19-4 显示了如何使用 split_at_mut

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

清单 19-4:使用安全的 split_at_mut 函数

我们不能仅使用安全的 Rust 来实现此函数。尝试可能类似于清单 19-5,它无法编译。为了简单起见,我们将 split_at_mut 实现为一个函数而不是一个方法,并且仅针对 i32 值的切片而不是针对泛型类型 T

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

清单 19-5:尝试仅使用安全的 Rust 实现 split_at_mut

此函数首先获取切片的总长度。然后,它通过检查作为参数给出的索引是否小于或等于长度来断言该索引在切片内。断言意味着,如果我们传递一个大于要拆分切片的长度的索引,则该函数将在尝试使用该索引之前 panic。

然后,我们在一个元组中返回两个可变切片:一个从原始切片的开头到 mid 索引,另一个从 mid 到切片的结尾。

当我们尝试编译清单 19-5 中的代码时,我们会收到一个错误。

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust 的借用检查器无法理解我们正在借用切片的不同部分;它只知道我们正在从同一个切片借用两次。借用切片的不同部分从根本上说是可以的,因为这两个切片没有重叠,但 Rust 不够聪明,无法知道这一点。当我们知道代码没问题,但 Rust 不知道时,就该使用不安全的代码了。

清单 19-6 显示了如何使用 unsafe 块、一个原始指针和一些对不安全函数的调用来使 split_at_mut 的实现工作。

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

清单 19-6:在 split_at_mut 函数的实现中使用不安全的代码

回想一下第 4 章“切片类型部分,切片是指向某些数据的指针和切片的长度。我们使用 len 方法获取切片的长度,并使用 as_mut_ptr 方法访问切片的原始指针。在这种情况下,因为我们有一个指向 i32 值的可变切片,所以 as_mut_ptr 返回一个类型为 *mut i32 的原始指针,我们将其存储在变量 ptr 中。

我们保留 mid 索引在切片内的断言。然后我们进入不安全的代码:slice::from_raw_parts_mut 函数接受一个原始指针和一个长度,并创建一个切片。我们使用此函数创建一个从 ptr 开始且长度为 mid 项的切片。然后,我们使用 mid 作为参数在 ptr 上调用 add 方法,以获取从 mid 开始的原始指针,并使用该指针和 mid 之后的剩余项数作为长度创建一个切片。

函数 slice::from_raw_parts_mut 是不安全的,因为它接受一个原始指针,并且必须相信该指针是有效的。原始指针上的 add 方法也是不安全的,因为它必须相信偏移位置也是一个有效的指针。因此,我们必须在对 slice::from_raw_parts_mutadd 的调用周围放置一个 unsafe 块,以便我们可以调用它们。通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以断定 unsafe 块内使用的所有原始指针都将是指向切片内数据的有效指针。这是对 unsafe 的可接受和适当的使用。

请注意,我们不需要将生成的 split_at_mut 函数标记为 unsafe,并且我们可以从安全的 Rust 代码中调用此函数。我们已经为不安全的代码创建了一个安全的抽象,该函数的实现以安全的方式使用 unsafe 代码,因为它只从该函数可以访问的数据创建有效的指针。

相反,在清单 19-7 中使用 slice::from_raw_parts_mut 可能会在使用切片时崩溃。此代码获取一个任意内存位置并创建一个长度为 10,000 项的切片。

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

清单 19-7:从任意内存位置创建切片

我们不拥有此任意位置的内存,并且不能保证此代码创建的切片包含有效的 i32 值。尝试像使用有效切片一样使用 values 会导致未定义的行为。

使用 extern 函数调用外部代码

有时,您的 Rust 代码可能需要与用另一种语言编写的代码进行交互。为此,Rust 提供了关键字 extern,它有助于创建和使用*外部函数接口 (FFI)*。FFI 是一种编程语言定义函数并使另一种(外部)编程语言能够调用这些函数的方式。

代码清单 19-8 演示了如何与 C 标准库中的 abs 函数进行集成。在 extern 块中声明的函数从 Rust 代码调用始终是不安全的。原因是其他语言不强制执行 Rust 的规则和保证,并且 Rust 无法检查它们,因此确保安全的责任落在程序员身上。

文件名:src/main.rs

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

代码清单 19-8:声明和调用以其他语言定义的 extern 函数

extern "C" 块中,我们列出了要调用的其他语言的外部函数的名称和签名。"C" 部分定义了外部函数使用的 *应用程序二进制接口 (ABI)*:ABI 定义了如何在汇编级别调用函数。"C" ABI 是最常见的,并且遵循 C 编程语言的 ABI。

从其他语言调用 Rust 函数

我们还可以使用 extern 创建一个接口,允许其他语言调用 Rust 函数。我们不是创建整个 extern 块,而是在相关函数的 fn 关键字之前添加 extern 关键字并指定要使用的 ABI。我们还需要添加一个 #[no_mangle] 注解,以告诉 Rust 编译器不要修改此函数的名称。*修改* 是指编译器将我们给函数的名称更改为包含更多信息的另一个名称,以供编译过程的其他部分使用,但可读性较差。每种编程语言编译器修改名称的方式略有不同,因此为了使 Rust 函数可以被其他语言命名,我们必须禁用 Rust 编译器的名称修改。

在以下示例中,我们将 call_from_c 函数编译为共享库并从 C 链接后,可以从 C 代码访问它

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

这种 extern 的用法不需要 unsafe

访问或修改可变静态变量

在本书中,我们还没有讨论 *全局变量*,Rust 支持全局变量,但在 Rust 的所有权规则下可能会出现问题。如果两个线程访问同一个可变全局变量,则会导致数据竞争。

在 Rust 中,全局变量称为 *静态* 变量。代码清单 19-9 显示了使用字符串切片作为值的静态变量的声明和使用示例。

文件名:src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

代码清单 19-9:定义和使用不可变静态变量

静态变量类似于我们在第 3 章的 “变量和常量之间的区别”部分中讨论的常量。按照惯例,静态变量的名称采用 SCREAMING_SNAKE_CASE 形式。静态变量只能存储具有 'static 生命周期 的引用,这意味着 Rust 编译器可以确定生命周期,而我们不需要显式地对其进行注释。访问不可变静态变量是安全的。

常量和不可变静态变量之间的一个细微区别是静态变量中的值在内存中具有固定的地址。使用该值将始终访问相同的数据。另一方面,常量允许在每次使用时复制其数据。另一个区别是静态变量可以是可变的。访问和修改可变静态变量是 *不安全的*。代码清单 19-10 显示了如何声明、访问和修改名为 COUNTER 的可变静态变量。

文件名:src/main.rs

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {COUNTER}");
    }
}

代码清单 19-10:从可变静态变量读取或写入是不安全的

与常规变量一样,我们使用 mut 关键字指定可变性。任何读取或写入 COUNTER 的代码都必须在 unsafe 块中。这段代码编译并打印 COUNTER: 3,正如我们预期的那样,因为它是单线程的。让多个线程访问 COUNTER 可能会导致数据竞争。

对于全局可访问的可变数据,很难确保没有数据竞争,这就是 Rust 将可变静态变量视为不安全的原因。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全的智能指针,以便编译器检查从不同线程访问数据是否安全。

实现不安全特征

我们可以使用 unsafe 来实现不安全特征。当一个特征的至少一个方法具有一些编译器无法验证的不变量时,该特征就是不安全的。我们通过在 trait 之前添加 unsafe 关键字并将特征的实现标记为 unsafe 来声明一个特征是 unsafe,如代码清单 19-11 所示。

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}

代码清单 19-11:定义和实现不安全特征

通过使用 unsafe impl,我们承诺我们将维护编译器无法验证的不变量。

例如,回想一下我们在第 16 章的 “使用 SyncSend 特征进行可扩展并发”部分中讨论的 SyncSend 标记特征:如果我们的类型完全由 SendSync 类型组成,则编译器会自动实现这些特征。如果我们实现了一个包含非 SendSync 类型的类型(例如原始指针),并且我们想将该类型标记为 SendSync,则必须使用 unsafe。Rust 无法验证我们的类型是否维护了可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动执行这些检查,并使用 unsafe 指示这一点。

访问联合的字段

最后一个只能使用 unsafe 的操作是访问 *联合* 的字段。union 类似于 struct,但在特定时间点,特定实例中只使用一个声明的字段。联合主要用于与 C 代码中的联合进行交互。访问联合字段是不安全的,因为 Rust 无法保证当前存储在联合实例中的数据的类型。您可以在 Rust 参考 中了解有关联合的更多信息。

何时使用不安全代码

使用 unsafe 来执行刚刚讨论的五种操作(超级权限)之一并没有错,甚至没有受到反对。但是,要使 unsafe 代码正确无误更加棘手,因为编译器无法帮助维护内存安全。当您有理由使用 unsafe 代码时,您可以这样做,并且拥有显式的 unsafe 注解可以更容易地在问题发生时找到问题的根源。