5.5 栈借用

原文链接: https://rust-unofficial.github.io/too-many-lists/fifth-stacked-borrows.html

在上一节我们试着在 miri 下运行不安全的单链表队列,它说我们违反了stacked borrows的规则,并链接了一些文档。

通常我会带读者浏览文档,但我们不是那文档的目标受众。它更多是为研究 Rust 语义的编译器开发者和学者设计的。

所以我会只给你 stacked borrows 的高层概念,然后给你一个遵循规则的简单策略。

旁白: Stacked borrows 作为 Rust 的语义模型仍是"实验性"的,所以违反这些规则可能并不意味着你的程序真的"错了"。但除非你 literally 在做编译器工作,miri 抱怨时你应该修复程序。涉及未定义行为时,宁可安全也不要后悔。

动机:指针别名

在讲我们违反了什么规则之前,理解规则为什么存在会有帮助。有几个不同的动机问题,但我认为最重要的是指针别名。

当两个指针指向的内存重叠时,我们说它们别名。就像用"别名"称呼的人可以用两个不同名字指代,那块重叠内存可以用两个不同指针指代。这会导致问题。

编译器用指针别名信息来优化内存访问,所以如果它拥有的信息错误,程序会被错误编译并做随机垃圾事。

旁白: 实际上,别名更关心内存访问而不是指针本身,而且通常只有其中一个访问是变异时才重要。强调指针是因为它们是附加规则的方便对象。

要理解指针别名信息为什么重要,考虑暴躁小人的寓言。


Michiel 有一天在书架上翻书,看到一本不记得的书。从书柜抽出,看封面。

“哦对,我旧的战争与和平,一本我 definitely 读过的书。我喜欢全是和平的那部分。”

突然有人敲门。Michiel 把书放回架子开门——是宿敌 Hamslaw。Hamslaw 正准备对 Michiel 明显较差的 codegolf 技巧发表毁灭性评论时,Michiel 看到了机会:

“嘿 Hamslaw,你读过战争与和平吗?”

“噗,没人真的读过战争与和平。”

“我读过,看就在我书架上,这显然意味着我读过。”

Hamslaw 不敢相信。她的脸从惯常的得意变成愤怒和决心的铁面具。Hamslaw 推开 Michiel 大步走向书架,以一千女武神的愤怒从架上劈下那部巨著。她把古书在手中翻转,一看到封面就开始颤抖。

Michiel 准备吹嘘自己显然无与伦比的才华,却被 Hamslaw 突然的笑声打断。

“这不是战争与和平,是战争与脚!”

泪水从 Hamslaw 脸上滚落。这 clearly 是她人生最伟大的时刻。

“不——不!我刚看过!”

他们从 Hamslaw 手中抢过书检查封面。确实,“和平"被划掉换成了"脚”。Michiel 羞愧难当。这 clearly 是他们人生最糟糕的时刻。

他们跪倒在地,茫然盯着书架。怎么会这样?他们刚才还检查了封面!

然后看到书架里有点动静。是个小人。Michiel 见过最愤怒皱眉的小人。小人冲 Michiel 竖中指,嘴型说"没人会信你",然后消失在书之间。

Michiel 的计划本来完美,但他们没考虑到一个拿着记号笔、渴望破坏的暴躁小人的可能性。他们以为知道书的封面写什么,以为没人能改它。但 alas,他们错了。

Hamslaw 已经在做纪念她惊人胜利的小册子——Michiel 在当地网吧的声誉再也回不来了。


没人想像 Michiel 一样,但也没人想一直害怕暴躁小人。我们想知道暴躁小人什么时候可能在耍花招。他在的时候,我们会非常小心偏执地在使用前检查一切。但他不在时,我们希望能记住东西。

这就是(非常简化的)指针别名核心:编译器什么时候可以假设"记住"(缓存)值而不是反复加载是安全的?要知道这个,编译器需要知道什么时候可能有暴躁小人在你背后修改内存。

旁白: 编译器也用这信息缓存存储,意思是如果认为没人会注意到,它可以避免把东西提交到内存。这种情况下问题仍然是暴躁小人,但他们只需要读内存就会成问题。

安全的 Stacked Borrows

好吧,我们想让编译器有好的指针别名信息,能做到吗?嗯,似乎 Rust 就是为此设计的。可变引用按定义不别名,虽然共享引用可以互相别名,但不能变异。完美!发布!

除了比这复杂。我们可以这样"再借用"可变指针:

1
2
3
4
5
6
7
8
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

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

println!("{}", data);

编译运行正常。怎么回事?

交换两次使用就能看出:

1
2
3
4
5
6
7
8
9
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// 顺序交换!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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

突然变成编译器错误!

再借用可变指针时,原指针在借用人用完之前(没有更多使用)不能再用了。

能工作的代码里,使用有漂亮的嵌套。再借用指针,用新指针一段时间,在再用旧指针之前停止使用它。不能工作的代码里不是这样。我们任意交错使用。

这就是我们能有再借用还保留别名信息的方式:所有再借用清晰嵌套,所以任意时刻我们可以认为只有一个"活跃"。

嘿,你知道表示清晰嵌套事物的好方法吗?栈。借用的栈。

哦嘿,就是Stacked Borrows!

借用栈顶的是"活跃"的,知道它实际上无别名。再借用指针时,新指针压入栈,成为那个活跃指针。使用旧指针时,通过弹出它上面的所有东西让它"复活"。此时指针"知道"它被再借用过,内存可能被修改过,但它再次拥有独占访问——不用担心暴躁小人。

所以访问再借用的指针总是可以的,因为我们总能弹出它上面的所有东西。真正的麻烦是访问已经从借用栈弹出的指针——那你就搞砸了。

幸好借用检查器的设计确保 safe Rust 程序遵循这些规则,如上例所示,但编译器一般从 stacked borrows 视角"反向"看这个问题。不是说使用 ref1 使 ref2 失效,而是坚持 ref2 对其所有使用必须有效,而 ref1 是通过乱序搞砸的那个。

因此 “cannot use *ref1 because it was mutably borrowed”。结果一样(尤其有非词法生命周期时),但表述方式可能更直观。

但当我们开始用 unsafe 指针时,借用检查器帮不了我们!

不安全的 Stacked Borrows

所以我们需要某种方式让 unsafe 指针参与这个 stacked borrows 系统,即使编译器不能正确跟踪它们。我们还希望系统相当宽松,这样不太容易搞砸导致 UB。

这是个难题,我不知道怎么解,但研究 Stacked Borrows 的人想出了合理的东西,miri 试图实现它。

非常高层的概念是:当你把引用(或任何其他安全指针)转成裸指针时,基本上就像做一次再借用。所以裸指针可以对那块内存为所欲为,再借用过期时就和普通再借用一样。

但问题是,再借用什么时候过期?嗯,可能是你开始再次使用原引用的时候。否则东西就不是漂亮的嵌套栈了。

但等等,你可以把裸指针转成引用!你还可以复制裸指针!如果你做 &mut -> *mut -> &mut -> *mut 然后访问第一个 *mut 呢?stacked borrows 到底怎么工作?

我真的不知道!这就是为什么事情复杂。事实上它们额外复杂,因为 stacked borrows 试图更宽松,让更多 unsafe 代码按你期望的方式工作。这就是为什么我用 miri 运行东西帮我抓错。

事实上,这种混乱是 miri 有一个额外实验性额外严格模式的原因:-Zmiri-tag-raw-pointers。

要启用它,我们需要通过 MIRIFLAGS 环境变量传递,像这样:

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

或者在 Windows 上,你需要全局设置变量:

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

我们一般会尝试符合这个额外严格模式,只是为了额外对我们的工作有信心。从某种意义上说它也"更简单",所以实际上更适合摸索并获得 stacked borrows 的直觉。

管理 Stacked Borrows

所以使用裸指针时,我们会尝试坚持一个简单粗犷、希望有较大容错空间的启发式:

一旦你开始使用裸指针,就尽量只使用裸指针。

这尽可能降低意外失去裸指针对内存"权限"的可能性。

旁白: 这在两方面过于简化:

  1. 安全指针通常断言的不只是别名:内存已分配、对齐、足够大以容纳指向类型、指向对象已正确初始化等。所以在状态可疑时随意抛掷它们更危险。

  2. 即使待在裸指针领域,也不能随意别名任何内存。指针概念上与特定"分配"绑定(可以细到栈上的局部变量),你不应该从一次分配取指针、偏移它,然后访问另一次分配的内存。如果允许,到处都会有暴躁小人的威胁。这是"指针只是整数"是有问题观点的部分原因。

我们仍然希望在接口中使用安全引用,因为我们想构建漂亮的安全抽象,让链表用户不必知道或担心。

所以我们要做的是:

  1. 在方法开始时,用输入引用得到裸指针
  2. 从此尽量只使用 unsafe 指针
  3. 结束时如需要把裸指针转回安全指针

但我们的类型字段是私有的,所以那些会完全保持为裸指针。

事实上,我们犯的大错之一是继续使用 Box!Box 里有特殊注解告诉编译器"嘿这很像 &mut,因为它唯一拥有那个指针"。这是真的!

但我们保留的指向链表末端的裸指针指向 Box 内部,所以每当我们正常访问 Box,很可能就在使那个裸指针的"再借用"失效!☠

下一节我们会回到真实形态,用一堆例子撞得头破血流。

最后修改 August 23, 2026: 更新 (499855b16)