Rust 如何知道在堆栈展开期间是否 运行 析构函数?

How does Rust know whether to run the destructor during stack unwind?

mem::uninitialized 的文档指出了为什么 dangerous/unsafe 使用该函数:在未初始化的内存上调用 drop 是未定义的行为。

所以我认为这段代码应该是未定义的:

let a: TypeWithDrop = unsafe { mem::uninitialized() };
panic!("=== Testing ==="); // Destructor of `a` will be run (U.B)

然而,我写了这段代码,它在安全的 Rust 中工作并且似乎没有遭受未定义行为的影响:

#![feature(conservative_impl_trait)]

trait T {
    fn disp(&mut self);
}

struct A;
impl T for A {
    fn disp(&mut self) { println!("=== A ==="); }
}
impl Drop for A {
    fn drop(&mut self) { println!("Dropping A"); }
}

struct B;
impl T for B {
    fn disp(&mut self) { println!("=== B ==="); }
}
impl Drop for B {
    fn drop(&mut self) { println!("Dropping B"); }
}

fn foo() -> impl T { return A; }
fn bar() -> impl T { return B; }

fn main() {
    let mut a;
    let mut b;

    let i = 10;
    let t: &mut T = if i % 2 == 0 {
        a = foo();
        &mut a
    } else {
        b = bar();
        &mut b
    };

    t.disp();
    panic!("=== Test ===");
}

它似乎总是执行正确的析构函数,而忽略另一个。如果我尝试使用 ab(例如 a.disp() 而不是 t.disp()),它会正确地错误提示我可能正在使用未初始化的内存。令我惊讶的是,虽然 panicking,但无论 i 的值是什么,它总是 运行 是正确的析构函数(打印预期的字符串)。

这是怎么发生的?如果 运行time 可以确定 运行 的哪个析构函数,是否应该从 mem::uninitialized() 的文档中删除关于实现 Drop 的类型强制需要初始化的内存部分作为链接以上?

使用 drop flags.

Rust(直到并包括版本 1.12)在其类型实现 Drop 的每个值中存储一个布尔标志(因此将该类型的大小增加一个字节)。该标志决定是否 运行 析构函数。因此,当您执行 b = bar() 时,它会为 b 变量设置标志,因此只有 运行s b 的析构函数。反之亦然 a.

请注意,从 Rust 版本 1.13(在编写 beta 编译器时)开始,该标志不存储在类型中,而是存储在每个变量或临时变量的堆栈中。 Rust 编译器中 MIR 的出现使这成为可能。 MIR 显着简化了 Rust 代码到机器代码的翻译,从而使该功能能够将丢弃标志移动到堆栈。如果优化可以在编译时确定何时删除哪个对象,则通常会消除该标志。

您可以 "observe" 通过查看类型的大小,在 1.12 版之前的 Rust 编译器中使用此标志:

struct A;

struct B;

impl Drop for B {
    fn drop(&mut self) {}
}

fn main() {
    println!("{}", std::mem::size_of::<A>());
    println!("{}", std::mem::size_of::<B>());
}

在堆栈标志之前分别打印 01,在堆栈标志之前打印 00

使用 mem::uninitialized 仍然不安全,但是,因为编译器仍然看到对 a 变量的赋值并设置丢弃标志。因此析构函数将在未初始化的内存上调用。请注意,在您的示例中, Drop impl 不会访问您类型的任何内存(丢弃标志除外,但您不可见)。因此,您没有访问未初始化的内存(无论如何,它的大小为零字节,因为您的类型是零大小的结构)。据我所知,这意味着您的 unsafe { std::mem::uninitialized() } 代码实际上是安全的,因为之后不会发生内存不安全。

首先,有drop flags - 用于跟踪哪些变量已被初始化的运行时信息。如果一个变量没有被赋值,drop()将不会被执行。

在稳定版中,丢弃标志当前存储在类型本身中。向其写入未初始化的内存可能会导致未定义的行为,即 drop() 是否会被调用。这很快就会成为过时的信息,因为丢弃标志在 nightly 中从类型本身中移出。

在 nightly Rust 中,如果将未初始化的内存分配给变量,可以安全地假设 drop() 将被执行。但是,drop() 的任何有用实现都将对该值进行操作。无法检测类型是否在 Drop 特征实现中正确初始化:它可能导致尝试释放无效指针或任何其他随机事物,具体取决于 Drop 的实现方式。无论如何,将未初始化的内存分配给具有 Drop 的类型是不明智的。

这里隐藏了两个问题:

  1. 编译器如何跟踪哪个变量已初始化或未初始化?
  2. 为什么使用 mem::uninitialized() 进行初始化会导致未定义的行为?

让我们按顺序解决它们。


How does the compiler track which variable is initialized or not?

编译器注入所谓的 "drop flags":对于每个 Drop 必须 运行 在范围末尾的变量,在堆栈上注入一个布尔标志,说明这个变量是否需要处理掉

标志从 "no" 开始,如果变量已初始化,则移动到 "yes",如果变量从 "no" 移动,则返回到 "no"。

最后,当删除这个变量的时候,检查标志并在必要时删除它。

这与编译器的流分析是否抱怨可能未初始化的变量无关:只有当满足流分析时才会生成代码。


Why may initializing with mem::uninitialized() lead to Undefined Behavior?

使用mem::uninitialized()时,您向编译器承诺:别担心,我肯定会初始化这个

对于编译器而言,变量因此被完全初始化,drop 标志被设置为"yes"(直到你移出它)。

这反过来意味着 Drop 将被调用。

使用未初始化的对象是未定义的行为,编译器代表您对未初始化的对象调用 Drop 算作 "using it".


奖金:

In my tests, nothing weird happened!

请注意,未定义行为意味着任何事情都可能发生;不幸的是,anything 还包括 "seems to work"(甚至 "works as intended despite the odds")。

特别是,如果您在 Drop::drop 中不访问对象的内存(只是打印),那么很可能一切都会正常工作。但是,如果您访问它,您可能会看到奇怪的整数、指向野外的指针等...

如果优化器很聪明,即使不访问它,它也可能会做一些奇怪的事情!由于我们使用的是 LLVM,因此我邀请您阅读 Chris Lattner(LLVM 之父)的 What every C programmer should know about Undefined Behavior