简明 Rust - 理解 Rust Ownership 机制
Posted on
每个编程语言都有自己管理内存的方式。因为内存有限,原则是数据在使用的时候加载到内存,不需要使用的时候及时释放,避免内存被撑爆。
较底层的语言比如 C
,需要程序员手动管理内存。也就是手动申请内存,手动释放内存。
较高级的语言比如 Python
,有所谓垃圾回收机制,无需用户手动管理内存,大为减轻程序员心智负担。
而 Rust
比较独特,采用了一种叫 Ownership
的机制来管理内存。可以认为是一种半自动的内存管理方式。
据说有不少程序员在理解 Ownership
机制时会遇到困难,其中不乏经验丰富的程序员。
经验告诉我们,理解新事物出现困难,往往是成见太深。也就是说只要放轻松,回归到事情的原点来看待问题,就可以了。
假如现在要实现一个自动管理内存的机制,比较自然的思路是看看这块内存有谁在使用,如果没有被使用,则表明这块内存可以被回收以便再次使用。这个思路大家称之为引用计数法
,即当引用数为 0 时,可回收内存。比如:
a = Data()
b = a
此时,对数据 Data()
所占用的内存,有 2
个引用,分别是 a
和 b
。当 a
和 b
这两个变量退出作用域,那么数据 Data
所占用的内存引用数为 0
,可以在恰当的时候被回收。(所谓变量退出作用域,大意是变量所在的作用域 (scope) 被关闭,比如函数返回)。
引用计数
这种行为会给程序带来额外的开销,因为运行时 (runtime) 需要一直去追踪每份数据到底有多少引用,并且对引用数为 0 的数据定时执行释放。
Rust
的思路是既然引用计数会带来额外开销,那就干脆不计数了。
引用数永远为 1,可不可以?
回到刚才的例子,
a = Data() # 引用数 = 1 ,引用为 a
b = a # 引用数 = 1 ,引用为 b, 且 a 不再合法
在 b = a
之后,a
不能再使用,因为引用数只能为 1,且对 Data()
的引用变成了 b
。
直观一点来看这个规则,Data()
就像有了一个 owner
一样,a = Data()
时,owner
是 a
。
b = a
后,owner
从 a
变成了 b
,就像 Data()
从 a
转移 (move) 到了 b
。
有了这个规则之后,内存回收就变得简单了,当 owner
退出作用域时 (scope 关闭),就可以回收内存。
我认为,在写 Rust
程序的时候,保持一种数据在不同的变量之间移来移去的感觉是非常有帮助的。
然而,问题还没完全解决。现实是复杂的,某些情况下,引用数就是需要有多个,不可能为 1。
怎么办?
引用计数啊!
Rust
的意思是,只有在需要的时候,才使用引用计数。Rust
提供了 Rc
和 Arc
两种 wrapper 来启用引用计数功能 (Arc
是线程安全版本的 Rc
)。上面的例子可以写为:
let a = Rc::new(Data::new());
let b = a.clone();
这时候,当 a
和 b
都不再使用时,Data
才会被销毁。
引用计数的使用场景在并发共享数据的情况下极为常见,比如要开 n
个线程同时访问同一份数据,这时候便需要用 Arc
把数据包装起来,然后 .clone()
n
次得到 n
个 owner,再 move 到线程里边使用。大概的模式如下:
let n = 10;
let data = Arc::new(Data::new()); //开启引用计数
for i in 1..n {
let data_x = data.clone(); // 先 clone 一个 owner
thread::spawn(move || {
do_something(data_x); // 将 data_x move 到线程内使用
})
}
有了 Rc
和 Arc
, 问题似乎得到了完美的解决。
但是,这样做似乎很麻烦。因为多引用是一个极为常见的情况。有没有别的办法?
引用计数,引用计数。能不能只引用,不计数?
关键就在这里。引用之所以需要计数,是因为引用的新增和销毁行为是动态的,也就是说需要把程序跑起来才能知道,所以才需要一个动态的计数器来追踪这个动态的行为。
反过来,如果引用的新增和销毁行为是静态的,也就是编译期就能知道,那么就不需要计数了。
比如有数据引用 a,此后从 a 衍生出新引用 &a,且静态分析得知 &a 使用并销毁在 a 销毁之前,那么数据就可以只增加引用而不增加计数,因为从内存回收的角度,这个 &a 的引用实际上就像没出现过一样。
也就是说,编译时确保 &a 的生命周期在 a 的生命周期以内,那么运行时只需要在 a 销毁的时候回收内存即可。
直观感受一下:
{
a = Data()
{
b = &a // &a started
... do something ...
} // &a ended
}
这就是 Rust
所谓的 Borrow
和 Lifetime
的概念。
需要注意的是,Borrow
和 Lifetime
需要依赖编译期的静态分析,所以往往适用于顺序执行的情况。而在并发执行时,因为往往不知道哪个并发单元先执行完,需要使用动态的 Arc
来进行引用计数。
总结:
-
Ownership
机制是一种特殊的引用计数内存管理机制,计数永远为 1 。在写Rust
程序的时候,保持一种数据在不同的变量之间移来移去的感觉是非常有帮助的. -
Rust
同样支持常规的引用计数内存管理机制,Rc/Arc
。在并发编程的时候经常会用到。 -
此外,
Rust
支持只增加引用不增加计数的机制,也就是Borrow
。这种机制需要依赖编译期的静态分析,以确保Lifetime
的合法。
严谨的细节和规范看文档就可以,带着上面的思路多写写,很容易找到感觉。