Rust 内存模型

原文地址

位置

Rust里的位置与值大致对应着其他语言的“左值”和“右值”的概念,但是Rust的要更“弹性”一点。什么是位置和值呢? 我来打个比方,比如你用铅笔在一张白纸上写下42这个数字。那么这张白纸就是“位置”,因为它可以写字。而42就是这个数值。 如果你在计算器上按出了42这个数字,那么计算器也是一个“位置”,里面可以放42这个值。

概念上,一个位置的状态总是处于五种状态之中。
1. 还没有初始化(一张白纸、计算器刚开机)。
2. 被初始化了,里面有一个值(写好了42的白纸)。
3. 被初始化了,但是里面的值被移走了(写好了42的白纸,上面打了个叉,表示42不在这里了)。
4. 被初始化了,里面有一个值,这个值正在被共享借用。
5. 被初始化了,里面有一个值,这个值正在被独占借用。

这里的4和5两种状态是Rust的特色,后面会再详细说。

还要提一句,对编译器来说由于判断、循环等逻辑跳转的存在,有时候某个位置的静态分析结论会有“要么是状态A,要么是状态B”的情况。在语言里,针对这些“叠加态”是有专门特殊的规则的,在这里也先不细说。

除了状态这个属性之外,位置还有两种其他的属性。

位置的类型属性。类型首先描述了这个位置的大小(size),内存对齐(align),析构行为(dtor)。此外类型还与Rust独特的特质(trait)系统紧密集成,特别是标记特质(marker trait),影响最大的比如Copy, Send, Sync等等。比如,如果这个类型是满足Copy特质的,那么上面的状态3就不存在——位置里面的值无法被移走,换句话说,不存在存储位置存在但是里面的值已经死亡的情况。

位置的可变性属性。如果位置未被标记为可变(mut),那么上面的状态5就不存在——位置里面的值无法被独占借用;同时这个位置没有办法在离开状态1之后重新对它赋值,也就是在状态2、3下,对它执行赋值会编译错误。(状态4本来就没有办法对它执行赋值)这也就断绝了从状态3回到状态2的可能性。如果标记成了可变(mut),就没有上面的这些限制。

好了,那么这里说的位置是从哪来的呢?

首先最最常见的就是变量声明。通过let语句就可以建立新的带名字的位置,也就是变量。变量其实就是名字与这个位置之间的绑定关系。另外static条目会声明全局位置,相应的名字也存在绑定关系。

除了变量以外,函数调用的参数啊、返回值啊,其实都是有对应的位置的。

还有“临时位置”,比如你表达式计算1+2+4=7的时候,有三个临时位置存1、2、4这三个数,此外还有一个临时位置临时存了一下1+2的结果,3这个数。临时位置通常是隐式标记成了可变(mut)的。

这还没完,在数组(array)、元组(tuple)、结构体(struct)、枚举体(enum)、联合体(union)、闭包、生成器之类的类型的值的内部也会存在一些小的位置。走过一条访问路径就能访问这些值内部的小的位置,虽然语法、限制要求各不相同,但是本质都是相似的。

简单说了一下位置,我们再来说值(value)。我们常说的数值、文本等等种种,乃至对某个位置的引用之类的,都是值。

值有类型,也有唯一的内存表示,内存表示的长度、对齐都是由类型决定的。

由于在编译时就已经对所有关于位置的逻辑做好了事先的规划,所以运行时在通常情况下是不需要再来当场规划的。这一点也是Rust与Python之类的语言的本质区别点。Python之类的语言的运行时的一个很重要的功能,就是作为值的运行时管理器。而Rust在编译时刻做好了规划,在运行时值只剩下内存表示,存储于预先规划好的位置之内。通常来说,在Rust程序运行时,值的数量是不可数的。(而Python虚拟机、JVM里对象的数量是可数甚至可遍历的)

另一个有趣但是经常被忽视的事情是:const 条目定义的常数、fn 条目定义的函数,这些本身都是只有常数值,没有位置的。但是当你每次使用这些常数值的时候,会当场创建一个临时的位置。刚才说过,临时位置是可变的,所以你可以在这时引用、修改这个临时位置里的值,而不会影响原始的常数值。

存储区

在某些编程语言中,类型被分类并采取不同的存储策略。首先先说结论:在Rust中,存储策略是上文说的位置的特性,而与类型完全无关

存储区的分类

对于通常的系统编程来说,常见的存储区大致有三类:全局存储区、调用堆栈存储区(栈)、堆存储区(堆)。但是这是一种惯例而不是本质要求。Rust 作为一门编程语言,语言本身不要求这些概念,而在标准库对常用操作系统的适配中,存在着这样的对应关系。

在现实情况,特别是一些极端情况(如嵌入式编程等等),存储区的划分是可以变通的。比如如果存储比较紧张,首先舍弃的就是堆存储区。在这样的情况下,如果有必要,可以配置成由全局存储区定制提供(概念上是)堆存储区的存储空间;如果没必要,则自始至终不使用(概念上是)堆存储区的机制。其次,调用堆栈存储区一般都是会准备的,但是在极端情况下也可以舍弃,这时程序需要编译成一个整体,而不能存在函数之间的相互调用,更不能支持递归函数。

Rust 的存储策略

一般而言,rust程序的static条目所提供的位置是来自全局存储区的。函数中变量提供的位置是来自调用堆栈存储区的,临时位置大部分也是如此,但是在某些情况下对某些常量提升了的值,移至全局存储区也是存在可能性的。async 函数中的变量、闭包的捕获等等位置,与结构体的字段等等,由于都是类型系统刻画的更大的值中的局部位置,所以不需要对应存储区(它们对应更大的位置中的一小块)。

上面的这段话里完全没有提到堆存储区,那么堆存储区是如何使用的呢?在Rust里有一个全局分配器机制(global allocator),它通常被适配为从操作系统规定的进程的堆存储区分配、释放内存。而某些类型的API设计与全局分配器机制紧密结合——以Box为例,Box<i32> 在构建时通过全局分配器获取出一块合适的堆存储区内存,形成了一个能存放i32类型值的外部位置,并立刻把一个值放进去(状态2)。它的内部状态记录的就是这个外部位置,而它的析构行为则被设定为将这个外部位置里的值析构(进入状态3)、然后通过全局分配器归还这个外部位置对应的空间。在通常的配置下,这个外部位置在堆上,从而允许你把一个i32类型的值通过放进这个位置里来放在堆上;这与整个Box<i32>类型的值和对应的位置是完全无关的

指针与内存地址

在过去的几十年里,C语言在系统编程领域具有很大的影响力。Rust语言在设计时,自然也提供了一定程度的对C语言的兼容性。首先,对位置的引用能够转化为原生指针(mut T 和 const T),然后这两个指针类型又能够转化为usize数值——机器字长的整数。对应于C语言里的指针和内存地址。

可能对某些人有点反直觉的事实:上文我们说到的位置,并不总是需要在运行时对应实际的内存区域,而只是概念上的。代码生成过程、优化过程中,大部分位置会被抹除。代码生成及优化的步骤本身是一个充满了启发式(heuristic)策略的过程,除了基本的原则会被保证之外,做法和结果都是来源于“尽力而为”。这里的基本原则就是:优化后的结果执行起来仿佛跟没有优化过一样。

即使是留下来的那些位置,在概念上,如果编译器能够证明两个位置的使用在时序上没有交集,它甚至可以决定让它们实际对应的存储空间重叠,换句话说,内存地址可以不唯一。

有趣的是,当你试着取某个地址的引用,转化为指针,并打印出来的时候。实际上你会改变编译器的判断。原本可以对应CPU寄存器的位置,编译器会觉得因为这个位置被你获取了内存地址。所以必须在内存里留出对应的位置。算是一种奇妙的观测交互吧。

赋值,即瞬间移动

Rust里的赋值与其他语言比起来有点特别,默认是一个移动操作。详细步骤如下:

输入:具有相同类型的位置1,位置2。

目标:以位置2为源,位置1为目标执行赋值。

编译时检查:

  • 若位置1处于借用状态,赋值时立即终止借用,回到状态2。借用检查器会检查是否会产生lifetime冲突问题。
  • 若位置2处于借用状态,且类型未满足Copy特质,赋值时也需要立即终止借用,回到状态2。借用检查器会检查是否会产生lifetime冲突问题。
  • 若位置2处于借用状态,且类型满足Copy特质,赋值时相当于进行了一次共享借用(原来在状态4则不动,原来在状态2则切换到状态4,并在赋值结束后还原)。借用检查器会检查是否会产生lifetime冲突问题。
  • 若位置1位于状态2、3,且未标记为可变(mut),则编译报错。
  • 若位置2处于状态1、3,则编译报错。

运行时步骤:

  • 若位置1位于状态2,且类型未满足Copy特质,则对位置1中的值执行析构,进入状态3。
  • 将位置2中的值的内存表示复制到位置1中,位置1进入状态2。
  • 若类型未满足Copy特质,位置2进入状态3

所有权的溶解

所有权体系是Rust语言的设计指导思想之一,是Safe Rust中所有的语言设计和API设计必然要遵守的原则。 其他语言的实践中也有类似物,如“谁分配,谁释放”,“谁开发,谁保护;谁污染,谁治理”等等(有奇怪的东西混进来了)。

然而在实际落到具体的语言规则上时,所有权并不真正存在。我们在这里重新来看下“所有权”对于Rust内存模型来说,究竟意味着什么。

前面我们说到了位置(place),有一些是编译器规划内的位置,有一些是某些类型的API以引用间接访问的虚拟位置。前者由编译器规划它们的销毁:static位置不销毁(在进程存在期间一直存在);函数的参数、变量和临时位置在相应的代码块结束时销毁;作为值的一部分提供的位置在对应的值消亡的时候销毁。后者则由相应类型通常在Drop时安插销毁逻辑。

那么所有权在这里的要求就是: 1. 在位置销毁的时候,若其中具有有效的值,那么使值消亡。 2. 值在位置间移动的时候,在必要时使目标位置已有的值消亡、同时正确的将源位置标记为值已经移走的状态。 3. 使用编译错误来禁止位置进入它不能够支持的状态。

作用域与生存期

值位于某个位置内的时序范围,称为作用域(scope),在这个范围内,通过位置可以访问到值。对于编译器规划内的那些位置,编译器会理解并跟踪对这些位置的操作,从而确定其中有值的代码范围,即作用域。然而由于判断、循环等因素,可能出现在编译时编译器认为这些位置中可能有值也可能没有的情况。即状态2和状态3的叠加态。在这种情况下,编译器会自动增加用于区分它是状态2还是状态3的特殊标志位存储(历史原因被称为drop flag)。通过对标志位的同步更新,编译器生成的代码能够同时应付两种可能性,从而能够正确使其中的值在正确的时序位置消亡。

而在值的作用域之内,可以建立对值的引用(reference),这是通过对这个位置执行借用(borrow)操作实现的。这个借用的范围和用来代表这个范围的特殊标记,称为生存期(lifetime,也常被译为生命周期)。生存期总是在作用域的范围之内的。

借用操作有两种:一种叫做共享借用(&),可以对状态2和状态4的位置使用,它会使位置进入状态4。另一种叫做独占借用(&mut),只能对状态2的位置使用,它会使位置进入状态5。在初版(2015版)的Rust中,借用的结束位置是由产生的引用的作用域决定的。借用结束后,位置会回到状态2。而在第二版(2018版)的Rust中,借用的结束位置改成了最后一次直接或者间接触碰借用范围的特殊标记。第二种对初学者学起来可能有点难,但是对熟手来说,第二种在编程的时候更方便。

在进一步详细说之前,我们来用一个比喻来快速地整理一下:假如折叠桌的桌面是位置(place),端上来的一盘鱼香肉丝是值(value),从它被端上桌到被端下桌是它的作用域(scope)。如果没有人来把它端下桌的话(move),那么呆会打算把桌子折叠起来的时候就自动会把盘子扔掉(drop)。鱼香肉丝在桌面上的时候,大家可以围着它拍照(共享借用),这段时间可以随便起个名字比如叫做'a;也可以用唯一的一双筷子去夹着吃(独占借用),这段时间可以随便起个名字比如叫做'b,但是由于某些原因,在Rust的设计下,一边拍照一边有人吃是绝对禁止的,必须交替进行。('a与'b不重叠,或者用更数学一点的说法,不存在'c既满足'a: 'c 又满足'b: 'c)

小朋友,你是否有很多冒号

刚才,我们引入了一个奇怪的写法 'a: 'b。这里并不讲语法,所以只是快速提一下,冒号的意思是左边 满足 右边的约束。对于生存期,它的含义是包含。'a 包含 'b,从而'a 满足 'b 这个要求。

如果'a包含'b和'b包含'a同时成立,事实上就表示'a与'b一致。然而Rust当前的推理规则貌似并不包含这一条——成立,但是不可证。Rust认可的只有同一,也就是当你用'c代入到'a和'b的位置的时候,它们才是相等的。

另外生存期包含是自反的—— 'a 包含 'a。

最最后面,有一个带名字的生存期叫做'static,Rust认可它包含所有的生存期。

最后

生存期真正的用途,是记录值与值之间的依赖关系。

我们回到刚才说的位置的五种状态。当我们对一个位置进行借用操作的时候,就会出现一个没有名字的生存期,我们不妨在头脑里给它起个名字,叫'#1。'#1会附着在新产生的引用值上,成为它的类型的一部分。这时,'#1这个生存期的存在成了这个位置无法回到状态2的原因。

我们可以继续按照Rust的规则对这个引用值做操作,比如把它传递给某个函数,Rust编译器其实并不懂也不关心我们在这个函数里做了什么事情。执行过后,也许仍然有值的类型带有'#1这个生存期。也许某个值带有另一个生存期'#2同时'#1: '#2成立。无论存在的是'#1还是'#2,含义都是产生'#1的那次借用还没有结束,也就是刚才的那个位置仍然处于借用状态,是状态4还是状态5则要看产生'#1时那次借用操作是共享借用还是独占借用。

当你有机会给生存期命名的时候,除了HRTB之类的少数情况,绝大多数都是在类型定义、函数签名上。目的只有一个:既对实现者也对使用者约定值与值之间的依赖关系。这是Rust的土特产——接口能够并且必须以生存期的形式表达值与值之间的依赖关系——它是API接口的一部分。

另外还有一个有趣的性质:每个匿名的生存期必然是与某个位置紧密相关的。如果你在编程中对某个匿名生存期心存疑惑,不妨去找出那个与它直接对应的位置,看下它的作用域边界,相信会对找出问题所在有所帮助。

Last modification:March 6th, 2021 at 12:12 am
安安,亲觉得有用, 请随意打赏嗷