写给前端开发者的 Rust 入门指南 Part 1 - 内存

2,736 阅读12分钟

我正在参加「掘金·启航计划」

作为前端工程师,习惯了 JavaScript 的高效表达能力:箭头函数、async await、模块化...开发者不用操心内存分配,垃圾回收,编译优化等脏活累活。不愧是高级语言,挥舞宝剑吧,兵来将挡,水来土掩。但如果你的「剑术」够好了,想要修炼一下内功,不妨学习一门底层语言 -- Rust

学习一门底层语言,犹如修炼内功,帮助我们以更底层的视角看待编程,写出健壮安全的代码。修炼 Rust 绝非一朝一夕,本文旨在从 JavaScript 开发者的角度,对比和厘清 Rust 中那些有趣的,难啃的,或独有的概念,帮助同胞们迈出学习 Rust 的第一步。

完整文章传送门:

写给前端开发者的 Rust 入门指南 Part 1 - 内存 📍当前位置

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

写给前端开发者的 Rust 入门指南 Part 3 - 借用

写给前端开发者的 Rust 入门指南 Part 4 - 生命周期

一切,都要从内存说起。

栈内存!堆内存!

之所以标题加了两个感叹号,是因为想要充满气势地告诉大家:得内存者得天下!学习一门底层语言,其内存管理机制是绕不开的课题,如何安全有效地分配、使用、释放内存,是每一门语言的必修课。Rust 正是以其优秀的内存安全闻名天下。学习 Rust,内存知识是基础之基础,核心之核心。

计算机程序运行的时候,所有的值,都是需要储存在内存中的。但由于值的类型不同,有的值是静态的,有的值是动态的,有的值较小,有的值很大... 因此内存也分成两种 -- 栈内存和堆内存,用于存储不同类型的数据。

说起栈内存(Stack Memory)堆内存(Heap Memory),JS 开发者们其实并不陌生。我们知道 JS 的数据类型分成基础类型引用类型,基础类型值就是字符串,数字,布尔值等等,引用类型值就是对象啊,数组啊,这些我们很熟悉。

在 JS 中,基础类型值储存在栈内存中;引用类型值储存在堆内存中,并将其值的引用放到栈内存中

js_stack_heap.png

上面的 JS 代码中,先后声明了的 a,b,c 这 3 个变量。a 和 b 分别是数字和字符串,属于基础类型值,直接储存在栈内存上;c 是一个对象,属于引用类型,储存在堆上,并把值的引用指针(堆上的内存地址)放到栈上,这样,程序在读写 c 的时候,可以通过其栈上的指针,从堆上定位到它的值了。

如果你能理解以上内容,那太好了,因为 Rust 也遵循以上的基本规则。

栈内存(Stack Memory)

栈是一个后进先出的队列结构。我们不妨把栈内存想象成一个个小卡槽整齐排列的样子,每个小槽是大小相等的内存空间:

stack_memory.png

在 Rust 中声明基础类型值的时候:

let a = 1;
let b = 2;
let c = 3;

上面竟然是 Rust 代码?和 JS 一模一样啊!也用 let 关键字?是的呢。但别忘了,Rust 是一门静态类型的底层语言,变量在定义的时候一定有确定的类型,以上代码,Rust 编译器会将其转化成:

let a: i32 = 1;
let b: i32 = 2;
let c: i32 = 3;

如果你声明了整数的变量,又没有明确指定类型,Rust 编译器会默认推断这个变量是 i32 类型的。正如你用 TypeScript 声明一个数字,TS 编译器会推断这个变量是 number 类型:

let a = 1

// TS 编译器推断 👇

let a: number = 1

回到 Rust 中来。i32 是 Rust 中的一个基本类型,表示带符号 32 位整数(Signed Integer),32 位的意思是计算机会用 32 个比特(bit)来存这个数字,带符号的意思是带着正负号,比如 1 和 -1 都是 i32 类型。当然,你也可以自己指定整数的类型:

let a: i16 = 1; // 16位整数,占 2 个字节
let b: i32 = 2; // 32位整数,占 4 个字节
let c: i64 = 3; // 64位整数,占 8 个字节

上面这三个 a,b,c 变量先后申明,我们来看看他们在栈内存中的样子:

abc_stack.png

一个接着一个排列得整整齐齐,之所以整齐,是因为这些变量的数据类型,在编译期间可以确定其大小,后续不会改变了。比如在定义变量 b 时候,b 是32位整数,需要占用 4 个字节,于是 Rust 向操作系统申请了 4 个字节的连续内存空间,由于栈内存的连续性,分配内存的时候只需要在当前栈内存的顶部 -- 即变量a,的内存空间后面,顺着腾出 4 个字节的空间,把变量 b 塞进去。

也正因为这些数据排列得整整齐齐,所以要读取数据的时候非常快。举一个生动但不很精确的例子:想象你是一个餐厅的服务员,要给整个餐厅的顾客点菜,如果顾客整齐地一桌接一桌挨着坐,你只需要按顺序从第一桌走到最后一桌就搞定了,很有效率。但如果顾客坐得东一桌西一桌,你要在桌子间来去穿梭,还要记清楚客人到店的先后顺序,很容易乱做一团。

另外,好东西一般都是稀缺资源,栈这么好用,操作系统为了整体性能会限制栈内存的大小,在 JS 中,不同的宿主环境(不同浏览器,Node.js)有不同的限制,在 Rust 中,主线程的栈大小是 8mb,普通线程 2mb。所以,无论如何哪一门语言,都会有栈溢出这个恐怖故事:比如,你写了一个没有返回的递归函数,不断往栈顶加数据,总会等到栈爆炸的那一刻。

总结一下,栈内存是一个排列整齐,存取高效的内存空间,用以储存那些静态的,在编译期大小确定的数据,栈的大小是有限的,程序编写不当会导致栈溢出。

堆内存(Heap Memory)

栈内存高效又整齐,但无法应对所有类型的数据,比如动态类型的数据结构, 也就是那些在编译期间无法确定大小,程序运行期间动态变化的数据。比如动态变化的数组:

fn logData(data: Vec<i32>) {
  println!("{:?}", data)
}

即使完全不懂 Rust,也大概可以看出:以上代码定义了一个 logData 函数,接收一个参数 - data,然后把它打印出来。

参数 data 的类型是 Vec<i32>, Vec 是 Rust 的动态数组类型,紧跟着尖括号里的类型 <i32>,表示这个数组里面可以放什么类型的值。上文说过,i32 代表 32 位整数,所以,Vec<i32> 表明了参数 data 是一个动态数组,里面放的都是 i32 整数。类比一下,以上的 logData 函数,等同于以下 TypeScript 代码(TS 更接近 Rust,便于举例对比):

function logData(data: Array<number>) {
	console.log(data)
}

回到 Rust 代码。函数是编译的最小单元,logData 函数在编译期的时候,编译器干巴巴地盯着 logData,无法未卜先知 data 参数会传入长度是多少的动态数组。只有在运行时,函数调用的当下,才能知道传入的数据的大小:

	...
  let v1 = vec![1, 2, 3];
  let v2 = vec![1, 2, 3, 4, 5];
  
  // 函数调用
  logData(v1);
  logData(v2);
	...

上面的代码中, vec![x, x, x] 语法初始化了两个动态数组:v1 的长度是 3,v2 的长度是 5。调用 logData 时,如果传入 v1,参数 data 的长度就是 3,传入 v2,就是 5。请思考:这个大小飘忽不定的 data 参数,如果要储存到栈内存中,需要申请一个多大的内存空间?办不到呀,申请空间小了,装不下数据,申请大了,浪费空间,最终导致栈内存无法整整齐齐的了,这可不行!

堆内存就是解决这个问题的。我们姑且想象成有一堆「不那么规整,但很大的」的内存空间。存数据的时候,比如上例中的动态数组 v1(vec![1,2,3]),初始化的时候长度是 3,后面可能会变,没关系,那就先在堆内存中找到一段长度至少为 3 的连续空间,存进去就完事儿:

vec_in_heap_1.png

由于是动态数组,值有可能动态变化,比如,往 v1 中添加 1 个元素:

// 使用 mut 关键词,声明的变量才可以修改
let mut v1 = vec![1, 2, 3]; 
v1.push(4);

这样一来,原先申请的空间就装不下有 4 个元素的 v1 了。这种情况下,Rust 会在堆内存里面重新找一个足够大空间(至少长度是4吧),再把旧的数组拷贝过去,加上新的数据,最后释放原先占用的旧内存空间,这个过程叫做内存再分配。数据拷贝腾挪之后,原先的被释放的内存地址,就在堆上留下了一个长度为 3 的空洞,堆内存上可能这里一个洞,那里一个洞的,有的洞能够重新利用,有的洞可能由于某些原因再也没法用了,形成内存碎片。所谓碎片整理大致就是把七零八碎的内存排列整齐,确保高效使用。

vec_heap_2.png

可以想象,如果数据很大,再分配过程是很消耗性能的。因此,实际上,堆内存变量创建的时候,不会只申请正正好的大小,都会多申请一些,比如 v1(vec![1,2,3])在初始化的时候,会在堆内存里面申请一块连续的,容量为 6 的空间(双倍容量),然后把 1,2,3 依次摆在头三位:

vec_heap_3.png

这种情况下,后续数组如果要添加新元素,也至少容纳得下 3 个新元素,不至于马上触发再分配和拷贝,提高了内存效率。

我们知道如何往堆上储存数据了,那么,程序运行时,怎么在乱糟糟的堆里找到这个数据呢?如前文所述,储存在堆上的数据,会在栈上留下一个指针指向它:

heap_in_stack.png

堆上数据在栈上的引用指针,包括 3 个数据, address 是堆上数据的内存地址,通过地址可以直接找到值,length 是 v1 当前的长度(3),capacity 是申请的这一块堆内存,最大容量是多少(6)。

如果你预先确定动态数组的最大长度,比如最多不超过 5 个元素,可以使用 Vec::with_capacity(5),在堆上分配一个长度为 5 的空间。既节省了空间,又可以避免重新分配的性能损耗。说到这里,不知道你有没感受到底层语言的强大操控能力,只要你愿意,你可以精确控制程序的内存状态,这是写 JS 没有的体验,挺新鲜的!

从堆内存中申请空间,很像在大学教室里找座位,比如你想和舍友 4 个坐在一起,那么找到连着 4 个空位,就可以坐进去了,无所谓座位在教室的哪个地方,也无所谓周围是不是还有多余的空位,只要有连着的 4 个空位就 OK。如果你们落座之后,隔壁宿舍的 4 个人来了,也想和你们坐一起,如果你们周围的空位不足 4 个,你们就要起身,另找有 8 个连着的空位。于是以后你们干脆,一开始就找连着的 8 个空位,先占着,避免起身挪来挪去,影响课堂纪律(😄)。

另外,与栈内存相反,操作系统一般不会限制堆内存的大小,堆内存只受计算机物理内存的限制,比如,你可以给你的电脑加个内存条,堆内存就被你堆上去了。

总而言之,堆内存用来储存编译期大小未知,或者大小会动态变化的数据类型。堆上的数据,会在栈上存放一个指针,指向自己。堆内存没有栈内存的性能高,原因是通过指针寻址需要时间,且数据动态变化,原内存放不下的时候,需要重新分配迁移内存。相比稀缺的栈内存,堆内存可以管饱,只受计算机的物理内存限制。

内存的释放

数据无论是存在栈内存,或是堆内存,用完之后,都需要释放这一块内存空间,否则再强大的计算机都会内存告急并宕机。既然内存分为栈内存和堆内存,释放机制自然也是不同的,特别是堆内存的释放,是块难啃的骨头。主流的内存释放机制大致分为两大流派:

  1. 垃圾回收(Garbage Collection,简称GC)流派。代表语言 JavaScript,Java,Go 等。GC 程序会默默潜伏在程序运行时背后,勤劳地检查每个变量是不是访问不到了(标记清除),或者用不着了(引用计数)。然后在它觉得合适的时候释放被占用的内存。GC 的优点是不需要开发者操心,缺点是 GC 本身会影响程序的性能;
  2. 手动流派。典型课代表 - C++,内存的申请和释放,都靠手动函数调用。优点是控制粒度极高,缺点是需要高度自律,容易出错;

等一下,那本文的主角 Rust 用的是哪一种内存释放机制?别急,Rust 走的是一条特立独行的道路 -- 所有权。按下不表,且听下回分解。

总结

内存是程序的载体,分为栈内存和堆内存。栈内存可以存放静态的,在编译期大小可知的数据;堆内存储存动态的,编译期无法确定大小,运行时大小可伸缩的数据。栈内存是高效精确的瑞士军刀,堆内存是灵活凶猛的武士大刀。

带上这双刀,开始 Rust 的冒险吧。