为什么 Rust 在安全的情况下不允许多个可变借款?



我很难理解为什么Rust的借用检查器在安全的情况下不允许多次可变借用。

让我们举一个例子:

fn borrow_mut(s : &mut String) {
s.push_str(" world!");
println!("{}", s);
}
fn main() {
let mut s = String::from("hello");
let rs : &mut String = &mut s;
// second mutable borrow
borrow_mut(&mut s);
println!("{rs}");
}

此代码编译失败,并显示以下消息:

error[E0499]: cannot borrow `s` as mutable more than once at a time
--> main.rs:11:16
|
8  |     let rs : &mut String = &mut s;
|                            ------ first mutable borrow occurs here
...
11 |     borrow_mut(&mut s);
|                ^^^^^^ second mutable borrow occurs here
12 | 
13 |     println!("{rs}");
|                -- first borrow later used here

1指向堆栈帧中类型为CCD_ 2的变量。String包含堆中内存上的指针。因此,即使字符串在borrow_mut()中重新分配了数据,这两个指针仍然有效,因此此代码应该是安全的。

有人能解释一下为什么借用检查器即使在安全的情况下也会阻止多个可变借用的原因吗?

这是为了线程安全,避免数据争用。如果可以存在两个这样的可变借用,那么两个执行线程都可以尝试修改原始数据。如果他们这样做,可能会出现各种恶劣的竞争条件,例如,如果两个线程都试图附加到字符串:

  • 保存数据的底层数组可能会被重新分配两次,其中一次会泄漏
  • 由于检查时间/使用时间问题,附加的数据最终可能会写入越界
  • 最终可能会出现长度和容量定义不一致的情况
  • 在某些体系结构和数据大小上,撕裂可能意味着单个逻辑值被读取一半作为旧版本,一半作为更新值(产生的东西很容易与旧新值无关)
  • 等等

Borrows作为一种语言特性意味着函数可以暂时将其唯一的可变所有权交给其他函数;当另一个函数持有借位时,原始对象不能通过任何访问,只能通过可变借位访问。这也意味着,对于非可变借用,它可以防止可变借用,因为可变借用可能会导致通过非可变借用读取和通过可变借用写入之间的竞争。借用检查器阻止您启动修改s的线程,然后从主线程调用borrow_mut,并且这两个线程在同时修改s时会产生垃圾或使程序崩溃。

需要明确的是,在未来版本的Rust中使用高级借用检查器,可以使此代码运行(您编写的代码没有本质上不安全的地方)。但是,全面分析深层代码路径以确保不会发生任何邪恶的事情是很困难的,而且施加更严格的规则相对容易(如果他们确信这不会对以后的语言设计施加限制,那么未来可能会放松)。毕竟,如果您将已经拥有的单个可变借用传递到borrow_mut中,那么您的代码就可以正常工作;你的代码并没有因为做"铁锈之路"而变得更糟™.

mut的意思是"排他性";。不仅仅是线程安全依赖于此!

粗略地说,Rust编译器有两个部分:

  • 铁锈"前端";,它包括借用检查器,并将您的Rust代码翻译为类似程序集的语言LLVM IR
  • LLVM,将LLVM IR转换为机器代码

前端告诉LLVMrs0引用是独占的也就是说,前端承诺,虽然存在对某个值的mut引用,但不会使用其他指针来访问该值。前端可以做出这个承诺,因为它借用了检查过的代码。

LLVM具有noaliasalias.scope元数据等特性,专门允许编译器提供这种承诺。它解锁了LLVM中强大的优化功能。但是,如果承诺被打破,这些优化可能会严重出错。例如,LLVM可以合理地推理如下:

  • 首先,在第13行的println中内联对<String as Display>::fmt的调用。

  • 内联代码只需要字符串的两部分:长度和指向字符的指针。

  • 在我们在第8行设置rs的点和在第13行使用它的点之间,rs不用于修改String

  • 并且CCD_ 19是排他性的;因此,其他人也没有修改它。String0未更改。(请注意,这个结论是错误的;程序确实修改了该字符串。因此,在这一点之后,LLVM的推理将越来越偏离轨道。)

  • 因此,我们不必等到第13行才从*rs中读取指针和长度。我们可以在这个范围内的任何一点上阅读它们。它会起作用,因为这些领域不会改变。

  • 如果你早点开始,内存访问会完成得更快。所以让我们尽早阅读。将这些读数移到第8行。

  • 实际上,如果我们这样做的话,长度保证是5。所以我们根本不需要读这些。

当然,LLVM实际上并不是";原因";就像人类一样,但它由多个优化器过程组成,每个过程都以具有相同累积效果的方式对代码进行增量调整。

因此,您可以看到LLVM如何在调用borrow_mut之前生成获取rs.ptr的代码,然后CCD_24会增长字符串,使指针无效。println!将访问释放的内存,或者至少打印错误的字节数。

我不确定LLVM是否真的会这样做,如果你以某种方式评论了Rust借用检查器并尝试了它。但我不会感到惊讶。移动内存访问("提升读取")是LLVM和其他编译器真正做的一个真正的优化。它甚至不被认为是特别花哨的!Rust前端可能会在将代码交给LLVM之前进行类似的优化——我不知道。

你的问题答案太长了。简短的回答是,不存在";当它是安全的">打破这个规则从来都不安全,因为这不仅仅是为了人类推理他们的代码。CCD_ 26表示";排他性的";到编译器。