MENU

Rust 所有权

• June 23, 2022 • Read: 1380 • Rust

栈(Stack)与堆(Heap)

在了解 Rust 所有权前,我们需要知道在 Rust 中数据是如何储存的。

栈和堆都是在代码运行时可供存储数据的内存。

对于栈来说,其中存储的所有数据都必须有固定的大小,所以栈中可以存储 Rust 中一些固定大小的数据类型,如 u8i32 等,当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

对于像 String 这种在编译时大小未知或大小可能变化的数据,我们不能将它放在栈中,而是要将它放入堆中。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针(pointer)。对于指针来说,它的大小是固定的,所以指针可以存储在栈中。

访问堆中的数据比访问栈中的数据要慢,因为堆中的数据都需要用指针来进行访问,一般要经过多次跳转,而存储在栈中的数据则可以直接获取数值。在堆上分配大量的空间也可能消耗时间。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题就是所有权系统要处理的。

垃圾回收(Garbage Collector, GC)

由于如 String 这样的大小不定的数据类型需要在运行时动态分配内存,这就意味着:

  • 需要有一个分配内存给 String 的方法
  • 需要一个在 String 使用结束后将内存返还的方法

其中第二部分将内存返还的方法就是垃圾回收方法(以下简称 GC)。

对于像 PythonJava 这类拥有 GC 机制的语言来说,我们并不需要关心内存的释放。而对于 C/C++ 这类没有 GC 机制的语言来说,需要程序员手动进行 allocatefree。而在 Rust 中采用了一个不同的机制:内存在拥有它的变量离开作用域后就被自动释放。当变量离开它所处的作用域后,Rust 会自动调用一个名为 drop 的函数,在 drop 中开发者可以自行编写释放内存的代码,Rust 也会在结尾的 } 处自动调用 drop 函数。

{
    let s: String = String::from("hello world");
}  // 自动调用 drop 函数
println!("{}", s);  // 报错,因为s在离开它所处的作用域后便被释放

深拷贝(deep copy)与浅拷贝(shallow copy)

深拷贝和浅拷贝是很重要的一个问题,也是许多人包括我自己在编程的时候会遇到的坑。

对于 u32 这种基本数据类型来说是没有深拷贝和浅拷贝的问题的,它们在进行赋值操作时是直接将数值赋给新的变量,所以并不需担心。
而对于 String 这种不定大小的类型来说,深拷贝和浅拷贝有着很大的区别。

我们都知道,在 Rust 中有着指针的概念,指针也就是内存地址,指针变量是用来存放内存地址的变量,这点与 C/C++ 相同,所谓浅拷贝就是将对象的指针复制一份给新的变量,这样两个指针指向的是同一对象,但是对一个变量操作时,另一个变量因为也指向此内存地址,所以值也会更改。

# 因为在 Rust 中将 a 赋给 b 后 a 会被无效化,所以这里用 python 中的列表做演示
a = [1, 2, 3, 4, 5]
b = a
a.append(6)
print(a)  # > [1, 2, 3, 4, 5, 6]
print(b)  # > [1, 2, 3, 4, 5, 6]

可以看出,在对 a 做出添加 6 的操作后,b 的值也被改变了

如果我们想要两个变量各自存储独立的数据,即指针指向不同的内存地址,并且数值相同的话,就要用到深拷贝了。深拷贝就是将原来的数据复制一份,将其存入内存后返回新分配的内存地址给被赋值的新变量,这样就做到了真正的复制。

# python 中的深拷贝
a = [1, 2, 3, 4, 5]
b = a.copy()
a.append(6)
print(a)  # > [1, 2, 3, 4, 5, 6]
print(b)  # > [1, 2, 3, 4, 5]

之前有写到 Rust 在变量离开作用域后会自动调用 drop 函数,所以如果当使用浅拷贝的时候,两个变量都指向同一指针地址,自动调用 drop 函数对两个变量进行回收的时候就会出现对同一内存地址回收两次的问题。这是一个叫做 二次释放(double free)的错误,也是内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。而 Rust 为了解决这个问题引入了 所有权 的概念

所有权

说了这么多,那么究竟什么才是所有权?
正如上文所说,一些语言中具有垃圾回收机制,在程序运行时不断地寻找不再使用的内存;在另一些语言中,开发者必须亲自分配和释放内存。而所有权系统就是 Rust 所独有的第三种内存管理机制,正是因为所有权系统,Rust 才能够在没有垃圾回收机制的前提下保证内存安全。

所有权规则:

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

为了演示所有权的规则,下面将用 String 类型作为例子

String 类型可以用如下语句创建:

let s = String::from("hello, world");

变量与数据交互的方式其一:移动

let s1 = String::from("hello");
let s2 = s1;

这是一段赋值代码,第一眼看上去,它可能和其他语言如 C/C++ 中的浅拷贝是一样的,将 s1 所指向的内存地址赋值给 s2,但是在 Rust 中却完全不是这样,s1 在被赋值给 s2 后,就被自动回收了,也就是说,在 let s2 = s1; 这条语句后,你就无法再对 s1 进行任何操作了,因为它已经被无效化了。
所以当你执行如下代码时:

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

你将得到一个类似如下的错误:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error

这正是 Rust 所有权规则中最重要的一条:值在任一时刻有且只有一个所有者。而这也解决了浅拷贝所造成的潜在的 二次释放 问题。

这里有一个问题,既然浅拷贝会造成一些内存安全问题,那为什么 Rust 不默认赋值为深拷贝呢?

实际上,深拷贝对于性能的影响是较大的,例如 String 这种类型,他存储的数据可能非常长,而进行复制的操作耗时也会相应地增加,因此,Rust 永远也不会自动创建数据的 “深拷贝”。

变量与数据交互的方式其二:克隆(深拷贝)

查看如下代码:

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

因为这段代码中的 s1 所存储的数值被复制到了一个新的内存地址,而 s1 所指向的值并未交出自己的所有权,还是只有 s1 一个变量指向此内存地址,所以在执行 let s2 = s1.clone(); 语句后, s1 并没有被无效化,所以编译也不会报错。

栈上的数据:拷贝

对于存储在栈上的固定大小的数据,值的复制是很快的,所以对于这些变量来讲就没有必要适用于所有权规则了,在赋值时直接进行复制存入栈中即可。

值得注意的是,这类数据类型一般都会有一个叫做 Copy trait 的特殊标注,如果一个类型实现了Copy` trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。

如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

引用

我们观察下列代码:

let s = String::from("hello, world");

fn get_len(s: String) -> usize {
    s.len()
}

println!("The length of '{}' is {}.", s, get_len(s));

这是一段输出字符串长度的代码,将其编译会得到以下错误:

error[E0505]: cannot move out of `s` because it is borrowed
  --> src\main.rs:20:54
   |
20 |     println!("The length of '{}' is {}.", s, get_len(s));
   |     -------------------------------------------------^---
   |     |                                     |          |
   |     |                                     |          move out of `s` occurs here
   |     |                                     borrow of `s` occurs here
   |     borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0505`.

在了解过所有权后,我们就会知道上述代码其实是不能够运行的,原因在报错信息中写的很清楚,s 的所有权在进行 get_len(s) 调用前,就已经移交给了 println!,而同一内存地址不能在同一时间被两个 owner 拥有,所以会导致报错。

那么,有没有什么方法使得函数可以使用变量的值,而不需要获得这块内存的所有权呢?这就是 引用

如果你学习过 C/C++ 等有引用概念的语言,那你一定对它感到很熟悉。在 Rust 中,我们使用 & 来表示引用,如 &s&s 语法让我们创建一个 指向s 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

有了引用的概念,上述代码就可以修改为下面这样:

let s = String::from("hello, world");

fn get_len(s: &String) -> usize {
    s.len()
}

println!("The length of '{}' is {}.", s, get_len(&s));

这样就可以正常运行了。

既然提到了引用,我们知道在 C/C++ 中,引用可以对值进行修改的,那么在 Rust 中呢?我们尝试以下代码:

let s = String::from("hello");

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

change(&s);

我们会得到以下报错:

error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
  --> src\main.rs:30:5
   |
29 | fn change(some_string: &String) {
   |                        ------- help: consider changing this to be a mutable reference: `&mut String`
30 |     some_string.push_str(", world");
   |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

error: aborting due to previous error

For more information about this error, try `rustc --explain E0596`.

正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

我们通过一个小调整就能修复上述示例代码中的错误:

let mut s = String::from("hello");

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

change(&mut s);

上述代码我们为两个地方添加了 mut 关键字,首先,要想对值进行修改,这个变量就应该是一个可变的变量,所以我们为 s 的声明加上了 mut;其次,引用默认是不可变的,所以我们也要将其变为可变引用,所以我们为 some_string 的类型注解加上了 mut。这样一来,我们就可以在不转移所有权的情况下对值进行修改了。

不过可变引用有一个很大的限制:在同一时间,只能有一个对某一特定数据的可变引用。尝试创建两个可变引用的代码将会失败:

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

错误如下:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 | 
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.

这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:

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

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

在CPU流水线中也有类似于数据竞争的概念叫做 数据冒险,数据冒险有以下三种情况:

  • RAW(read after write):又称先写后读相关性,是指在
  • WAW(write after write):又称先写后写相关性。
  • WAR(write after read):又称先读后写相关性。

对于这三种情况,一般我们有以下这三种方法解决:

  • 软件延后(nop 指令)
  • 硬件延后(bubble
  • 数据旁路技术

而对于 Rust 来说,我们使用了避免出现数据竞争的方法来解决这些问题,不让问题发生就等于是解决了问题。

类似的规则也存在于同时使用可变与不可变引用中。这些代码会导致一个错误:

let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{}, {}, and {}", r1, r2, r3);

错误如下:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.

由此可见,我们也不能在拥有不可变引用的同时拥有可变引用。不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

参考文献

Rust 程序设计语言 中文版 - 什么是所有权?
Rust 程序设计语言 中文版 - 引用与借用