栈借用( Stacked Borrorw)

上一章节中我们运行 miri 时遇到了一个栈借用错误,还给了文档链接,但这些文档主要是给编译器开发者和 Rust 研究者看的,因此就不进行讲解了。

而这里,我们将从一个更高层次的角度来看看何为栈借用。

目前栈借用在 Rust 语义模型中还是试验阶段,因此破坏这些规则不一定说明你的程序错了。但是除非你在做编译器开发,否则最好还是修复这些错误。事前的麻烦总比事后的不安全要好,特别是当涉及到 UB 未定义行为时

指针混叠( Pointer Aliasing )

在开始了解我们破坏的规则之前,首先应该了解为何会有这些规则的存在。这里有多个动机,但是我认为最重要的动机是: 指针混叠.

当两个指针指向的内存区域存在重叠时,就说这两个指针发生了混叠,这种情况会造成一些问题。例如,编译器使用指针混叠的信息来优化内存的访问,当这些信息出错时,那程序就会被不正确地编译,然后产生一些奇怪的结果。

实际上,混叠更多关心的是内存访问而不是指针本身,而且只有在其中一个访问是可变的时,才可能出问题。之所以说指针,是因为指针这个概念更方便跟一些规则进行关联。

再比如,编译器需要获取一个值时,是该去缓存中查询还是每次都去内存中加载呢?关于这个选择,编译器需要清晰地知道是否有一个指针在背后修改内存,如果内存值被修改了,那缓存显然就失效了。

安全地栈借用

有了之前的铺垫,大家肯定希望编译器能对指针混叠的信息了若指掌,但是可以吗?对于 Rust 正常代码而言,这种情况是可以避免的,因为严格的借用规则是我们的后盾:要么同时存在一个可变引用,要么同时存在多个不可变引用,这种规则简直完美避免了:两个指针指向同一块儿重叠内存区域,而其中一个是可变指针。

然而实际使用中,有一些情况会较为复杂,例如以下代码中发生了可变引用的再借用( reborrow ):

#![allow(unused)]
fn main() {
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

*ref2 += 2;
*ref1 += 1;

println!("{}", data);
}

看上去像是违反了借用规则,但是这段代码确实可以正常编译运行,如果交换下引用使用的顺序呢?

#![allow(unused)]
fn main() {
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// ORDER SWAPPED!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);
}
error[E0503]: cannot use `*ref1` because it was mutably borrowed
 --> src/main.rs:6:5
  |
4 |     let ref2 = &mut *ref1;
  |                ---------- borrow of `*ref1` occurs here
5 |     
6 |     *ref1 += 1;
  |     ^^^^^^^^^^ use of borrowed `*ref1`
7 |     *ref2 += 2;
  |     ---------- borrow later used here

For more information about this error, try `rustc --explain E0503`.
error: could not compile `playground` due to previous error

果不其然,编译器抛出了错误,当我们再借用了一个可变引用时,那原始的引用就不能再被使用,直到借用者完成了任务:借用者的借用有效范围并不是看作用域,而是看最后一次使用的位置,正因为如此,第一段代码可以编译通过,而第二段不行,这是著名的生命周期 NLL 规则

以上就是我们拥有再借用但是还拥有混叠信息的原因:所有的再借用都在清晰地进行嵌套,因此每个再借用都不会与其它的冲突。那大家知道什么方法可以很好的展现嵌套的事物吗?答案就是使用栈来存放这些嵌套的借用。

嘿,这不就是栈借用吗?

这个栈的顶部借用就是当前正在使用( live )的借用,而它清晰的知道在它使用的期间不会发生混叠。当对一个指针进行再借用时,新的借用会被插入到栈的顶部,并变成 live 状态。如果要将一个旧的指针变成 live,就需要将借用栈上在它之前的借用全部弹出( pop )。

通过栈借用的方式,我们保证了尽管存在多个再借用,但是在同一个时间,只会有一个可变引用访问目标内存,再也不用担心指针混叠的问题了。只要不去访问一个已经被弹出借用栈的指针,就会非常安全!

从表述方式来说,与其说使用 ref1 会让 ref2 不合法,不如说 ref2 必须要在所有使用情况下合法,ref1 恰恰是其中一种情况,会破坏 ref2 的合法性。而编译器的报错也是选择了第二种表述方式:无法使用 *ref1,原因是它已经被可变借用了,可以看出,第二种表述方式比第一种要更加符合直觉。

但是,当使用 unsafe 指针时,借用检查器就无法再帮助我们了!

不安全地栈借用

所以,我们现在需要一个方式让 unsafe 指针也可以参与到栈借用系统中来,即使编译器无法正确地跟踪它们。同时我们也希望这个系统能宽松一些,不要很容易就产生 UB。

这是一个困难的问题,我也不知道该如何解决,但是目前在编写栈借用系统的开发者显然是有想法的,例如 miri 就是其中一个产物。

从一个高抽象层次来看,当我们将一个引用转换成裸指针时,就是一种再借用。那么随后,裸指针就可以对目标内存进行操作,当再借用结束时,发生的事情跟正常的再借用结束也没有区别。

但是问题是,你还可以将一个裸指针转变成引用,最重要的是,还可以对裸指针进行拷贝!如果发生了以下转换 &mut -> *mut -> &mut -> *mut,然后去访问第一个 *mut,这种见鬼的情况下,栈借用该如何发挥作用?

反正我不知道,只能求助于 miri 了。事实上,正因为这种情况,miri 还提供了试验性的模式: -Zmiri-tag-raw-pointers。可以通过环境的方式来开启该模式:

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

如果是 Windows,你需要设置全局变量:

$env:MIRIFLAGS="-Zmiri-tag-raw-pointers"
cargo +nightly-2022-01-21 miri test

管理栈借用

因为之前的问题,使用裸指针,应该遵守一个原则:一旦开始使用裸指针,就要尝试着只使用它

现在,我们依然希望在接口中使用安全的引用去构建一个安全的抽象,例如在函数参数中使用引用而不是裸指针,这样我们的用户就无需操心 unsafe 的问题。

为此,我们需要做以下事情:

  1. 在开始时,将输入参数中的引用转换成裸指针
  2. 在函数体中只使用裸指针
  3. 返回之前,将裸指针转换成安全的指针

但是由于数据结构中的字段都是私有的,无需暴露给用户,因此无需这么麻烦,直接使用裸指针即可。

事实上,一个依然存在的问题就是还在继续使用 Box, 它会告诉编译器:hey,这个看上去很像是 &mut ,因为它唯一的持有那个指针。

但是我们在链表中一直使用的裸指针是指向 Box 的内部,所以无论何时我们通过正常的方式访问 Box,我们都有可能让该裸指针的再借用变得不合法。