译者 | 卢鑫旺、云昭
策划 | Ethan
编程语言各有各的“大能”,但如果谈到内存管理,Rust的话语权不是一般的高。GC(垃圾回收)?手动分配?对于掌握了Rust奥义的开发者而言,这些词汇简直弱爆了。众所周知,Rust编程语言的主要卖点之一是它的内存安全性。Rust对待内存,非常有自己的个性。与使用垃圾收集器的编程语言(如Haskell、Ruby和Python)不同,Rust为开发人员提供了快速功能,能够以一种独特的方式高效地使用和管理内存。Rust通过使用借用检查器(borrow checker)、所有权(ownership)、借用(borrow)这三个概念来管理和确保跨堆栈和堆的内存安全来管理内存,从而实现内存管理。本文讨论了Rust借用检查器,Rust与其他语言(如Go和C)的内存管理对比,以及Rust借用检查器的缺点。
内存是如何工作的
在讨论Rust如何管理内存之前,先来回顾一下计算机内存是如何工作的。分配给运行程序的计算机内存分为栈和堆。栈是一种线性数据结构,它按顺序存储局部变量,而不用担心内存的分配和重新分配。每个线程都有自己的栈,当线程停止运行时,每个栈都会被释放。数据以后进先出(LIFO)的模式存储——新的数据堆积在旧数据的上面。堆是一种分层数据结构,用于随机存储全局变量,内存分配和重新分配会是一个需要关注的问题。当一个字面量被压入堆栈时,是会有一个确定的内存位置的;这使得分配和重新分配(入栈和出栈)很容易。但是,在堆上分配内存的随机过程会导致使用内存的开销很大,这使得重新分配内存的速度变慢,因为在堆上分配内存时会涉及到复杂的引用记录。局部变量、函数和方法驻留在栈上,其他所有变量驻留在堆上;因为栈有固定的有限大小。Rust通过在堆栈中存储字面量(整数、布尔值等)来有效地处理内存。像结构体和枚举这些类型的变量在编译时由于没有固定的大小,存储在堆中。
所有权(所有权):“值”的主人
所有权是Rust中的一个概念,用来在没有垃圾收集器的情况下保证内存安全。Rust强制执行以下所有权规则:
- 每个值都有一个变量,称为owner(所有者)
- 每个值有且只有一个所有者
- 如果将变量赋值给新的所有者,那么原始值将被删除,否则它现在就会有两个所有者
在程序编译时,Rust编译器在程序编译之前会检查程序是否遵守了这些所有权规则。如果程序遵循所有权规则,则程序编译执行,否则编译失败。
Rust使用借用检查器(borrow checker)来验证所有权规则。借用检查器验证所有权模型以及内存(堆栈或堆)中的值是否超出范围(scope)。如果值超出范围,则释放内存。但这并不意味着访问值的唯一方法是通过原始所有者。这时就引出了"借用"的概念了。
借用(借用):重用有术
为了允许程序重用代码,Rust提供了借用的概念,和指针类似。
所有权可以暂时从所有者处借用,并在借用变量超出范围时归还。可以通过使用&(&)符号传递对所有者变量的引用来借用值。这在函数中非常有用。下面是一个例子:
1. fn list_vectors(vec: &Vec<i32>) {
2. for element in vec {
3. println!("{}", element);
4. }
5. }
- 1.
- 2.
- 3.
- 4.
- 5.
函数也可以通过使用对变量的可变引用来修改借用变量。普通变量可以通过mut关键字将其设置为可变的,那么可变引用只要在&后添加关键字mut就可以了。当然在进行可变引用之前,变量本身必须是可变的。
1. fn add_element(vec: &mut Vec<i32>) -> &mut Vec<i32> {
2. vec.push(4);
3.
4. return vec
5. }
- 1.
- 2.
- 3.
- 4.
- 5.
左右滑动查看完整代码所有权和借用的概念可能看起来没有那么灵活,除非你理解了复制,拷贝,移动的概念,以及它们如何一起工作。
复制所有权
复制通过复制位来复制值。复制仅适用于实现了Copy特征的类型。一些内置类型默认实现Copy特征。在栈中,很容易访问变量并更改所有权,而在堆中复制则不容易,因为位操作涉及位移动和位操作,而栈对于此类操作的组织更有条理。下面是一个在堆中复制值的示例。
1. fn main(){
2. let initial = 6;
3. let later = initial;
4. println!("{}", initial);
5. println!("{}", later);
6.
7. }
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
变量initial和later在同一作用域(范围scope)中声明,然后通过赋值将initial的值复制到later中。
虽然变量在相同的范围内,但initial将不再存在。这是在必须重新分配变量的情况下。输出:
试图打印initial变量的值将会引发编译错误,因为借用检查器注意到有变量的所有权转移了。
那如果你想保留这个值呢?Rust提供了克隆变量的能力。
拷贝变量
你可以将值分配给新所有者,同时使用拷贝的方法保留旧所有者中的值。然而,你所拷贝的类型必须提前实现拷贝特征。
1. fn main(){
2. let initial = String::from("Showing Ownership ");
3. let later = initial.clone();
4. println!("{} == {} [showing successful cloning] ", initial, later)
5. }
- 1.
- 2.
- 3.
- 4.
- 5.
变量initial在变量later的声明中被拷贝,这两个变量驻留在堆中。如果这时被借用,则这两个变量将引用同一个对象;但是,在这种情况下,这两个变量是堆上的新声明,并占用独立的内存地址。
移动所有权
Rust提供了跨作用域更改变量所有权的功能。当函数按值接受参数时,函数中的变量会成为该值的新所有者。如果你不选择移动所有权,可以通过引用传递参数。下面是一个如何将变量的所有权从一个变量转移到另一个变量的示例。
1. fn change_owner(val: String) {
2.
3. println!("{} was moved from its owner and can now be referenced as val", val)
4. }
5.
6. fn main() {
7.
8. let value = String::from("Change Ownership Example");
9. change_owner(value);
10. }
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
change_owner函数获得了之前声明的字符串的所有权,并在接受value变量的值作为参数时获得该字符串的所有权。此时试图打印值变量会导致错误。
Rust借用检查器的缺点
如果Rust的借用检查器一切都很完美,那么其他系统编程语言可能会切换或提供带有借用检查器实现的版本。在内存管理的问题上,它是用户体验和便利性之间的权衡。
各主流编程语言的内存管理方案一览
使用垃圾收集器的语言让内存管理变得更容易,但同时也降低了内存管理的灵活性,而像Rust和C这样的语言让开发人员可以快速访问内存,只要遵守它某些规则,如Rust的所有权规则,以及如何在C中将内存管理留给开发人员。
借用检查器可能是复杂的和有限制性的。随着程序规模的增长,自我确保所有权规则可能会变得困难,并且进行更改的代价可能是昂贵的。虽然Rust编译器通过执行检查来防止类似悬空引用这样的错误,但Rust也为开发人员提供了unsafe关键字,可以让指定代码区块不受检查。如果外部使用了依赖项unsafe关键字,这可能不利于代码安全性。许多开发人员,无论是初学者还是专家,都会从借用检查器中碰到所有权错误,更多的错误来自于在Rust中实现复杂的数据结构和算法。
Rust和C的内存管理比较
C编程语言是一种流行的系统编程语言,它不使用垃圾收集器或借用检查器来管理内存;相反,C让开发人员按照自己的意愿手动和动态地管理内存。
C开发人员可以使用在标准库中定义的malloc()、realloc、free和calloc等函数,用于堆中的内存管理,而栈中的内存一旦超出作用域就会自动释放。
哪种方法更好通常取决于要构建的内容。虽然开发人员可能会发现Rust借用检查器有一些限制,但它使开发人员在管理内存时更加高效,而不需要成为内存管理专家。Rust开发人员也可以选择在没有标准库的情况下使用Rust,并获得类似于C语言的体验,其中所有内存管理都是手动来实现。
带有标准库和借用检查器的Rust更适合用于构建需要处理资源密集型的应用程序。
Rust和Go的内存管理比较
Rust和Go是相当新的、强大的语言,经常在许多方面进行比较,包括内存管理。
Go使用非分代并发、三色标记和清除垃圾收集器以一种不同的方式管理内存,允许开发人员使用new和make函数手动分配内存,而垃圾收集器负责内存回收。
Go的垃圾收集由一个执行代码并向堆分配对象的mutator和一个帮助释放内存的收集器组成。Go还允许开发人员通过使用不安全的或者运行时包关闭垃圾收集器来手动访问和管理内存。运行时模块的debug包通过使用SetGCPercent方法(帮助设置垃圾收集器目标百分比)等方法设置垃圾收集器参数,为调试程序提供功能。
Go的垃圾收集器一直以来在接受来自Go开发者社区的批评,并且在过去的几年里一直在改进。Go开发人员可能希望手动管理内存,并能从语言中获得更多,在默认情况下,垃圾收集器不允许像C等语言提供手动内存管理所提供的灵活性。
在讨论内存管理时,Go和Rust是没法比较的,因为它们有不同的、不相关的内存管理方式,在灵活性和内存安全性之间进行权衡,特别是两种语言的开发人员都想要其他语言使用的东西。
开发人员选择Go来构建需要简单性和灵活性的服务和应用程序,选择Rust来构建需要低级别交互,但对性能和内存安全至关重要的应用程序。
借用检查器:Rust人避不开的坎
借用检查器是Rust之旅中不可绕开的困难。学习曲线在这里变得相当陡峭。伴随着借用检查器的接连不断的报错、警告,许多具有Python和JavaScript等语言背景的Rust崇拜者难免怀疑人生:“跟借用检查器硬刚,有前途吗?,还是放弃吧!”
需要明白的是:任何想要绕开借用检查器的想法都是徒劳的。这是一场你永远也赢不了的决斗。唯一能做的,就是将借用检查器看作是教你如何编写内存效率高的Rust代码的纪律制定者,而你必须通过学习更多关于如何编写更安全、内存效率高的Rust代码来玩好跟借用检查器之间的游戏。
随着编写Rust代码量的增加,开发者当然也会像其他语言一样,将找到防止出现借用检查器常见错误的最佳方法。学会与借用检查器斗智斗勇,开发者避无可避。
结语
毫无疑问,Rust是一种会在未来几年存在并被广泛使用的语言。我们已经看到像Discord和Microsoft这样的公司用Rust重写了他们的一些代码库,因为它能够通过外部函数接口(FFI)与C和c++等多种语言进行交互,还有许多其他公司(如AWS、Mozilla等)在产品的不同环节使用Rust。
所有权和借用是Rust中的基本概念,当你编写更多的Rust程序时,你很有可能会从借用检查器中得到一个错误。使用合适的工具是很重要的;你可以考虑在内存管理不是很重要,并且关心性能的程序中使用Go。
原文链接:
https://stackoverflow.blog/2022/07/14/how-rust-manages-memory-using-ownership-and-borrowing/
译者介绍
卢鑫旺,51CTO社区编辑,编程语言爱好者,对数据库,架构,云原生有浓厚兴趣,目前就职某跨境电商出海营销公司,担任后端开发工作。