5.5 栈借用
8 分钟阅读
原文链接: 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 就是为此设计的。可变引用按定义不别名,虽然共享引用可以互相别名,但不能变异。完美!发布!
除了比这复杂。我们可以这样"再借用"可变指针:
| |
编译运行正常。怎么回事?
交换两次使用就能看出:
| |
| |
突然变成编译器错误!
再借用可变指针时,原指针在借用人用完之前(没有更多使用)不能再用了。
能工作的代码里,使用有漂亮的嵌套。再借用指针,用新指针一段时间,在再用旧指针之前停止使用它。不能工作的代码里不是这样。我们任意交错使用。
这就是我们能有再借用还保留别名信息的方式:所有再借用清晰嵌套,所以任意时刻我们可以认为只有一个"活跃"。
嘿,你知道表示清晰嵌套事物的好方法吗?栈。借用的栈。
哦嘿,就是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 环境变量传递,像这样:
| |
或者在 Windows 上,你需要全局设置变量:
| |
我们一般会尝试符合这个额外严格模式,只是为了额外对我们的工作有信心。从某种意义上说它也"更简单",所以实际上更适合摸索并获得 stacked borrows 的直觉。
管理 Stacked Borrows
所以使用裸指针时,我们会尝试坚持一个简单粗犷、希望有较大容错空间的启发式:
一旦你开始使用裸指针,就尽量只使用裸指针。
这尽可能降低意外失去裸指针对内存"权限"的可能性。
旁白: 这在两方面过于简化:
安全指针通常断言的不只是别名:内存已分配、对齐、足够大以容纳指向类型、指向对象已正确初始化等。所以在状态可疑时随意抛掷它们更危险。
即使待在裸指针领域,也不能随意别名任何内存。指针概念上与特定"分配"绑定(可以细到栈上的局部变量),你不应该从一次分配取指针、偏移它,然后访问另一次分配的内存。如果允许,到处都会有暴躁小人的威胁。这是"指针只是整数"是有问题观点的部分原因。
我们仍然希望在接口中使用安全引用,因为我们想构建漂亮的安全抽象,让链表用户不必知道或担心。
所以我们要做的是:
- 在方法开始时,用输入引用得到裸指针
- 从此尽量只使用 unsafe 指针
- 结束时如需要把裸指针转回安全指针
但我们的类型字段是私有的,所以那些会完全保持为裸指针。
事实上,我们犯的大错之一是继续使用 Box!Box 里有特殊注解告诉编译器"嘿这很像 &mut,因为它唯一拥有那个指针"。这是真的!
但我们保留的指向链表末端的裸指针指向 Box 内部,所以每当我们正常访问 Box,很可能就在使那个裸指针的"再借用"失效!☠
下一节我们会回到真实形态,用一堆例子撞得头破血流。