Rust成名绝技
Rust 之所以能成为万众瞩目的语言,就是因为其内存安全性。在以往,内存安全几乎都是通过 GC 的方式实现,但是 GC 会引来性能、内存占用以及全停顿等问题,在高性能场景、实时性要求高和系统编程上是不可接受的,因此 Rust 采用了所有权系统。这也是Rust核心、如果不理解所有权Rust就无法深入学习下去。
内存管理方案
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。
手动管理
所有程序都需要和计算机内存打交道,学过C语言的都知道,C语言中我们需要手动管理内存空间。
int main() {
int size;
int *array;
printf("Enter the size of the array: ");
scanf("%d", &size);
// 动态分配内存
array = (int *)malloc(size * sizeof(int));
if (array == NULL) {
printf("Memory allocation failed.\n");
return 1;
}
// 释放内存
free(array);
return 0;
}
手动管理内存会有什么问题呢?
- 内存泄漏:如果你分配了内存但没有显式释放,就会导致内存泄漏。内存泄漏会逐渐消耗可用内存,最终可能导致程序崩溃或运行缓慢。
- 悬空指针:如果你释放了一块内存,但后续仍然使用指向该内存的指针,就会导致悬挂指针。使用悬挂指针可能会导致未定义的行为,包括访问无效内存或者覆盖其他数据。
- 内存访问错误:手动分配内存时,需要确保不会越界访问分配的内存块。如果越界访问数组或者在访问已释放的内存,可能会导致程序崩溃或产生不可预测的结果。
- 多次释放:如果你多次释放同一块内存,会导致内存错误,可能会导致程序崩溃或者破坏其他数据。
- 内存碎片:频繁地分配和释放内存可能会导致内存碎片化,即内存空间被分割成多个小块,无法有效利用。这可能会降低程序的性能。
GC(garbage collection)垃圾回收
采用GC的代表性语言:Java、Go、Js。
这里以JS为例可以看我的这篇文章。
通过所有权来管理内存
代表语言Rust,这种方式,利用编译器在编译时进行规则检查,不会造成运行时的性能损耗,解下来我们详细介绍所有权的概念。
栈和堆是编程语言最核心的数据结构,但是在很多语言中,你并不需要深入了解栈与堆。 但对于 Rust 这样的系统编程语言,值是位于栈上还是堆上非常重要, 因为这会影响程序的行为和性能。
栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。
栈
栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,再从顶部拿走。不能从中间也不能从底部增加或拿走盘子!
增加数据叫做进栈,移出数据则叫做出栈。
因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。
堆
与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。
当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针, 该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。
接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。
由上可知,堆是一种缺乏组织的数据结构。想象一下去餐馆就座吃饭: 进入餐馆,告知服务员有几个人,然后服务员找到一个够大的空桌子(堆上分配的内存空间)并领你们过去。如果有人来迟了,他们也可以通过桌号(栈上的指针)来找到你们坐在哪。
性能区别
在栈上分配内存比在堆上分配内存要快,因为入栈时操作系统无需进行函数调用(或更慢的系统调用)来分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备,如果当前进程分配的内存页不足时,还需要进行系统调用来申请更多内存。 因此,处理器在栈上分配数据会比在堆上分配数据更加高效。
所有权与堆栈
当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助。
所有权
所有权规则:
- 每一个值同时只能被一个变量拥有。(值就像钞票,只能被一个人持有,毕竟钞票带编号没有相同的)
- 当所有者离开作用域范围,该值将被丢弃。
fn main(){
let x = 1; // 1的所有权被x持有,x就是所有者。
}
拷贝Copy trait
关于trait后面会介绍~。
在 Rust 中,实现了 Copy trait 的类型是可复制的。Copy trait 表示类型的值可以通过简单的位拷贝来复制,而不会发生所有权转移。当一个变量的值被复制到另一个变量时,原始变量仍然保留对其值的所有权。
以下是 Rust 标准库中一些常见的实现了 Copy trait 的类型:
- 所有的整数类型(如 i32、u64 等)
- 所有的浮点数类型(如 f32、f64 等)
- bool 类型
- 字符类型 char
- 元组,只有当元组的所有元素都是可复制的时候才能拷贝
let x = 1;
let y = x;// 拷贝 x 的值到 y,x 仍然保留其所有权
println!(x) // 1
可能有同学会有疑问:这种拷贝不消耗性能吗?实际上,这种栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。
所有权转移
当变量离开作用域后,Rust 会自动调用 drop 函数并清理变量的堆内存。不过由于两个 String 变量指向了同一位置。这就有了一个问题:当 s1 和 s2 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 这样解决问题:当 s1 被赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2,s1 在被赋予 s2 后就马上失效了。
Rust对于实现了 Drop trait 的类型是不可复制的,因为它们可能会具有特殊的资源释放行为。当一个不可复制的类型的值被赋值给另一个变量时,所有权会被移动,而不是发生拷贝。例如:
let s1 = String::from("hello Rust");
let s2 = s1;
println!("s1:{},s2:{}",s1,s2);
可以看到报错所有权已经转移,他没有实现Copy trait
.
函数参数和返回值岁有权转移示例:
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
println!(s) // ... 所以到这里不再有效会报错
let x = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,所以在后面可继续使用 x
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 所以不会有特殊操作
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作