likes
comments
collection
share

什么?你居然还不理解Ownership?

作者站长头像
站长
· 阅读数 47

什么是Ownership

Rust是以性能媲美C语言,又能保证内存安全,在近期火爆崛起的编程语言。Rust是如何做到鱼和熊栈兼得的?这一切都要归功于OwnershipOwnership是Rust能够彻底摆脱垃圾收集器的束缚的同时,还能保证其内存的安全性重要原因之一。理解了Ownership也就理解了这门编程语言的设计哲学。

什么是Ownership呢?一言以蔽之,Ownership是用来确保,中垃圾数据只被精准释放一次(exactly once) ,而提出来的一系列编译规则。

Ownership规则如下:

  • 每一个值都拥有一个Owner(Each value in Rust has an owner)。

  • 每一个值在同一时间最多只有一个Owner(There can only be one owner at a time)。

  • 当Owner脱离出代码块,其所拥有的值会被释放(When the owner goes out of scope, the value will be dropped)。

为什么会有Ownership

Rust的内存分配规则

先说说为什么Ownership的诞生是因为需要处理堆中的垃圾数据,而不是栈中的呢?Rust在调用方法时,方法的参数和方法体中的本地变量的会在栈上分配(动态的部分要在堆中分配,但是也需要在栈中分配出指针的内存空间,用来指向堆中的地址)

When your code calls a function, the values passed into the function (including, potentially, pointers to data on the heap) and the function’s local variables get pushed onto the stack. When the function is over, those values get popped off the stack.

这一点和其他编程语言有所不同。比如Java,Java的栈只存放基本类型的值和对堆中对象的引用,对象的值始终都存放在堆中,并由垃圾收集器负责回收垃圾对象。下面举一个例子说明一下,有如下代码:

struct Labor {
    work_time: u16,
}

fn working() {
    let programmer = Labor {
        work_time: 996,
    };
}

working();

在执行working()方法时,Rust的内存分配如下图所示。

什么?你居然还不理解Ownership?

programmer变量和Labor结构体实例都是被分配在栈中。那么为什么Rust要把变量值也分配到栈中呢?因为在栈上分配内存非常的快,栈顶指向的内存区域就是直接可用的内存块,只需要按所需要的内存大小移动栈顶指针就能完成内存分配,但是要能在栈上分配值,就要要求变量值类型所占用的类型大小在编译期就已知,如果编译期无法知道其所占用的内存大小那么就需要在堆中分配内存,而在堆中分配内存的效率是远远不如栈的。堆中分配内存可没有移动栈顶指针这么简单,当需要在堆中分配内存时,需要在整个堆里找到一块能够空闲的连续空间,因此相比栈要耗时很多。

如果是Java,把Labor结构体当作类,在执行working()方法时,栈中只存放变量和引用,所有的对象实例是会被分配到堆中。

什么?你居然还不理解Ownership?

working()方法执行结束后,栈中的数据会随着方法执行结束而被全部释放。在Java的例子里,栈中programmer变量会被释放,指向的堆中对象实例由垃圾收集器负责回收。在Rust的例子里,programmer变量和值会全部被释放。

从这里可以得知,如果所有的变量的值都可以在栈上分配,随着方法执行完毕一起被回收,这样就内存就能自动回收,就不要有垃圾收集器了。是的,但是我们之前提到过,能在栈上分配的值,要求其类型大小在编译期间就确定,因此如果类型的值大小无法在编译期确定,那么只能分配在堆中,比如递归的结构体。

struct LinkedListNode {
    next: LinkedListNode
}

,LinkedListNode结构体无法通过编译,编译报错recursive type LinkedListNode has infinite size,为什么会这样呢?我们模拟一下编译器的计算过程,要计算LinedListNode的占用空间,首先要计算next字段类型值的大小,next字段是LinkedListNode的类型,因此需要计算LinkedListNode的大小,要计算LinedListNode的占用空间,首先要计算next字段类型值的大小。。。。。。产生无限递归,最终导致LinkedListNode的大小是无限大。因此我们只能把next的值放到堆中,Rust提供Box聪明指针(Smart Pointer)来把值放到堆中。

struct LinkedListNode {
    next: Box<LinkedListNode>
}

这样next的值是一个指向堆中的地址,大小就是确定的(32位占4个字节,64位占8个字节)。

同样String和Vec也无法完全的在栈上分配,因为String和Vec内部的元素是可以在运行时发生变化,编译期间无法确定,比如下面的这个例子:

let mut s = String::from("hello");
s.push('a');

let mut v = vec![1, 2, 3];
v.push(4);

String和Vec都可以在运行时用push()方法对其内部的元素做修改,因而只能把变化的数据放在堆中。

我们拿String举个简单的例子,代码如下:

{
    let s = String::from("hello");
}

来看一下String在Rust的内存结构是怎么样的

什么?你居然还不理解Ownership?

String的len和capacity字段值类型是usize,是固定的(32位占4个字节,64位占8个字节)分配在栈中这点没有什么疑问。但是String类型保存的"hello"字符数组就不能在栈中分配了,因为运行时可以对字符数组作修改,比如s.push('a'),这时需要重新分配额外的内存空间来存放多出来的'a'这个字符,动态分配内存必须要在堆中,因此就只能用ptr字段作为指针(指针大小固定)来指向堆内存的一块空间,我们这里假设该堆内存的起始地址为0x33。那么要如何释放0x33的内存空间呢?

这样新的问题就产生了,要如何安全释放堆中分配的内存呢?还记得我们开头提到过的,Ownership的产生是就要解决中垃圾如何释放的问题。那么释放堆中的内存到底会遇到什么样问题呢?

Double free带来的问题

我们接着上一个例子继续分析:

{
    let s = String::from("hello");
}

什么?你居然还不理解Ownership?

假设如果s所在的代码块执行完毕后,也把ptr指向的0x33堆内存也一起释放不就行了吗?咋一看是可以, 但是场景稍微再复杂一点就出问题了,比如下面这样:

{
    let s = String::from("hello");
    let s2 = s;
}

就多了一行,把s的值赋值给s2会出问什么问题呢?到这里就必须需要引入Rust的另外一个知识点,Rust的参数传递是值传递,准确的来说是浅拷贝。浅拷贝会把s的字段值全部复制到s2,也就是下面这样:

什么?你居然还不理解Ownership?

s.ptr是指针保存的是堆空间的内存地址,复制到s2.ptr后,两个变量的ptr字段都指向同一块堆内存地址,在代码块执行完毕后,准备释放栈空间,s2是后入栈,因此先出栈,出栈后如果按假设,s2.ptr也把指向的堆内存地址也就是0x33一起回收,那么在出s栈准备出栈时,就面临一个问题,到底要不要释放s.ptr所指向的内存地址呢?答案是不行,因为0x33已在s2在出栈时释放,此时也许0x33这个内存地址已经被分配给其他变量了,如果s在出栈时不管三七二十一也释放0x33的内存,s也许就把别人的内存给释放了,这种把一块内存地址连续释放了两遍的行为被称之为double free,这会导致完全不可预知的异常,是十分危险的。

为了解决这个double free问题,Rust在浅拷贝赋值后多做了一个动作,把赋值浅拷贝后的原变量置为不可用,并称之为MoveMove的是什么东西呢,其实就是Ownership。为了说明清楚,我们在上面的例子上稍作修改,加一了行打印s变量的值:

{
    let s = String::from("hello");
    let s2 = s;

    println!("{s}")  // 会编译失败
}

第一行:let s = String::from("hello"); 我们称之为,s拥有其String类型值的Ownership

第二行:let s2 = s; 我们称之为,s把其值的Ownership Move了给s2,Move过后s变量无法再被使用。

第三行:如果使用被Move后的变量会导致编译失败。

画个图应该能理解更清楚一点:

什么?你居然还不理解Ownership?

引入Ownership可以被Move这个机制后,这样就能完美解决了double free的问题。

只有当变量值拥有其值的Ownership时,在出栈时才能释放其指向的堆内存空间。

我们按这个逻辑再次分析一下两个变量出栈的场景。

在代码块执行完毕后需要释放栈内存,s2是后入栈,因此先出栈,出栈时s2拥有其值的Ownership,因此也把指向的堆内存地址也就是0x33一起回收。等到s栈准备出栈时,发现自己对其值没有Ownership,自己值的Ownership在赋值给s2时Move给了s2,因此s在出栈时就不管s.ptr所指向的堆内存空间,只要简单在出栈释时放自己占用的栈空间就行。这样就解决了堆空间被double free的问题。

Move与Copy

我们前面提到过,如果值完全能够在栈上分配,在赋值的时候没有即使不引入Ownership也不会有什么问题,赋值过后栈空间的两份数据是完全独立的。为了能够让编译器知道那些类型值是完全可以在栈上分配的,Rust提出了Copy trait(trait可以理解为Java语言里的接口)作为一个标记,因为只是一个标记,所以Copy trait并没有任何方法需要实现,只是用来告诉编译器,这个值是可以完全在栈上分配的。因为赋值后两份值都是在栈空间完全独立,所以压根不在存堆中分配内存,也就没有double free的问题,如果也用Move,让赋值后的变量无法被使用,是没有意义的。基于此,Rust对于实现Copy trait的值做赋值动作称之为Copy

那么那些类型实现了Copy trait呢?Rust规定能够实现Copy trait的类型必须是完全能够在栈上分配的,被称作Stack-Only Data,目前有下面的这些类型默认实现了Copy trait:

  • 所有的整数类型, 比如 u8、i8、 i32、 u32等等.
  • bool类型,true 和 false.
  • 所有的赋点类型, 比如 f64.
  • 字符类型 char.
  • 所有包含的类型都实现了Copy trait的Tuples类型, 比如 (i32, i32)。举个反例,(i32, String) 里的String不能实现Copy trait, 所以(i32, String) tuple 也不能实现Copy trait.

举一个例子:

let x: i32 = 5;
let y: i32 = x;

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

变量x的值是i32类型,i32类型实现了Copy trait,所以在赋值时会Copy一份数据栈中x的值给变量y,x和y是两份独立的数据,因此最后的println打印x和y变量并不会编译报错。

总之,Rust规定赋值动作只有两种选择,如果其类型实现了Copy trait那么为Copy,否则一律为Move

总结

说了这么多,希望大家没有被绕晕了。这里最后总结一下,Rust想要摆脱了垃圾收集器的束缚,就需要先解决堆内存double free问题,因而提出了Ownership规则,变量赋值时引入了Move的概念表示Ownership的转移。当然如果值完全能够在栈内存中分配,也就不需要Ownership,因而提出了Copy trait这个标记,帮助编译器识别非Move的场景,并把这种赋值动作称之为Copy。最后,Rust的赋值行为要么是Copy要么是Move

如果对文章有任何问题,请不吝赐教,谢谢大家。

转载自:https://juejin.cn/post/7248606302897127482
评论
请登录