Rust这门语言因为它的内存管理模型而闻名,它使用编译期检查代替了运行时的GC(垃圾回收)来确保内存安全。Rust能够做到没有GC但仍然能让程序员从易错的内存管理中解脱出来的秘诀就是:Borrowing and ownership,即借用检查和所有权设计,这个一个简约但并不简单的设计实现。

Stack & Heap memory

特别的,程序中的内存管理是比较复杂的,一个高阶的视角是,内存管理对编译器而言总是”分配一定长度的内存“和”回收一定长度的内存“,这有点像C语言中的malloc()free()函数。Rust中的所有权规则如下:

  1. 内存中一个值value的所有者owner是一个变量variable
  2. 在任何时刻,内存中的某个值value只有一个owner
  3. 内存中的某个值value在离开它的作用域scope时就会被回收

使用Rust中声明式的语法,比如在声明一个变量let a = "hello";的时候,rustc编译器就会知道,在编译期间会为该变量分配有一定长度的内存,并对该段内存进行追溯。这个过程中会引入lifetimes生命周期这个机制,生命周期即是指该变量从开始声明到离开作用域被销毁的整个生命周期。如果变量是定长的,那么编译器就会分配指定大小的内存,比如为i32类型分配32bits,即4bytes。下面来看2个变量内存分配的例子:

1
2
3
4
5
6
7
8
9
fn my_func() {
// the compiler allocates memory for x
let x = LargeObject::new();
x.do_some_computation();
let y = call_another_func();
if y > 10 {
do_more_things();
}
} // deallocate(drop) x, y

Rust编译器真的会按照上面的内存释放顺序执行吗?是也不是。这里的窍门是,静态代码分析模块会去检查每个变量的作用域到底到哪里就结束了,而不是等到函数执行完退出时才释放。那么问题就来了,如果一个线程在未知的运行时改变了一个变量那怎么办呢?在编译期的时候是不可能知道运行时所发生的动作的,这也是很多语言使用GC垃圾回收器来解决内存回收的原因。然而Rust并没有采用这个设计,Rust引入了两个基本策略:

  1. 每个变量在任何时刻只能被一个作用域scope拥有owner
  2. 基于第1点,变量在作用域之间传递须同时移交所有权(pass ownership

在我们和作用域打交道的时候,使用栈内存stack是自然而然的事情。我们都知道内存有2种,一直是栈内存stack,一种是堆内存heap。和其他编程语言一样,Rust使用不同的数据类型来声明要分配栈内存,还是要分配堆内存,如基本类型、复杂类型,如Box, Rc等。

栈是一种后进先出的结构,通常生存周期短、数据类型简单、占用内存长度固定sized。下图是栈内存的示意:
stack_memory

而堆则与之不同,堆是一大块内存,可以方便的为复杂变量分配其所需的内存长度。在堆中变量的分配是无序的,已分配的变量通过内存地址来索引,即内存指针pointer,而内存指针的长度是定长的,它可以很好的存在栈上(内存指针的长度,是由当前CPU运行模式的寻址位数来决定的,一般64位处理器的内存指针长度为8bytes,32位处理器的内存指针长度为4bytes,而在堆上的变量其在栈的存储结构除了指向内存地址的起始指针之外还会有其他的元数据,比如内存长度等)。堆变量的表示示意图如下:
heap_memory

Onwership & Borrowing

典型的,在其他编程语言中,在栈上分配的变量会以值拷贝的方式进行传递,即将值在栈上复制一份到新分配的内存地址然后再使用。Rust也一样,不过Rust的所有权机制使得在scope之间传递时,如果栈上变量的所有权移交到新scope,那么旧scope的变量就会失效,无法再通过变量名访问到栈上原有的内容。下面是一个示例:

1
2
3
4
5
fn my_function() {
let x = 10;
do_something(x); // ownership is moved here
let y = x; // x is now invalid! unreachable
}

每个值只有一个变量拥有其onwership,值在不同scope间传递的时候需要进行move,想象一下当我们想在多个scope中访问同一个变量时,每个scope都进行move in move out将会很不方便,那么有没有一种机制可以在多个scope中访问到同一个变量呢?答案就是借用borrowing。使用borrowing传递进新的scope时,借用本身也会move in scope,但和值拷贝不同的是它不需要将原变量完整的值进行拷贝,借用只是原值的一个引用reference,它可以访问原有的值但不拥有其ownershipBorrowing的规则如下:

  1. Owners可以拥有不可变的immutable和可变的mutable的借用reference,但不能同时存在两者
  2. 可以存在多个不可变借用,但可变借用只能存在一个

还是上面的例子,使用借用进行scope间的传递,示例如下:

1
2
3
4
5
6
fn my_function() {
let x = 10;
do_something(&x); // pass a borrowing reference to x,
// variable &x is created and moved in scope
let y = x; // x is still valid!
}

其实借用Borrowing深度依赖变量的生命周期lifetimes。想象一下你借用出去的reference后续访问原变量的时候需要保证内存安全,即不会出现访问内存错误的情况,这就需要知道原始变量和借用出去的引用的生存周期并保证原始变量存活的都比借用的引用要久。而借用会用在struct field等多层传递的复杂scope中,此时编译器如何确保知道变量的生命周期之间的存活周期约束呢?这就需要显示生命周期声明了!

Lifetimes

Rust的生命周期参数声明使用一个'后跟标识符来标识,其中一个预定义的生命周期是'static,表示静态全局生命周期。

其实生命周期参数是很说得通的,想象一下你传了一个参数到函数里面,然后函数又把它返回回来,那么这个参数的生命周期就比这个函数的生命周期存活的久。同样,函数中声明的变量的生命周期只存活到函数执行结束时,所以不能借用一个在函数内部声明的变量。

1
2
3
4
5
6
7
fn another_function(mut passing_through: MyStruct) -> MyStruct {
let x = vec![1, 2, 3];
// passing_through cannot hold a reference to a shorter lived x!
// the compiler will complian.
passing_through.x = &x;
return passing_through;
} // x's life ends here

不能借用一个在函数内部声明的变量,这是因为引用它的变量比它存活的更久,不能确保将来想访问这个变量的时候它还存在在内存中。针对这种情况有以下几个解决方法:

  • 将对函数内部声明变量的借用改为移交所有权,这样使用该变量的值直接拥有其所有权从而可以伴随着生命周期一直使用。
1
2
3
4
5
6
7
fn another_function(mut passing_through: MyStruc) -> myStruct {
let x = vec![1, 2, 3];
// passing_through owns x and it will live as long as with passing_through,
// it's safe for passing_through use x, and finally they weill be dropped together
passing_through.x = x;
return passing_through;
}
  • 第二个方式是将变量x进行clone一份给passing_through
1
2
3
4
5
6
fn another_function(mut passing_through: MyStruct) -> MyStruct {
let y = vec![1, 2, 3];
// passing_through owns a deep copy of x' value that is be
passing_through.x = x.clone();
return passing_through;
}
  • 最后一个方法,在本例中vec![]是静态定义的,它可以移到函数参数中,这在有时候可能会需要一个显示的生命周期声明。
1
2
3
4
5
6
fn another_function<'a>(mut passing_through: MyStruct<'a>, x: &'a Vec<u32>) -> MyStruct {
// the compiler knows and expects a lifetime that
// is at least as long as struct's of any reference passed in as x.
passing_through.x = x;
return passing_through;
}

Lifetimes生命周期让很多Rust学习者遇到了很多编译错误,但随着2018版本的Non-Lecical-Lifetimes(NLL)的发布,更智能的生命周期检查器可以更好的检查借用规则和生命周期参数是否真正触发了错误,这可以让我们使用生命周期编写更灵活的代码。NLL不再是严格的词法检查器,它还会检查在上下文中是否真正用到了,只有真正用到了并且检查不合法才会报错。

Multiple owners std::rc::Rc<T>

Rust中每个值只有一个owner,这固然很安全,但是并不是所有的场景都能满足。假设一个场景,你需要在循环里多次使用某个变量的值,而如果单个onwership那么在第一次循环的时候就会将ownership移交而剩下的循环则无法访问到有效的值,这显然不能满足我们的需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct FileName {
name: String,
ext: String,
}

fn no_ref_counter() {
let name = String::from("main");
let ext = String::from("rs");

for _ in 0..3 {
println!("{:?}", FileName{name, ext});
// error: use of moved value: `name`,
// value moved here in previous iteration of loop
}
}

一个可选的方法是在每次迭代中将name, ext进行clone,但克隆会产生完整的内存复制并降低整体的运行速度,为了解决这个问题,Rust引入了Rc类型,即reference counting引用计数类型。std::rc::Rc<T>将类型T装箱存储在堆heap上并返回一个不可变的引用,这个引用可以被低成本的clone(此时只有有一个新的引用被克隆在stack上,原有heap上的内存不变,注意这个新的引用也是不可变的)。这看起来就像是Rc<T>拥有在heap上的内存,然后在函数间进行传递并以此对堆上内存进行访问。只克隆引用计数和克隆完整的内存段相比,前者的代价就小多了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::rc::Rc;

#[derive(Debug)]
struct FileName {
name: Rc<String>,
ext: Rc<String>,
}

fn ref_counter() {
let name = Rc::new(String::from("main"));
let ext = Rc::new(String::from("rs"));

for _ in 0..3 {
println!("{:?}", FileName{
name: name.clone(),
ext: ext.clone()
});
}
// output:
// FileName { name: "main", ext: "rs" }
// FileName { name: "main", ext: "rs" }
// FileName { name: "main", ext: "rs" }
}

上述的代码解决了一个变量能多处访问的问题,但也仅仅是在单线程并且是不可变的scope中良好运行,在多线程中就行不通了。为了解决这个问题,Rust又引入了新的数据类型,且往下看!

Concurrency & mutability

Rust的内存管理是一个很强大的概念,可以轻松应对多线程并发和并行执行。Rust依托于其底层实现提供了和操作系统映射线程的API,比如Linux/Unix系统中的POSIX(The Portable Operatin System Interface)。当线程不涉及外部参数的时候还是很简单的,下面是一个示例:

1
2
3
4
5
6
7
8
9
use std::thread;

fn threading() {
// || 是参数部分,这里为空表示没有参数
let handle = thread::spawn(|| {
println!("Hello from a thread");
});
handle.join().unwrap();
}

然而,当涉及外部变量在多线程之间的传递同步时,事情就没那么简单了,不过在进入到这个话题之前,让我们先来了解一个变量的可变性。

Immutable variables & Interior mutability

Rust中的变量默认是不可变的,如果需要可变则需要在声明的时候使用mut来显示定义,如let mut x = 10;。不可变的变量,可以避免数据竞争的出现,因为不涉及写操作。而显示声明mut可变则告诉编译器在哪里会产生写操作从而检查可能出现的数据竞争data race。这使得Rust在编译期间就能检查到可能存在的警告和错误,避免代码到了线上运行时才产生各种奇怪的事故现场。当然,如果可变的变量用的很少,那么你的情况将会简单的多。

还记得我们前文提到过的Rc吗?它是一个不可变的引用,但没有什么理由因为我们使用了一个不可变的引用,就不能修改装箱在堆上的变量,这就引入了Rust中的内部可变类型RefCellRefCell对某个值维护了一个单一的owner并允许可变借用在运行期对其进行修改。和编译期错误不同的是,如果违反了相关的变量访问约束那么将会得到一个运行时panic!,这会导致程序奔溃。RefCell通常和Rc结合使用,用来提供有内部可变性的多个owner,而这多个owner变量之间,需要确保没有违反借用的2个规则。再来复习一下关于借用的2条规则:

1.Owners可以拥有immutablemutablereference,但不能同时存在两者
2.可以存在多个immutable reference,但mutabl reference只能存在一个

使用Rc来包装RefCell以达到多个owner,同时提供了一种方式可以改变变量的内容。这和传统语言的引用很像,在诸如Java或者C#语言中,典型的使用引用在函数调用中传递,而这些引用都直接指向内存中的实例变量,都能对其作出修改。

Rust中的Rc<RefCell<T>>这个模式,对实现复杂的程序和数据结构有着重要的影响,特别是对于一个变量来说因为它的所有权Ownership并不总是特别清晰的。比如在双向链表中的元素,分别有一个指针指向前面的元素和后面的元素,那么它们彼此之间谁拥有谁呢?而内部可变性告诉我们,它们可以同时拥有彼此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
use std::cell:RefCell;
use std::rc::Rc;

#[derive(Clone)]
struct Node {
value: String,
next: Link,
prev: Link,
head: Link,
tail: Link,
}

type Link = Option<Rc<RefCell<Node>>>;

impl Node {
pub fn new(value: String) -> Node {
let node = Node {
value: value,
next: None,
prev: None,
head: None,
tail: None,
};
node.head = Some(Rc::new(RefCell::new(node)));
node.tail = Some(Rc::new(RefCell::new(node)));
node
}

pub fn append(&mut self, value: string) {
let new = Rc::new(RefCell::new(Node::new(value)));
match self.tail().take() {
Some(old) => {
// by using `borrow_mut()`, the mutable reference only lives
// as long as the assignment takes, thereby ruling out
// creating a too-large scope and violating the borrowing rules.
old.borrow_mut().next = Some(new.clone);
new.borrow_mut().prev = Some(old);
},
None => self.head = Some(new.clone()),
}
}

fn head() -> Link {
unimplemented!()
}

fn tail(&self) -> Link {
unimplemented!()
}
}

通过使用RefCell类型的borrow_mut()函数,Rust会检查并强制保证借用规则,如果有违反了借用规则则会产生panic!。到目前为止,通过使用Rc<RefCell>的复合类型,我们现在已经能够在多个地方(线程)对同一个变量进行访问和修改,不过此时的修改需要特别小心不能违反借用规则,即同一个作用域只能有一个运行时的可变借用在修改,否则会产生panic!。那么有没有办法可以让我们可以无惧的在多个线程中安全的访问和修改同一个变量呢?答案当然是可以,这就是RustMutex类型!

Moving data

在介绍Mutex类型之前,让我们先来看一下单个线程是怎么使用外部变量的。上文提到,变量会在其离开scope作用域的时候被销毁,在函数定义域内声明的变量不能被外部引用,变量为了进入新的scope有时我们会移交ownership,而为了在多个scope之间能访问到同一个变量,我们使用了reference进行借用而不是移交ownership。同样的,线程需要使用外部的变量,可以通过移交所有权,也可以通过reference借用的方式,而移交所有权,有时也被称为Moving data,又称移动语义。下面来看一个对比:

1
2
3
4
5
6
7
8
9
use std::thread;

fn threading() {
let x = 10;
let handle = thread::spawn(|| {
println!("hello from a thread, the number is {}", x);
});
handle.join().unwrap();
}

上面这段代码是编译不过的,因为println!()函数使用了x,但是编译器不知道外部的x变量和线程内使用的scope哪个生命周期更长,也就无法保证内存安全。为了在线程内安全的访问一个变量,编译器此时建议我们可以将x move in到线程的scope内,即像这样:

1
2
3
4
5
6
7
fn threading() {
let x = 10;
let handle = thread::spawn(move || {
println!("hello from a thread, the number is {}", x);
});
handle.join().unwrap();
}

上面的代码较之前的改动就是在线程的定义处加了一个move关键字,表示的是对线程内用到的外部变量进行move in,这样线程对其访问就是安全的,因为线程和变量的lifetimes是一样久的。然而,如果需要在线程间传递多个变量,或者实现Actor model角色模型,Rust标准库为此提供了channels信道。channels是一种single-consumer, multi-producer单个消费者多个生产者的队列,借助channels可以在多个线程中产生数据然后在单个线程内消费数据,下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::sunc::mpsc::{channel, Sender, Receiver};

fn channels() {
const N: i32 = 10;
let (tx, rx): (Sender<i32>, Receiver<i32>) = channel();
let handles = (0..N).map(|i| {
let _tx = tx.clone();
thread::spawn(mvoe || {
_tx.send(i).unwrap();
});
});
// wait all threads finish
for h in handles {
h.join().unwrap();
}
let numbers: Vec<i32> = (0..N).map(|i| {
rx.recv().unwrap()
}).collect();
println!("{:?}", numbers);
// output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}

有了channel这个消息队列,我们在线程间的通信就可以不用手动的对数据加锁并且可以免于因粗心而引入的bug。好了,到了这里,我们已经解决了在线程间通信的问题了,我们使用channel在多端产生数据然后在某个终端线程进行消费。但是如果我们想要在多个线程中都访问同一个共享变量呢?这个时候就需要在多个线程内对变量可见,同时还得满足借用规则,这就需要引入RustMutex类型!Mutex,即mutual exclusion,写互斥的,简称互斥,在任何时刻只有单个线程取得某个变量的写权限。

Mutex互斥是编程中的一个古老的概念,所以Rust的标准库中就直接支持了。Mutex是如何使得在多个并发线程中访问同一个变量变得简单的呢?通过Mutex类型对某个变量进行装箱,它就可以在多个并发写的线程中保持互斥,这使得此时变量可以被多个写线程访问到。但是,此时那些线程还没有获得Mutex装箱类型的内存写权限。类似在单线程中我们使用Rc<RefCell>一样,我们首先对对单个变量使用RefCell装箱存储在堆上,然后使用Rc提供了多个可访问的借用owner。在多线程中,Rust提供了一个新的类型Arc(an aotimic reference counter),原子计数器类型,通过Arc装箱的类型类似于单线程中dRc,也提供了多个可访问的借用owner,只不过到了多线程中它需要是原子性的,即多个线程对同一个变量的引用计数的增减必须同一时刻只有一个,不然会造成引用计数不同步的情况,这在某些情况下会造成变量提前释放或内存泄露的风险。就这样,结合Arc<Mutex<T>>,我们可以无畏的在多线程中对变量进行装箱读写,Arc保证了多线程间引用计数的准确性,Mutex提供了多线程间的互斥写。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::thread;
use std::sync::{Mutex, Arc};

fn shared_state() {
let v = Arc::new(Mutex::new(vec![]));
let handles = (0..10).map(|i| {
let numbers = Arc::new(&v);
thread::spawn(move || {
let mut vector = numbers.lock().unwrap();
(*vector).push(i);
});
}).collect();

for handle in handles {
handle.join().unwrap();
}
println!("{:?}", *v.lock().unwrap());
// output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}

虽然在多线程中推荐尽可能使用不可变的变量,但是Rust可以为我们提供在多线程中无畏安全的并发编程能力。

Send & Sync

Send, Sync这两个annotationRust多线程的基础准则,它们有着不同的作用:

  • Send: 表示一个数据类型从一个线程send(move)移交到另一个线程是安全的,即不会发生内存段访问错误
  • Sync: 表示一个数据类型在线程间无需手动加锁或者进入临界互斥区即可共享

对于上述两个标记,Rust标准库为所有的基本类型都实现了,如果是基于基本类型构建的复合类型,那么也会自动实现这2个标记。但是,如果你想为自定义类型实现者2个标记,这是unsafe的,因为编译器无法得知你自定义的类型是否能在编译器的线程间安全的共享或者移交所有权,所以通常我们很少自定义这2个标记的实现。

以上,就是入门Rust语言重要的一章,Borrowing借用和Ownership所有权。让我们再来回顾一下Rust这门多编程范式的语言:它将数据data定义和行为实现behavior进行了强制分离,使用宏macros进行元编程,并借助所有权ownership来确定变量的生命周期lifetimes。知道变量的生命周期工作机制,就理解了为何Rust能去掉GC,同时也就理解了Rust是如何保证多线程的读写安全的。同时,在多线程和异步处理中,只有拥有变量的可变的所有权时才能对其进行修改,这很多时候是在编译期保证的,但是在运行时Rust依然能够保证其安全,真因为如此,Rust可以高效的避免了很多数据竞争的场景。

最后说点题外话,在我读这本《Hands-On Functional Programming in Rust 1st Edition》书之前,我完成了官网教程的《The Rust Programming Language》《Rust by Example》,但直到读这本书的这一章,我才真正理解了Rust里面的数据额类型设计原来是这个意思。其实每个数据类型的出现,都是有它必然需要的场景,我们需要做的就是去了解它的设计背景,理解它的用法,从而将它牢牢掌握。最后,也都希望大家能够今早吃透Rust的数据类型设计,继续探索Rust后面更丰富的类型系统!