另一个角度看 Rust 所有权和借用
笔者在一开始了解 Rust 的内存管理机制的时候,阅读了官方文档,以及网络上的教科书,它们首先都引入了所谓 “所有权” 和 “借用” 的概念。
笔者认为这样的叙述方法非常合适,特别是如果读者对操作系统原理与实现、C/C++ 语言不了解,那么这么讲授的方法是大概是最好的。因为这能够很快教给读者 rust 语言的规则,而不需要很多的知识储备或者语境。
但这也带来了弊端:很多读者会把它当作只有 rust 才有的特性、规定,但实际上这种系统设计思想可以用在很多地方。对于为什么 rust 要这么设计,则需要读者学了很长时间之后,结合计算机相关基础知识才能慢慢领会。
因此,个人认为应该从 C/C++ 如何变得安全的角度来讨论 rust 的这个语言特性会比较方便,因为 rust 本身就是在对标 C/C++ 内存不安全的问题,然后在此基础上进行改进。
笔者想提供一种思路,从 C/C++ 开发者的视角来解释 rust 为什么这么设计,然后再定义这两个性质,希望能对读者有所启发。本人学识短浅,望读者勘误/斧正。
如何设计一种全新的内存管理机制?
假设你想创造一个新的编译型语言(就叫 rust),想和 C/C++ 一样,支持直接操纵变量的内存地址、引用,但想要更安全,并且正在为这个语言设计编译器。
我们知道,对于一个正常高级语言而言(无论是 C++/Java 还是其他的什么),为变量分配内存的方式有两类:
- 一个是为编译时已知大小的变量分配(例如一个整型)。由于编译时长度已知,我们直接可以让编译器在栈(stack)上管理它们就行,这样不仅空间和时间效率高,而且不会有内存泄漏问题;
- 一个是运行时才知道大小的变量分配(例如一个保存用户输入的字符串)。这时需要程序运行时动态分配内存,也正是为了应对这种需求,OS 才有了 “堆”(heap)这种运行时内存结构,允许程序在运行时动态分配内存。
好,第一个(已知大小)的变量分配已经解决。以 C++ 为例,我们在写编译器时只需要把这些变量考虑在内,然后生成汇编的时候在当前函数的 activation record 中按需要的大小移动 %rsp
分配就行。
对于第二个(编译时未知大小)的变量分配,我们通常有几种办法:
语言直接向开发者暴露分配和释放堆空间的接口(C/C++)。这种内存管理方式需要手动分配和释放堆上内存,可能编写出内存不安全代码/内存泄漏;
所谓内存不安全代码,可能是访问了指针指向的错误的内存区域、错误地修改了某块内存,导致程序意外结束。
所谓内存泄漏不是真正 “泄漏”,在 OS 的角度看,只是应用程序内存释放不及时,或者要释放的地址丢了(unreachable),导致释放速度赶不上分配速度,最终耗尽了当前进程的虚拟内存空间。
语言向开发者暴露分配堆空间的接口 (Java/JavaScript) 或者 自动分配堆空间 (Python),并采用自动垃圾回收(GC)的方式来释放堆空间。这种内存管理方式的好处是开发者不需要关心堆空间释放问题。
但是 GC 对程序的影响就很有讲究了,人们为了防止 “GC 释放速度赶不上分配速度” 这种情况发生,想了很多种 GC 算法,例如 mark & swap、reference count、copy collection、generation collection、Parallel Scavenge、G1GC、ZGC 等等,限于篇幅不再展开。
即便想的如此周全,还有各种各样的问题:例如 STW(时停开销)、堆扫描的时间开销、循环引用导致内存泄漏(指 refcnt 方法),等等。
于是你想,如果我们开发的语言既不想用第一种方法(不安全),也不想用第二种方法(性能没法达到 C/C++ 水平),能不能同时兼顾安全性和性能呢?
看起来只有在编译时就完成 “编译时未知大小的变量” 的堆分配工作了(让编译器帮我们管理堆空间)!
这就是 rust 语言在设计时考虑内存分配的思路。
好,现在我们需要解决针对变量操纵的几个问题:如何进行变量分配、如何进行变量传递(何时值传递、何时引用传递)。
不妨先完全借鉴 C/C++ 内存分配的思路,为 rust 设计一种方案:
- 对于编译时已知大小的变量,直接分配在栈上,默认使用值传递。即便是复合数据类型(不管有没有指针域)都是如此。
- 引入 “引用” 类型,来显式使用引用传递,节约构造成本。
- 复制构造和值传递的方法,就是逐内存 byte 的复制,我们不妨称之为 copy trait(
Copy
特性);
- 对于编译时未知大小的变量,分配在堆上(需要手动释放),使用指针操纵(读写),指针本身使用值传递。
这种方案对于当年的 rust 创造者来说不可容忍,因为:
- 内存不安全。我们希望编译器帮我们回收堆上的空间;
- 指针可以通过值传递 / copy trait 到处传播,不知道何时释放比较合适,不知道会不会出现 UAF / double free 的问题。
所以作出改进:
- 指针(当然在 rust 中可以不叫指针,只是表示堆上数据的一个类型)可以值传递,但是必须让编译器可以追踪到、必须明确变量内存释放的 “责任” 在确定的变量身上(把这个动作和有“责任”的变量的生命周期绑定起来,类似 C++ RAII),以便进行准确无误的、及时的回收工作,并且不允许实现隐式的 copy trait;
- 用于引用传递的 “引用” 类型需要区分变量的可变性(因为 rust 之前设计的 “变量可变性” 要应对并发安全的场景)。并且需要注意到,引用也会影响到编译器对 “变量内存释放的责任” 的追踪。我们特别规定“引用”类型的传递是不能传递它引用的变量的 “内存释放的责任” 的!
对号入座
其实,改进 1 中 “内存释放的责任” 就是所有权抽象,改进 2 中的 “引用不能传递内存释放的责任” 就是借用抽象。
到此为止,我们就能理解为什么 rust 要设计 “所有权” 和 “借用” 了。
现在笔者再搬出所有权、借用的 “规定”,你看看能不能对号入座了?
Rust “所有权” 制定了以下规则(为了明确内存释放的责任):
Rust 中的每个值(特指堆上的数据)在同一时刻只能被一个变量所拥有,这个变量被称为该值的 “所有者 (owner)”;
从变量的角度说,同一时刻只能绑定一个特定的值;
其实有个特例(参见下文),可变引用(
&mut T
)即便信息在栈上,也被所有权管理,因为它经过编译器翻译后底层是指针,为了确保赋值/复制的数据安全、方便所有权追踪,就这么设计了。当所有者离开绑定值声明的作用域后,这个值将被丢弃(drop);
就第一条而言,我们记住一个原则:“所有权” 是针对堆上的数据的,我们需要所有权管理的也就是堆上的数据。
就第二条而言,大多数语言在效果上都是差不多的:即一个变量只在声明有效的作用域内能够使用。
Rust “引用/借用” 制定了以下基本规则:
- 引用需要区分变量的可变性(
&T
和&mut T
,我们不难发现 Rust 的引用就是 C++ 指针的另一种表述形式,而不是 C++ 的引用); - 一个变量的不可变引用(
&T
)生命周期内可以出现多次,因为不会改变所有权;- 因为不可变引用的安全性,
&T
单独实现 Copy Trait(意味着它不被所有权管理);
- 因为不可变引用的安全性,
- 一个变量的可变引用(
&mut T
)在它的生命周期内只能出现一次、并且与不可变引用互斥。这是为了防止变量可见性冲突,也就是并发程序中共享资源的读者和写者间的关系;- 同时考虑到可变引用也要支持赋值/复制,因此不实现 Copy Trait(意味着即便它不存放在堆上,也被所有权管理!);
- 考虑一下,可变引用被编译器翻译后,底层实现是指针,因此在所有权管理范围内;
Rust 通过上面的 “所有权” 和 “借用” 的规则,巧妙地保证了:
- 编译器能够始终追踪到堆上变量整个生命周期的使用情况,并且能自动判断释放的合适时机,不需要手动释放,也不需要 GC;
- 引用不会干扰内存管理的安全性;
- 可变引用的互斥性,结合变量可变性限制,杜绝数据竞争现象,维护数据安全假设。
最后,Rust 利用是否定义 Copy Trait 将一个类型是否会被所有权管理区分开来,方便具体实现。
归纳,然后演绎
现在我们明白了 Rust “所有权” 和 “借用” 的内容和原因,Rust 所有看似难以理解的语言特性都能得到合理解释。我们举几个例子:
Q0:为什么下面的例子有所有权问题?是不是违反了 “引用不会传递所有权” 的约定?
1 | fn main() { |
答:并没有违反。因为直到最后 s1
仍然可以访问(你可以把 mut1
改成 s1
看看),证明 s1
的可变引用不会传递 s1
的所有权。
这个例子只是恰好展示了可变引用的性质。我们知道如果同时定义同个变量的两个可变引用,编译器会提示冲突:
1 | let mut v = String::from("hello,"); |
但是编译器允许可变引用传递,不过代价是所有权传递:
1 | let mut v = String::from("hello,"); |
这恰恰印证了一点:&T
不可变引用是有 Copy Trait 的,但是可变引用 &mut T
没有 Copy Trait,因此赋值会出现所有权转移(参见“对号入座”一节的可变引用规则)。
可变引用和 不可变引用 相当于 C++ 中的指针和常量指针(不是 C++ 引用,想想为什么)。
因此最开始的例子中的问题是,s1
并没有所有权转移,所有权转移的是 mut1
可变引用变量本身:mut1
所有权转移到 x
上,然后 x
立即被编译器设计的汇编析构了,mut1
当然是无效的。
Q1:为什么下面的例子没有内存释放或者所有权的问题?
1 | fn main() { |
答:因为 &String
Rust 引用不会传递所有权。在 calculate_length
结束后,编译器知道所有权(释放的责任)不在局部变量 s
这里,就不会释放它;
所有权仍然位于 s1
,因此只有 main
结束(s1
生命周期结束),编译器才会去释放 s1
的堆上空间;
Q2:为什么下面的例子无法通过编译?
1 | fn main() { |
答:因为 Rust 引用根据变量的可变性作出区分。更改不可变引用就破坏了 Rust 作出的数据安全假设。
Q3:为什么可变引用同时只能存在一个?
1 | let mut s = String::from("hello"); |
答:虽然 Rust 引用不传递所有权,但是多个可变引用在并发场景相当于共享资源多写者,破坏了 Rust 的数据安全假设。
Q4:为什么可变引用和不可变引用不能同时存在?
1 | let mut s = String::from("hello"); |
答:同 Q3。这是并发场景共享资源临界区同时存在写者和读者,破坏了 Rust 数据安全假设。
Q5:为什么这样的写法又可以?
1 | let mut s = String::from("hello"); |
答:在新版 Rust 编译器中,默认使用 Liveness Analysis 进行 Dead Code Elimination。r1
和 r2
的生命周期只有定义的一行。到定义 r3
时,r1/r2
已经死亡了。如果读者了解过编译原理,这很容易可以看出。
Q6:为什么这样的引用无法通过编译?
1 | fn main() { |
答:这里和 C/C++ 有很大不同。虽然 s
创建在堆上,但是请记住回收是交给编译器完成的,而编译器是将回收动作和变量生命周期绑定的。
也就是说,s
作为一个局部变量,在函数返回后生命周期就会结束,编译器会释放掉 s
涉及的堆空间。这个效果就和 C/C++ 返回指向函数栈上的局部变量的指针一样。
总的来说,Rust 这么做就是为了方便追踪变量的释放责任(所有权),方便判断恰当的释放时机。
总结
对 C/C++ 开发者,总结一下:
Rust 的所有权管理的是堆上的数据。本质上是通过所有权机制,让编译器帮你管理堆上的空间,不需手动分配和释放堆空间、不需 GC;
也就是:只在编译期完成所有内存分配、释放的规则的制定。
所有实现
Copy
traits 的类型,都是代表可以安全进行值拷贝的类型。例如不含指针域的定长简单复合类型、基本类型。Copy
traits 类比为 C++ 中的默认复制构造方法,隐式调用。
其他复合类型(一般是含有指针域的),在 rust 中都不能实现
Copy
trait,因为可能隐式复制构造会造成指针共享,破坏了 rust 的所有权机制。Rust 函数传参和 C++ 相同,默认值传递(无论是什么类型),除非显式使用引用记号(
&T / &mut T
,即 Rust 引用);- 需注意 Rust 对数据竞争的安全性要求;
- 需注意可变引用没有 Copy Trait,因此它本身就被所有权管理;
- 需注意引用的生命周期,防止冲突。