从Rust看现代编程语言内存管理新思路(二)

达芬奇密码2018-08-13 16:26


上一篇从Rust看现代编程语言内存管理新思路(一)中我们提到了现代计算机语言在内存管理方面遇到的问题,并引入介绍了Rust语言,本篇我们就来探索一下Rust语言内存管理的秘密。


为自动管理内存,必须要了解程序运行过程中数据实例的生命周期,以一般常识来看实例的生命周期仅能够在运行时判断,例如GC语言中需要在运行时通过垃圾收集器分析实例引用关系以标记不再被使用的垃圾实例或者在运行时通过引用计数的增减来判断实例是否还被引用。然而Rust语言却支持在编译期判断实例的生命周期,下面我们来看Rust是怎么做的。


Rust语言之所以能够在编译期了解实例的生命周期源于其引入了一些语言层面的约束,这里可以归结为3个关键词ownership、borrow、lifetimes,下面通过一些实例来说明。


我们来看以下这两个简单的函数:

fn main() {
    let x = 5i;
    let y = x;

    println!("x = {}, y = {}", x, y)
}
--------------
fn main() {
    let x = box 5i;
    let y = x;

    println!("x = {}, y = {}", x, y)
}


这里第一个main函数可以编译通过,第二个却不能,两段程序的差异只在于x的内存分配方式

x = 5i 表示x为分配在栈上的int类型
x = box 5i 表示x为分配在堆上的int类型


在接下来的赋值操作中

y = x 在第一段程序中由于 x 分配在栈上这里产生了一次值拷贝,而第二段程序中则是将y指向引用地址和x共享堆内存。看似很平常的操作然而由于 Rust 引入了 ownership 的约束,第二段程序便不合法了。Rust 的 ownership 约束认为 x 拥有其地址指向的堆内存的所有权,当将 x 赋值给y则产生了所有权的转移,Rust 称之为 move 。因此当第二段程序要打印 x 时,编译器发现x已经不对应任何实例的所有权,加之 Rust 中默认不加 mut 关键词申明的实例不可变,所以 x 已不能再使用了,因此最终得到编译器聪明的反馈:

error: use of moved value: x
fn select<'r, T>(shape: &'r Shape, threshold: f64,
                 a: &'r T, b: &'r T) -> &'r T {
    if compute_area(shape) > threshold {a} else {b}
}

fn select_based_on_unit_circle<'r, T>( threshold: f64, a: &'r T, b: &'r T) -> &'r T {                                  
    let shape = Circle(Point {x: 0., y: 0.}, 1.);   
    select(&shape, threshold, a, b)       
}

当参数增多后,每个传入的参数的生命周期将是不同的,编译器选择将这些参数的生命周期的交集范围作为'r标识的生命周期,上面程序中传入select函数的shape参数生命周期比a,b短,因此编译器将'r等同于shape的生命周期,但这样就出错了,select函数返回的是a或b的引用生命周期应该采用这两个参数的而不是shape,因此上面这段程序又遭到强大的编译器的报错,那如何来修复呢,我们需要修改一下select函数的声明:


fn select<'r, 'tmp, T>(shape: &'tmp Shape, threshold: f64,
                       a: &'r T, b: &'r T) -> &'r T {
    if compute_area(shape) > threshold {a} else {b}
}

可以看到修改的点在于单独命名了shape参数的生命周期,这样返回值的生命周期就能和a,b参数一致了。


通过以上的一些例子,我们看到了在不需要垃圾收集器的情况下,通过onwership,borrow,lifetimes 的约束使得实例的生命周期得到控制,这也使得Rust语言在运行时再无额外的负担,这便是Rust语言实现内存管理的秘密了。


如果你是一位久经考验的程序员,看到这里你应该不会就此轻信Rust就是传说中的银弹,只依靠Rust提供的这些机制真的能够表达所有的程序逻辑吗,实际这是非常困难的,个人尝试写了一些程序,感觉是能写,但很多时候无法优雅的书写程序,主要还是来自于过于强大的类型系统的约束。


Rust通过运行时也提供了一些便利,这里主要介绍一下垃圾收集方面的支持,Rust运行时目前支持以引用计数的方式来进行垃圾收集,看下面一小段程序:

use std::rc::Rc;

let x = Rc::new(5i);
let y = x.clone();

Rc是基于引用计数的垃圾收集包装类型,这使得调用x.clone时仅仅对堆上的实例做了引用计数加一,当实例较大不希望产生内存拷贝时,采用Rc类型是很好的选择,对于并发的情况Rust还提供了Arc类型来保证线程安全。除了引用计数垃圾收集Rust还在0.11版本前提供了Gc类型支持task(task为Rust并发执行的单位,一般为一个用户线程)范围内的垃圾收集,但在0.12暂时取消了该类型,预计后续版本会回归, task范围的垃圾收集和Erlang语言的垃圾收集机制是十分相似的,由于很多时候单个task的整个生命周期范围并不需要触发垃圾收集,即使触发垃圾收集单个task的内存占用一般很小引用关系不深,垃圾收集的开销也很小,这对于保障软实时是一个很好的基础。


Rust这种将实例生命周期控制前移至编译期的做法,使得Rust在运行时没有额外的负担,这也使得Rust能够很好的与C/C++集成,我们在第一篇中提到跨语言调用的运行时鸿沟, 自动GC语言当把实例的引用传递给C后无法了解其被引用的情况,而在Rust语言中由于存在onwership约束,可以做到把实例的onwership move给一个非安全的指针供C程序使用,我们来看下面一段程序:


ownership的约束使得一个实例始终只被一个变量所绑定,这样做的好处是当超出这个变量对应的程序scope时对应的实例也就自然不再被引用,可以自动释放了。我们在第一篇中提到Rust的并发采用的是Actor模型,基于消息传递,在传统的Actor并发模型下我们如果只是将实例的引用传递给某个process,如果实例是可变的那会造成Data Races,如果实例是不可变的我们还是需要通过垃圾收集手段来控制实例生命周期,而在ownership的约束下实例的所有权被转移到目标process从而规避了前面提到的问题,并且依靠ownership的约束大部分时候都已能够满足业务逻辑的需求。


但如只依靠ownership的约束,实际编程中还是会有问题,我们来看下面例子

fn main() {
    let x = box 5i;
    test(x);
    println!("x = {}", *x)
}

fn test(x:Box<int>) {
    println!("x = {}", *x)
}

这段程序的编译会产生错误,原因就在于当调用test(x)时为保障ownership的约束,x对实例的所有权被转移到了函数test的scope内,从而使得函数调用完成后将无法继续使用x,显然ownership约束会在这里产生麻烦,当然我们也可以选择拷贝实例内存来做调用,但这样就要牺牲性能了。为解决这一问题需要引入一个新概念,称之为borrow,采用操作符&,看上去是对变量引用的概念,然而borrow并不仅仅是提供引用的能力,同样还带有编译期的约束来控制实例生命周期,下面我们来看一个borrow的例子:

fn main() {
    let mut x = 5i;
    let y = &x;

    x = 6i;
    println!("y = {}", y)
}

这段程序中 y = &x ,即为Rust中的borrow,这样y只是引用了x并不会对栈上的x进行拷贝,不过这里同时也产生了约束:在y的生命周期范围内(必须小于等于x的生命周期范围),由于y借用了x,x将不能再被修改。可以看到y的生命周期一直到main函数结束,因此尽管x被声明为可变的,x = 6i还是将产生编译错误。通过bowrrow的约束,借用的生命周期得到控制,并且不会产生借出方被修改从而产生悬挂指针的问题。


borrow的引入又进一步解决了问题,但凡事有借有还,borrow来的内容该怎么还回去?例如调用函数借用了结构体的一部分,希望不产生栈上的值拷贝把这一部分作为返回值返回,我们来看下面一段代码:

struct Point {x: f64, y: f64}
fn get_x(p: &Point) -> &f64 {
    &p.x 
}

上面这段程序希望以引用的方式将Point结构的x字段返回,get_x函数显式声明了返回值类型为&f64与&p.x的类型一致,但这段程序是无法通过编译的。原因在于编译器无法通过函数的内容判断返回值是Point结构的一部分,从而推导得知返回值的生命周期应当和p相同,因此在Rust语言中还需要显式的声明生命周期,我们来修改一下上面这段程序:

struct Point {x: f64, y: f64}
fn get_x<'r>(p: &'r Point) -> &'r f64 {
    &p.x 
}

这段程序增加了‘r这样的标记作为类型的一部分,这样函数显示声明了返回值的生命周期与传入的结构体p生命周期一致,编译器就能把返回值类型和声明的类型匹配起来完成编译,这样就又引入了Rust的一大特色--“liftimes也是一种类型”。上面这段程序中由于函数只有一个参数因此生命周期’r是很容易确定的,但如果参数更多一些呢,我们再来看一小段程序:

fn main() {
    let my_num: Box<int> = box 10;
    test(my_num);
}

fn test(my_num:Box<int>) {

unsafe { 
    //将安全的Box<int>类型转换成不安全的指针类型
    let my_num2: *const int = mem::transmute(my_num);
    println!("num = {}", *my_num2);
}

}

在main函数中只有安全的rust代码,通过调用test函数将my_num的ownership进行了转移,在test函数中通过调用 mem::transmute 函数将 Box 类型的my_num转换成了不安全的指针类型(该操作必须位于unsafe代码块中),调用transmute函数时my_num的ownership再次发生转移,但由于transmute是一个C函数,my_num的生命周期并没有在transmute函数中结束而是换了马甲变成了非安全的指针类型并赋值给了my_num2, 这样my_num2指向的实例将不再被Rust管理,而是需要后续将其转换回 Box 类型,交还给Rust处理。通过这样的机制在Rust中可通过转移ownership使得实例不再受生命周期控制的约束,从而可以让C代码自由处理该实例。


在以上的示例程序中transmute函数调用的条件是要求转变前后的类型size不变,否则便会出错,当实例为struct类型时则需要保障Rust中struct的内存布局与C语言中相同,Rust通过提供#[repr(C)]属性来保证其struct的内存布局与同平台下C声明的struct内存布局相同,以支持语言间的互操作。


可以看到Rust与C语言的互操作由于没有运行时的负担达到了前所未有的融合,这也使得Rust有可能突破GC语言的瓶颈可以作为和C/C++一样的系统级编程语言角色。


之所以将Rust通过两篇文章介绍给大家,也在于Rust为编程语言的世界带来了一些有意义的新东西,让我意识到原来编程语言的内存管理也可以这么做,学习编程语言的一大目的也正是在于能够回到软件开发的本源来开拓思路,让我们能够反思问题的本质,所以个人也是非常推荐大家能够有时间来学习Rust,Erlang,...等能够开拓学习者思路的编程语言。


最后再留一个问题,通过对Rust内存管理机制的了解,我们会发现强大的解决方案背后代价是让程序员在开发时需要背负更多的负担,例如需要能够合理的利用类型与约束,这使得我们在编程时需要经常思考业务逻辑以外的问题,比起使用自动GC的动态类型编程语言时行云流水的感觉来大相径庭,这种负担是否是追求高性能、高安全性所必须承担的,是否还有更好的解决方案,大家不妨思考一下。


网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈谔授权发布。