Rust能力养成系列之(31): 内存分配

前言

在运行时,进程中的内存分配要么发生在栈(stack)上,要么发生在堆(heap)上。这是两种结构不同的存储位置,用于在程序执行期间存储相应的数值。在本节中,我们将看看这两种分配方法。

栈用于保存短期(short-lived)值(这里的短期是就编译时间来看),并且是函数调用及其上下文关联的理想存储位置,函数返回后需要清除这些值。堆用于任何需要存在于函数调用之外的东西。我在在最早的三篇引入文中提到的,Rust默认进行栈分配,在这种情况下,创建并将绑定到变量的任何类型的值或实例都存储在栈中。而在堆上存储是显式的,通过使用智能指针类型来完成,这将在本章后面解释。

 

每次调用函数或方法时,栈都用于为函数中创建的值分配空间。函数中的所有let绑定都存储在栈中,要么作为值本身,要么作为指向堆上内存位置的指针。这些值构成了活动函数的栈帧(stack frame)。栈帧是堆栈中存储函数调用上下文的逻辑内存区块。这个上下文可能包括函数参数、局部变量、返回地址以及从函数返回后需要恢复的任何被保存的寄存器值。随着越来越多的函数被调用,对应的栈帧被压入栈中。一旦函数返回,对应于该函数的栈帧就被释放了,在该帧中声明的所有值也随之消失。

这些值按照其声明的相反顺序被一一删除,遵循后进先出( Last In First Out,LIFO)的顺序。

栈上的分配是快速的,因为在这里分配和释放内存只需要一条CPU指令:递增/递减栈帧指针( incrementing/decrementing the stack frame pointer)。栈帧指针(esp)是一个总是指向栈顶的CPU寄存器。当函数被调用或返回时,栈帧指针会不断更新。当函数返回时,通过将栈帧指针恢复到该函数进入之前的位置。使用栈是一种临时内存分配策略,但是由于它的简单性,在释放已使用的内存方面是可靠的。然而,栈的属性使其并不适用于存储比当前栈帧存活时间更长的值。

下面的这一段代码,大致说明了在程序中调用函数时,栈是如何更新的:

// stack_basics.rs

fn double_of(b: i32) -> i32 {
    let x = 2 * b;
    x
}

fn main() {
    let a = 12;
    let result = double_of(a);
}

我们将用一个空数组[]来表示这个程序的栈状态。那么让我们通过演示这个程序来探索堆栈内容。这里也使用[]来表示父栈中的栈帧。当这个程序运行时,发生的步骤如下:

  • 当调用main函数时,它创建栈帧,其中保存变量a和result(初始化为0),现在的栈是[[a=12, result=0]]
  • 接下来,调用double_of函数,并将一个新的栈帧压入栈以保存其局部值。栈的内容现在是[[a=12, result=0], [b=12, temp_double=2*x, x=0]]。temp_double是编译器创建的一个临时变量,用于存储2 * x的结果,然后将该结果赋值给在double_of函数中声明的变量x。这个x然后返回给调用者,也就是我们的main函数。
  • 一旦double_of返回,它的栈帧就会从栈中弹出,那么现在栈的内容是[[a=12, result=24]。
  • 接着,main函数结束,它的栈帧被弹出,栈为空:[]。

事实上,还有更多的细节。我们刚刚给出了一个函数调用及其与栈内存交互的非常high-level的概述。现在,如果我们所有的局部值只在函数调用的生命周期内有效,那么它的作用将是非常有限的。虽然栈简单而强大,但为了实用,程序还需要lifetime更长的变量,为此我们需要堆。

 

堆用于更复杂一些和动态的内存分配需求。程序可以在某个点对堆进行分配,也可以在另一个点释放它,而且不需要像栈内存那样在这些点之间有严格的边界。在栈分配的情况下,你得到了确定的值分配和释放。此外,堆中的值可能存在于分配该值的函数之外,之后可能被其他函数释放。在这种情况下,代码无法调用free,因此可能根本无法释放它,这是最令人担忧的情况。

不同的语言使用堆内存的方式不同。在Python这样的动态语言中,所有东西都是对象,默认情况下它们都分配在堆上。在C中,我们使用手动的malloc调用在堆上分配内存,而在C++中,使用new关键字分配内存。要释放内存,我们需要在C语言中调用free,在C++中调用delete。在C++中,为了避免手动delete调用,经常使用智能指针类型,如unique_ptr或shared_ptr。这些智能指针类型具有析构器方法,当它们超出内部作用域时,就会调用这些方法,接着会调用delete。这种管理内存的方式被称为RAII原则,并在C++中得到推广。

Rust也有类似于C++如何管理堆内存的抽象。在这里,在堆上分配内存的唯一方法是通过智能指针类型。Rust中的智能指针类型实现Drop特性,并指定值使用的内存应该如何回收,在语义上类似于C++中的析构器方法(deconstructor methods)。除非有人编写了自定义的智能指针类型,否则永远不需要在他们的类型上实现Drop。关于Drop特性的更多信息,可参见后续单独的章节。

为了在堆上分配内存,编程语言依赖于专用的内存分配器,其通常隐藏了所有底层细节,比如在对齐内存上分配内存,维护空闲内存块以减少系统调用开销,以及在分配内存和其他优化时减少碎片。对于编译程序,编译器rustc本身使用jemalloc分配器,而从Rust构建的库和二进制文件则使用系统分配器。在Linux上,这会是glibc内存分配器APIs。Jemalloc是一个在多线程环境中使用的高效分配器库,它极大地减少了Rust程序的构建时间。尽管编译器使用jemalloc,但任何使用Rust构建的应用程序都不会使用它,因为它增加了二进制文件的大小。因此,编译后的二进制文件和库在默认情况下总是使用系统的分配程序。

Rust也有一个灵活的分配器设计,可以使用系统分配器或任何用户实现的分配器来实现std::alloc模块中的GlobalAlloc特性。这通常是通过#[global_allocator]属性实现的,该属性可以放在任何类型上,并将其声明为分配器。

在Rust中,大多数事先不知道大小的动态类型都在堆上分配。但原语类型(primitive type)除外。例如,创建一个在堆上内部分配的字符串:

let s = String::new("foo");

String::new分配Vec<u8>到堆上;并返回对它的引用。这个引用被绑定到变量s,该变量在栈上分配。只要s在作用域中,堆中的字符串就会一直存在。当s超出范围时,Vec<u8> 从堆中释放,其drop方法作为Drop实现的一部分被调用。在极少数情况下,需要在堆上分配一个基本类型,可以使用Box<T>类型,而这是一个通用的智能指针类型。

 

结语

在下一节中,让我们看看使用C这类语言时的陷阱,因为它不具备自动内存管理的所有优点,所以是一个很好的吐槽时刻,请大家不要错过。

 

主要参考和建议读者进一步阅读的文献

https://doc.rust-lang.org/book

Rust编程之道,2019, 张汉东

The Complete Rust Programming Reference Guide,2019, Rahul Sharma,Vesa Kaihlavirta,Claus Matzinger

Hands-On Data Structures and Algorithms with Rust,2018,Claus Matzinger

Beginning Rust ,2018,Carlo Milanesi

Rust Cookbook,2017,Vigneshwer Dhinakaran

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值