浅谈Rust内存管理

Rust因在内存管理上的独到之处,近年来受到了不少开发者的青睐。Rust内存管理的核心功能就是所有权。不同的语言采取了不同的内存管理方式,主要分为开发者手动管理或者编译器辅助管理,以及垃圾回收机制等。Rust的所有权机制,有别于这两者。

堆栈内存

我们知道程序会在堆或者栈上创建数据。栈上创建数据很容易,只要知道数据的大小,移动栈顶指针就能开辟出所需要的空间。对它的访问,代码层面我们有变量,汇编层面只是一个相对于栈顶地址的偏移而已,这个偏移量往往很小,所以访问栈数据很快。而堆上创建数据,则比较麻烦,我们要从内存空间,寻找一块合适大小的内存,然后"开辟"出来,也就是需要登记下,哪一块空间被使用了。然后需要存储这个地址的开始地址,占用大小,实际使用大小等。访问这块内存的话,也要根据记录的开始地址,去内存空间里寻址,这个过程相对比较慢。

我们使用堆内存,是因为它有一些栈内存没有的好处:1.可以动态开辟。栈中的所有数据都必须占用已知且固定的大小。我们在编译阶段,已经能知道栈上的变量的相对位置关系和大小。而我们可以根据需要,开辟任意允许大小的堆上数据。开辟的这些数据,我们也无法确定它们之间的位置关系。2.堆内存可以共享。因为指针和实际内存的分离,使得我们可以使多个指针指向同一块内存来实现数据共享。但是因为栈变量即栈内存,所以两个变量也就意味着两份存储。3.可以操控的生命周期。一旦发生清栈,栈上开辟的内存就会被销毁。而堆上的内存可以存在很久,有些语言里需要开发者手动清除,有一些需要一些回收机制来清理。

所有权规则

我们先提出Rust的所有权规则:

  1. Rust 中的每一个值都有一个 所有者owner)。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

这里的作用域指的是变量的有效使用范围。基于我们前面对堆栈的认识,我们发现,栈上的变量是符合上述描述的。栈变量即栈内存,变量既是值的唯一所有者,当我们离开变量的作用域,编译器不允许我们再次使用这个变量,此时它已经被丢弃,后续清栈的时候,它就会被销毁。但是对于堆内存,上述规则在C、C++等语言里就不符合了。有些语言通过在变量作用域结束的地方插入释放变量所指内存的代码,来实现内存管理。但是如果有多个指针指向同一个内存,就存在多次释放的问题。于是有的语言就使用了引用计数的技术,每个指针变量都是内存的所有者。变量离开作用域的时候,只减少引用计数,并不立即执行释放内存的操作。待等到引用计数降为0的时候,才释放内存。Rust则从源头控制任意时刻只有一个所有者,这样所有者离开作用域,就能立即丢弃这个值。

变量与数据交互方式

那如何确保上述规则呢,这里就要提到变量与数据交互的几种方式:移动、克隆、引用(借用)。

移动是Rust语言有别于其他语言的一种行为。在C/C++中,我们多个指针变量可以指向同一块内存,可以对任意一个调用free或者delete。但是Rust中为了确保唯一所有者这个规则,当你用另一个变量指向当前变量指向的内存时,它认为你后续不会再使用前面这个变量了,也就是把所有权转移给另一个变量了。

如果你要保持当前指针的有效,其中的一个办法就是克隆一块新的内存,这样两块内存,两个所有者就没有违反上述规则了。对于堆内存而言,通常需要调用clone方法。对于栈内存而言,类似int之类的编译时已知大小的类型,拷贝往往是自动发生的。而一些特殊的自定义类型,需要实现Copy trait。

但是克隆大内存往往会产生比较大的消耗,而且往往我们并不需要一块新的内存。我们能不能在不转移所有权的情况下,用其他变量去借用一下这块内存?有,这就是引用。引用很好,既没有转移所有权,也没有增加所有者。但是引用引入了新的问题。

引用

数据竞争

引用给了我们用其他变量访问同一块内存的能力,而且不会转移所有权,这很棒,但是默认情况下,引用只能读取值,而不能修改值。除非你声明一个可变引用:

rust 复制代码
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

现在有了能读能写的引用,看起来一切很美好,但是这就引入了读写竞争问题。

数据竞争data race)类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。所以两个可变引用、一个可变引用多个不可变引用都不能在生命周期内发生重叠,否则编译器会报错。

悬垂引用

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。Rust通过检查所有者的生命周期,来确保引用的有效性。

生命周期注解

生命周期的主要目标是避免**悬垂引用,**大部分时候生命周期是隐含并可以推断的,但也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。来看一个例子:

rust 复制代码
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if 还是 else 会被执行。我们也不知道传入的引用的具体生命周期,所以也就不能通过观察作用域来确定返回的引用是否总是有效。 为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。

生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。

生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a 作为第一个生命周期注解。生命周期参数注解位于引用的 & 之后,并有一个空格来将引用类型与生命周期注解分隔开。

简而言之,通过生命周期注解,我们告诉Rust函数有哪些参数与其返回值的生命周期进行关联。函数只能在它们生命周期重叠区域使用。

部分引用-slice

前面我们讲的引用都是引用整体,对于一些序列型的数据类型,例如字符串和array,我们可以引用其部分数据,这种引用类型称为slice。字符串slice使用下标区间从字符串获得引用值:

rust 复制代码
fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

字符串字面值的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

我们也会想要引用数组的一部分。我们可以这样做:

rust 复制代码
let a = [1, 2, 3, 4, 5]; 
let slice = &a[1..3]; 
assert_eq!(slice, &[2, 3]); 

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。

智能指针

智能指针smart pointers )是一类数据结构,他们的表现类似指针,但是也拥有额外的元数据和功能。在 Rust 中普通引用和智能指针的一个区别是,引用是一类只借用数据的指针;相反,在大部分情况下,智能指针 拥有 他们指向的数据。

智能指针通常使用结构体实现。智能指针不同于结构体的地方在于其实现了 DerefDrop trait。Deref trait 允许智能指针结构体实例表现的像引用一样,这样就可以编写既用于引用、又用于智能指针的代码。Drop trait 允许我们自定义当智能指针离开作用域时运行的代码。

Box<T>

前面我们提到了,栈上的值不会被多个变量共享,只会产生多个拷贝。但有时候我们希望将值在堆上开辟。这里使我们不得不这么做的一个例子是递归类型。Box<T>使我们能够像指针变量一样访问值类型。它们多用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
  • 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候

RC<T>

前面我们提到了Rust是单所有权的,如果想使用多所有权,就是要使用引用计数。Rust的Rc<T>类型可以实现这个功能。Rc<T> 只能用于单线程场景。可以通过RC::clone()来增加引用计数,Rc<T> 的引用计数叫strong_count。而Weak<T>的引用计数叫weak_count。通过不可变引用, Rc<T> 允许在程序的多个部分之间只读地共享数据。

RefCell<T>

前面我们提到,借用规则规定在有不可变引用时,不可以改变数据。然而,特定情况下,令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的。值方法外部的代码就不能修改其值了。RefCell<T> 是一个获得内部可变性的方法。RefCell<T> 并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应地在运行时检查借用规则。如果违反了这些规则,会出现 panic 而不是编译错误。

当创建不可变和可变引用时,我们分别使用 &&mut 语法。对于 RefCell<T> 来说,则是 borrowborrow_mut 方法,这属于 RefCell<T> 安全 API 的一部分。borrow 方法返回 Ref<T> 类型的智能指针,borrow_mut 方法返回 RefMut<T> 类型的智能指针。这两个类型都实现了 Deref,所以可以当作常规引用对待。

RefCell<T> 记录当前有多少个活动的 Ref<T>RefMut<T> 智能指针。每次调用 borrowRefCell<T> 将活动的不可变借用计数加一。当 Ref<T> 值离开作用域时,不可变借用计数减一。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。

总结:相较于RC<T>的多所有权,RefCell<T>是单所有权的。它们都允许有多个引用,但是RC<T>的引用都是不可变的,而RefCell<T>可以返回可变引用。Box<T> 允许在编译时执行不可变或可变借用检查;RefCell<T> 允许在运行时执行不可变或可变借用检查。

Weak<T>

在使用引用计数的语言里,都面临一个因循环引用导致引用计数不能减少到0,而造成内存泄露的问题。可以使用Weak<T>来解决该问题。

参考:

1.Rust 程序设计语言 简体中文版

相关推荐
远望清一色14 分钟前
基于MATLAB的实现垃圾分类Matlab源码
开发语言·matlab
confiself23 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
XiaoLeisj35 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
杜杜的man38 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*39 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家40 分钟前
go语言中package详解
开发语言·golang·xcode
llllinuuu41 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s41 分钟前
Golang--协程和管道
开发语言·后端·golang
王大锤439143 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
为什么这亚子44 分钟前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算