为什么Rust不允许可变别名?

17

Rust 不允许这种代码,因为它是不安全的:

fn main() {
    let mut i = 42;
    let ref_to_i_1 = unsafe { &mut *(&mut i as *mut i32) };
    let ref_to_i_2 = unsafe { &mut *(&mut i as *mut i32) };

    *ref_to_i_1 = 1;
    *ref_to_i_2 = 2;
}

我如何通过对同一事物使用多个可变引用来做一些坏事情(例如分段错误、未定义行为等)?

我唯一能想到的可能问题来自数据的生命周期。如果i仍然存在,那么每个可变引用都应该没问题。

我可以看出当引入线程时可能会出现问题,但是如果我在一个线程中执行所有操作,为什么仍然会被阻止呢?


将数据从一个线程传输到另一个线程,并在一个线程中写入,另一个线程中读取。愉快的竞争条件。 - CodesInChaos
@CodesInChaos 问题已编辑。 - Boiethios
4
在单线程的情况下,您可以使用&Cell<T>,它与您想要的语义非常接近。这会排除一些编译器优化,不幸的是,编译器缺乏一些语法糖来方便地处理单元格。 - CodesInChaos
3
也在单线程共享可变性的问题中有所回答。 - bluss
2
滥用迭代器可能是违反安全性的经典例子。https://play.rust-lang.org/?gist=f33cba7d223dc6fec12bbb7c29b8ed97&version=stable - Josh Lee
2
Nomicon也涵盖了其中的一部分。https://doc.rust-lang.org/nomicon/aliasing.html - E net4
5个回答

22

在C++程序中,甚至在Java程序中,一个非常常见的陷阱是在迭代过程中修改集合,例如:

for (it: collection) {
    if (predicate(*it)) {
        collection.remove(it);
    }
}

对于C++标准库集合,这会导致未定义的行为。也许迭代会一直工作,直到您到达最后一个条目,但是最后一个条目将解引用悬空指针或超出数组范围。可能支撑集合的整个数组将被重新定位,并且它将立即失败。也许它大多数时间都有效,但如果在错误的时间重新分配,则会失败。对于Java标准集合中的大多数,根据语言规范,这也是未定义的行为,但集合往往会抛出ConcurrentModificationException——这会导致运行时成本,即使您的代码是正确的。两种语言都无法在编译期间检测到此错误。

这是并发造成的数据竞争的常见示例,即使在单线程环境下也是如此。并发不仅意味着并行性:它还可以意味着嵌套计算。在Rust中,编译期间会检测到这种错误,因为迭代器对集合具有不可变借用,因此在迭代器存活时不能更改集合。

一个更易理解但较少见的示例是当您向函数传递多个指针(或引用)时出现指针别名。一个具体的示例是将重叠的内存范围传递给memcpy而不是memmove。实际上,Rust的memcpy等价函数也是unsafe的,但这是因为它接受指针而不是引用。链接的页面展示了如何使用可变引用从而产生安全的swap函数。

一个更牵强的引用别名的例子如下:

int f(int *x, int *y) { return (*x)++ + (*y)++; }
int i = 3;
f(&i, &i); // result is undefined

在Rust中,您不能编写像这样的函数调用,因为您必须获取同一变量的两个可变借用。


6
我可以做一些坏事(例如分段错误,未定义行为等),使用多个可变引用来引用同一个对象,但我相信尽管你这样做会触发"未定义行为",但是在Rust编译器中,对于&mut引用实际上并没有使用noalias标志,所以从实际角度讲���你现在可能无法通过这种方式触发未定义行为。你所触发的是"实现特定行为",它是"按照LLVM的C++行为"执行。
有关详细信息,请参见为什么Rust编译器不会优化代码以假定两个可变引用不能别名? 我可以理解在引入线程时可能会出现问题,但即使我在一个线程中做完所有操作,为什么也要禁止这样做呢?
请阅读关于未定义行为的系列博客文章 我认为,竞争条件(如迭代器)并不是你所说的好例子。在单线程环境中,如果你小心谨慎,你可以避免这种问题。这和创建无效内存的任意指针并写入它是没有什么区别的,只要不这样做。你比使用C差不了多少。
要理解此处的问题,请考虑在发布模式下编译器在执行优化时可能会重新排序语句;这意味着尽管你的代码可能以线性顺序运行:
a; b; c;

如果(根据编译器的知识)没有逻辑原因要求语句必须以特定的原子序列执行,那么不能保证编译器在运行时按照这个顺序执行它们。上面链接的博客的第三部分演示了这如何导致未定义的行为。

tl;dr:基本上,编译器可能会执行各种优化;只有你的程序不触发未定义的行为,这些优化才能保证使你的程序行为具有确定性。

据我所知,Rust编译器目前没有使用许多可能导致此类失败的“高级优化”,但不能保证它将来不会使用。引入新的编译器优化并不是一项“破坏性变更”。

因此...实际上,现在通过可变别名触发实际的未定义行为的可能性非常小;但是限制允许未来进行性能优化。

相关引用:

C FAQ将“未定义行为”定义为:

任何事情都可能发生;标准不强制任何要求。程序可能无法编译,或者执行不正确(崩溃或默默生成不正确的结果),或者幸运地恰好做到程序员想要的。


2
在单线程环境中,如果你小心谨慎,就可以避免那种问题。但是,如果你这样想,为什么还需要借用检查器呢? - Dan Hulme
如果我理解他的观点,他说规则“移动后不要使用某物”(这是每个人都会给出的例子,请参见其他答案)与“不要有多个可变别名”并不相同。例如,如果您使用可变切片,则不能在移动后使用项目,因为切片只是一些借用的内存,您必须拥有某些内容才能将其移动。但是,此答案表示对切片具有多个可变别名仍然很糟糕,因为编译器处理的信息是错误的。我更喜欢这个答案,因为它更加精确。 - Boiethios
我听过很多人在C++中使用“也许未来的编译器可以更好地优化这种方式”作为糟糕编程实践的辩解,当禁止多个可变引用以防止编程错误是一个真正的好处时,在Rust世界中听到同样的说辞真是令人遗憾。 - Dan Hulme
@Boiethios 是的,我已经读了几遍,之后我感到很困惑。您提出“移动后使用”作为错误,但那不是您示例的说明,也不是我的示例的说明,并且在这个答案中似乎没有提到它。 - Dan Hulme
@DanHulme 因为人们不够小心,我们会犯错误。 - Ekrem Dinçel

4

作者注:以下答案最初是为如何交织的作用域创建“数据竞争”?撰写的。

编译器允许在假定&mut指针是排他(不具别名)的情况下优化它们。你的代码打破了这个假设。

问题中的示例太过简单,无法展示任何有趣的错误行为,但考虑将 ref_to_i_1ref_to_i_2 传递给修改两者并然后执行某些操作的函数:

fn main() {
    let mut i = 42;
    let ref_to_i_1 = unsafe { &mut *(&mut i as *mut i32) };
    let ref_to_i_2 = unsafe { &mut *(&mut i as *mut i32) };

    foo(ref_to_i_1, ref_to_i_2);
}

fn foo(r1: &mut i32, r2: &mut i32) {
    *r1 = 1;
    *r2 = 2;
    println!("{}", r1);
    println!("{}", r2);
}

编译器可能会(也可能不会)决定取消交错访问r1r2,因为它们不允许别名。
// The following is an illustration of how the compiler might rearrange
// side effects in a function to optimize it. Optimization passes in the
// compiler actually work on (MIR and) LLVM IR, not on raw Rust code. 
fn foo(r1: &mut i32, r2: &mut i32) {
    *r1 = 1;
    println!("{}", r1);
    *r2 = 2;
    println!("{}", r2);
}

它甚至可能会意识到println!总是打印相同的值,并利用这一事实进一步重新排列foo

fn foo(r1: &mut i32, r2: &mut i32) {
    println!("{}", 1);
    println!("{}", 2);
    *r1 = 1;
    *r2 = 2;
}

编译器能够进行这种优化很好!(即使像Doug所回答的那样,Rust目前还没有实现。)优化编译器非常棒,因为它们可以使用如上述的转换来使代码运行更快(例如通过更好地将代码通过CPU流水线处理,或者通过使编译器在后续处理中执行更激进的优化)。一切相等的情况下,每个人都喜欢他们的代码运行得更快,不是吗?
你可能会说“嗯,这不是一种有效的优化,因为它没有做同样的事情。”但你是错误的:&mut引用的整个就是它们不是别名。没有办法使r1r2别名而不违反规则†,这就使得这种优化是有效的。
你可能还认为这只是出现在更复杂的代码中的问题,因此编译器应该允许简单的示例。但请记住,这些转换是长时间的多步优化过程的一部分。重要的是要在所有地方遵守&mut 引用的属性,这样编译器就可以对一个代码段进行轻微的优化,而不需要理解所有代码。
还要考虑一件事:你作为程序员的工作是选择和应用适合你的问题的类型;偶尔请求编译器例外&mut 别名规则就等于请它替你完成工作。
如果你想要共享可变性并放弃这些优化,那很简单:不使用&mut。在这个示例中,你可以像注释中提到的那样使用&Cell<i32>而不是&mut i32
fn main() {
    let mut i = std::cell::Cell::new(42);
    let ref_to_i_1 = &i;
    let ref_to_i_2 = &i;

    foo(ref_to_i_1, ref_to_i_2);
}

fn foo(r1: &Cell<i32>, r2: &Cell<i32>) {
    r1.set(1);
    r2.set(2);
    println!("{}", r1.get()); // prints 2, guaranteed
    println!("{}", r2.get()); // also prints 2
}

std::cell中的类型提供了"内部可变性",这是术语,表示"不允许某些优化,因为&引用可能会改变事物状态"。它们并不总是像使用&mut那样方便,但这是因为使用它们会给你更多的灵活性来编写以上代码。

另请参阅

  • 单线程共享可变性的问题解释了即使没有多个线程和数据竞争,拥有多个可变引用也可能导致声音问题。
  • Dan Hulme的答案说明了对于更复杂的数据,别名突变也可能导致未定义行为(甚至是在编译器优化之前)。

† 请注意,仅使用unsafe本身并不算"违反规则"。为了让您的代码具有定义的行为,即使在使用unsafe时,&mut引用也不能被别名所引用。


2
我知道的最简单的例子是尝试将元素push到已被借用的Vec中:
let mut v = vec!['a'];
let c = &v[0];
v.push('b');
dbg!(c);

这是一个编译器错误:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let c = &v[0];
  |              - immutable borrow occurs here
4 |     v.push('b');
  |     ^^^^^^^^^^^ mutable borrow occurs here
5 |     dbg!(c);
  |          - immutable borrow later used here

很好,这是编译器错误,否则它将会是 use-after-free。`push` 重新分配了 `Vec` 的堆存储并使我们的 `c` 引用无效。Rust 实际上不知道 `push` 做了什么;所有 Rust 知道的是 `push` 需要一个 `&mut self`,而在这里违反了别名规则。
许多其他单线程的未定义行为示例涉及销毁堆上的对象。但如果我们玩一下引用和枚举,我们可以表达类似的东西而不需要堆分配:
enum MyEnum<'a> {
    Ptr(&'a i32),
    Usize(usize),
}
let my_int = 42;
let mut my_enum = MyEnum::Ptr(&my_int);
let my_int_ptr_ptr: &&i32 = match &my_enum {
    MyEnum::Ptr(i) => i,
    MyEnum::Usize(_) => unreachable!(),
};
my_enum = MyEnum::Usize(0xdeadbeefdeadbeef);
dbg!(**my_int_ptr_ptr);

在这里,我们将指向my_int的指针存储在my_enum中,并使my_int_ptr_ptr指向my_enum。如果我们可以重新分配my_enum,那么我们可能会破坏my_int_ptr_ptr指向的位。对my_int_ptr_ptr进行双重解引用将是一个野指针读取,这可能会导致段错误。幸运的是,这是别名规则的另一种违反,它不会编译:

error[E0506]: cannot assign to `my_enum` because it is borrowed
  --> src/main.rs:12:1
   |
8  | let my_int_ptr_ptr: &&i32 = match &my_enum {
   |                                   -------- borrow of `my_enum` occurs here
...
12 | my_enum = MyEnum::Usize(0xdeadbeefdeadbeef);
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ assignment to borrowed `my_enum` occurs here
13 | dbg!(**my_int_ptr_ptr);
   |      ---------------- borrow later used here

我刚才跟随上面的链接,发现我的答案与此帖子非常相似:https://manishearth.github.io/blog/2015/05/17/the-problem-with-shared-mutability。我想我几年前曾经读过它,并且一直记得它 :) - Jack O'Connor

1
术语“别名”通常用于标识更改涉及不同引用的操作顺序会更改这些操作效果的情况。如果一个对象有多个引用被存储在不同的位置,但该对象在这些引用的生命周期内未被修改,则编译器可以有用地提升、延迟或合并使用这些引用的操作而不影响程序行为。
例如,如果编译器看到代码读取由x引用的对象的内容,然后对由y引用的对象执行某些操作,再次读取由x引用的对象的内容,并且编译器知道对y的操作不可能已修改由x引用的对象,则编译器可以将对x的两次读取合并为一次读取。
确定在所有情况下一个引用的操作是否可能会影响另一个引用将是一个棘手的问题,如果程序员有无限制地使用和存储引用的自由。然而,Rust试图处理这两种简单的情况:
  1. 如果在引用的生命周期内对象永远不会被修改,使用该引用的机器代码就不必担心在该生命周期内哪些操作可能会改变它,因为任何操作都无法这样做。

  2. 如果在引用的生命周期内,对象只会被基于该引用的可见引用所修改,使用该引用的机器代码就不必担心使用该引用的任何操作是否会与涉及看似无关的引用的操作相互作用,因为没有看似无关的引用将标识相同的对象。

允许可变引用之间存在别名的可能性会使事情变得更加复杂,因为许多优化可以与对可变对象的非共享引用或对不可变对象的共享引用交替执行,但此时不能这样做。一旦语言支持涉及看似独立的引用的操作需要按精确顺序处理的情况,编译器很难知道何时需要进行这样的精确排序。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接