Me as a method
用直觉与逻辑寻求好解释,用好解释丰富直觉与逻辑


简明 Rust - 理解 Rust Ownership 机制

Posted on

每个编程语言都有自己管理内存的方式。因为内存有限,原则是数据在使用的时候加载到内存,不需要使用的时候及时释放,避免内存被撑爆。

较底层的语言比如 C ,需要程序员手动管理内存。也就是手动申请内存,手动释放内存。

较高级的语言比如 Python ,有所谓垃圾回收机制,无需用户手动管理内存,大为减轻程序员心智负担。

Rust 比较独特,采用了一种叫 Ownership 的机制来管理内存。可以认为是一种半自动的内存管理方式。

据说有不少程序员在理解 Ownership 机制时会遇到困难,其中不乏经验丰富的程序员。

经验告诉我们,理解新事物出现困难,往往是成见太深。也就是说只要放轻松,回归到事情的原点来看待问题,就可以了。

假如现在要实现一个自动管理内存的机制,比较自然的思路是看看这块内存有谁在使用,如果没有被使用,则表明这块内存可以被回收以便再次使用。这个思路大家称之为引用计数法,即当引用数为 0 时,可回收内存。比如:

a = Data()
b = a

此时,对数据 Data() 所占用的内存,有 2 个引用,分别是 ab 。当 ab 这两个变量退出作用域,那么数据 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() 时,ownera

b = a 后,ownera 变成了 b,就像 Data()a 转移 (move) 到了 b

有了这个规则之后,内存回收就变得简单了,当 owner 退出作用域时 (scope 关闭),就可以回收内存。

我认为,在写 Rust 程序的时候,保持一种数据在不同的变量之间移来移去的感觉是非常有帮助的。

然而,问题还没完全解决。现实是复杂的,某些情况下,引用数就是需要有多个,不可能为 1。

怎么办?

引用计数啊!

Rust 的意思是,只有在需要的时候,才使用引用计数。Rust 提供了 RcArc 两种 wrapper 来启用引用计数功能 (Arc 是线程安全版本的 Rc)。上面的例子可以写为:

let a = Rc::new(Data::new());
let b = a.clone();

这时候,当 ab 都不再使用时,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 到线程内使用 
    })
}

有了 RcArc, 问题似乎得到了完美的解决。

但是,这样做似乎很麻烦。因为多引用是一个极为常见的情况。有没有别的办法?

引用计数,引用计数。能不能只引用,不计数?

关键就在这里。引用之所以需要计数,是因为引用的新增和销毁行为是动态的,也就是说需要把程序跑起来才能知道,所以才需要一个动态的计数器来追踪这个动态的行为。

反过来,如果引用的新增和销毁行为是静态的,也就是编译期就能知道,那么就不需要计数了。

比如有数据引用 a,此后从 a 衍生出新引用 &a,且静态分析得知 &a 使用并销毁在 a 销毁之前,那么数据就可以只增加引用而不增加计数,因为从内存回收的角度,这个 &a 的引用实际上就像没出现过一样。

也就是说,编译时确保 &a 的生命周期在 a 的生命周期以内,那么运行时只需要在 a 销毁的时候回收内存即可。

直观感受一下:

{
    a = Data()
    {
        b = &a  // &a started
        ... do something ...
    }  // &a ended
}

这就是 Rust 所谓的 BorrowLifetime 的概念。

需要注意的是,BorrowLifetime 需要依赖编译期的静态分析,所以往往适用于顺序执行的情况。而在并发执行时,因为往往不知道哪个并发单元先执行完,需要使用动态的 Arc 来进行引用计数。


总结:

  1. Ownership 机制是一种特殊的引用计数内存管理机制,计数永远为 1 。在写 Rust 程序的时候,保持一种数据在不同的变量之间移来移去的感觉是非常有帮助的.

  2. Rust 同样支持常规的引用计数内存管理机制,Rc/Arc。在并发编程的时候经常会用到。

  3. 此外,Rust 支持只增加引用不增加计数的机制,也就是 Borrow。这种机制需要依赖编译期的静态分析,以确保 Lifetime 的合法。

严谨的细节和规范看文档就可以,带着上面的思路多写写,很容易找到感觉。