Rust的“并发安全”设计

Posted 双锅首上

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Rust的“并发安全”设计相关的知识,希望对你有一定的参考价值。

部分内容参考Aaron Turon的文章《Fearless Concurrency with Rust 》


昨天发了一篇说异步IO和轻量级线程的文章,有人问我为什么不在后面补充一下rust的并发模型。其实rust并不存在一个独特的并发模型,但它从语言层面上提供了一整套机制来保证并发的安全,借助这套机制,你可以安全的实现很多并发模型,如消息传递式、共享状态式、无锁式和纯函数式。昨天晚上我在群里和人讨论有关设计的话题,我觉得一个好的设计应当有两方面表现,一方面是符合直觉,也就是各个方面保证一致性以便使用者可以触类旁通。另一方面就是通过精致的设计来规避一些常见的,甚至是很复杂的问题。我认为rust做到了这两点。而对于我们今天谈论的话题——rust提供的安全并发,主要就是后者的功劳。



之前文章中谈到erlang,我们知道它使用自动调度的轻量级线程来实现异步IO。不过没有谈到erlang关于锁的态度。erlang是在设计上排斥锁的,而它成功避免数据竞争的原因是在很多方面禁止多进程访问一块内存(大部分情况下也就是禁止共享状态)。这是一个很多函数式语言都使用的套路,但用在传统应用场景上多有些阉割程序员的意思。rust同样在设计上避免了数据竞争,而它的法宝就是变量的所有权机制。因为内存安全bug和并发bug经常都是因为代码对数据不正确地访问造成的。rust提供的所有权静态检查对于内存安全而言,这意味着没有垃圾回收,也不用担心段错误,因为检查时会发现你的错误。


在具体说并发有关的内容之前,先来说一说rust的所有权机制:
所有权
在Rust中,每一个变量都具有一个所有权域,传递或返回一个变量会转移它的所有权到新的域中(在实现上就是C++的移动语义)。当一个域结束时,如果域所拥有的值还没销毁,此时将自动销毁。
一个变量创建时所在的域也是该变量的所有权域(在创建时该域就拥有它)。它可以随心所欲地使用这个变量。在域结束时,如果变量仍然被域所拥有,它将被自动销毁。
不过如果变量转移了所有权呢?下面我们来看这个例子
fn make_vec() -> Vec<i32> {
    let mut vec = Vec::new();
    vec.push(0);
    vec.push(1);
    vec // transfer ownership to the caller
}

fn print_vec(vec: Vec<i32>) {
    // the `vec` parameter is part of this scope, so it's owned by `print_vec`

    for i in vec.iter() {
        println!("{}", i)
    }

    // now, `vec` is deallocated
}

fn use_vec() {
    let vec = make_vec(); // take ownership of the vector
    print_vec(vec);       // pass ownership to `print_vec`
}

现在,在make_vec的域结束之前,vec以函数返回值的方式被移了出来,它不会再被销毁,它的所有权被调用者use_vec所接收。
另一方面,函数print_vec有一个vec参数,当该函数被调用时,参数的所有权将从调用者转移给它。由于vec的所有权在函数print_vec中没有被再次转移,为此在print_vec的域结束时,vec将自动销毁。
一旦所有权转移了,对于原所有权域来说,值将不再可用。你也就不能再访问或者改变它。能检查到这点很厉害,因为如果允许访问,很有可能这时候它已经被新的所有权域销毁了。对于指针来说,C++的所有权智能指针可以达到同样的效果。

 

borrowing

可能大家都发现了问题,什么鬼,为什么我传了个参数这参数后面还不能用了呢?确实,我们并不打算让print_vec销毁传递给它的vector。我们真正希望地是让print_vec只是临时访问一下vector,然后后面我们接着用。这时候就要用到borrowing特性。在rust中,如果你能访问一个变量(注意这和拥有所有权可是不一样的),你可以把它“借”给你所调用的函数,供它们访问。rust会检查所有借出的值,确保它们的寿命不会超过值本身的寿命。
为了借出一个值,你可以使用引用(嗯……看起来好像和C++差不多对不对),对应的操作符是 &。看下面的例子:

fn print_vec(vec: &Vec<i32>) {
    // the `vec` parameter is borrowed for this scope

    for i in vec.iter() {
        println!("{}", i)
    }

    // now, the borrow ends
}

fn use_vec() {
    let vec = make_vec();  // take ownership of the vector
    print_vec(&vec);       // lend access to `print_vec`
    for i in vec.iter() {  // continue using `vec`
        println!("{}", i * 2)
    }
    // vec is destroyed here
}
现在 print_vec拥有一个vector的引用,并且use_vec使用&vec的方式把vector借给它。因为借出的值只是用于临时访问用的,use_vec仍然拥有vector的所有权。因此,在函数print_vec调用之后(print_vec对vec的借用将过期),我们还可以继续使用它。

每一个引用仅在一个有限的域中有效,编译器会自动判定。引用具有以下两种形式:
1. 不可变引用&T,可以共享但不能被改变。一个值可以同时具有多个&T引用,但即使引用可用也不能改变它。

2. 可变引用&mut T,可以被改变但不能共享。如果一个值已经有了一个 &mut T引用,就不能同时具有其他可用的引用,但是它可以被改变。


为什么会存在两种不同的引用?考虑下面这个函数:

fn push_all(from: &Vec<i32>, to: &mut Vec<i32>) {
    for i in from.iter() {
        to.push(*i);
    }
}

该函数会遍历一个vector中的每一个元素,并把它们添加到另一个vector中。迭代器持有一个vector当前位置和结束位置的指针,一次前进一个元素。
那么,假设我们在调用这个函数时,把同一个vector做为该函数的两个参数传入,将会发生什么?

诶,或许你想到了,我们把元素放入vector时,它将会改变大小,分配新的内存,并拷贝元素到新内存。迭代器将会持有一个指向旧内存的无效指针,从而导致内存不安全。这也正是为什么可变引用只允许有一个的原因。


到目前为止,你已经知道了Rust里关于所有权的基础知识。下面让我们看一下它对于并发而言意味着什么。

消息传递
并发编程具有多种模型,最简单的就是消息传递,线程和actors之间通过互相发送消息进行通信。这也是erlang的思想之一——“不要通过共享内存进行通信;相反,通过通信共享内存。”。由于rust的所有权模型,可以把上面这条思想转化到编译器检查规则中,从而使消息传递并发模型编程变得更加简单。


让我们先看一下Rust的通道(channel)API:

fn send<T: Send>(chan: &Channel<T>, t: T);
fn recv<T: Send>(chan: &Channel<T>) -> T;
通道中传输的数据类型是泛型的(<T:Send>是API的一部分)。Send代表T可以安全地在线程中传输,此处先不细说,现在我们只要知道Vec<i32>是一个Send就足够了。
在rust中,只要传递一个T给函数send就意味着会转移它的所有权。这一原则具有重大影响。因为两个线程同时拥有一个变量,就可能会引起竞争(race condition),或因此出现一个释放后使用的bug。

共享状态

共享状态式并发编程模型(shared-state concurrency)名声不怎么好。因为容易忘记加锁,或者在不正确的时间改变不正确的数据,从而导致灾难性后果。由于太容易犯这些错误,从而导致很多人都避免使用这种模型。


Rust对于该模型的态度是:
1. 共享状态式并发编程模型仍然是一项基本的模型,被系统编程,性能优化及实现其他并发编程模型所需要。
2. 问题的根源在于意外地共享状态。

不管你使用加锁(locking)又或者无锁(lock-free)技术,Rust的目标是给你直接征服共享状态式并发编程的工具。
在Rust中,因为所有权机制的关系,写操作只会发生在线程具有数据的可变访问权限时,拥有该数据的所有权,或者拥有该数据的可变引用。 换句话说,在同一时间,只有一个线程能访问数据。为了弄清楚这是怎么做到的,让我们先看一下Rust中的锁。
下面是一个简单的版本(标准库更为复杂):

// 创建互斥锁
fn mutex<T: Send>(t: T) -> Mutex<T>;

// 获取锁
fn lock<T: Send>(mutex: &Mutex<T>) -> MutexGuard<T>;

// 访问由锁保护的数据
fn access<T: Send>(guard: &mut MutexGuard<T>) -> &mut T;

Mutex是一个类型T的泛型类型,T是锁要保护的数据。当你在创建一个Mutex时,会将数据的所有权转移到mutex中,并立即放弃对它的访问。(锁在创建时,默认是没有锁定数据的)。

然后,你可以调用lock函数来阻塞线程直到获取到锁。这个函数同普通的函数也不太一样,它会返回一个值,MutexGuard<T>。 当MutexGuard<T>销毁时,它会自动释放锁,这里不存在单独的unlock函数。
访问数据的唯一方式是通过函数access,它将可变引用MutexGuard<T>转换为一个可变引用T。

这里有两个关键点:
1. access函数返回的可变引用的寿命不能超过MutexGuard的寿命。
2. 只有当MutexGuard销毁时,锁才会被释放。

这样做的结果就是Rust强制执行加锁的准则:访问被保护的数据前必须先持有锁。任何除此之外的访问都将产生一个编译错误。


线程安全
存在一些典型的方法用于判定数据类型是否线程安全。线程安全的数据结构在内部会使用足够多的同步以确保被多个线程并行使用时是安全的。


举例说明,rust有两种基于引用计数的智能指针:
1. Rc<T> 通过常用的读写方式进行引用计数,它不是线程安全的。
2. Arc<T> 通过原子操作进行引用计数,它是线程安全的。


因为Arc使用的硬件原子操作比Rc使用的普通操作更为昂贵,因此使用Rc更具优势。但另一方面,非常关键的是永远不能将Rc<T>从一个线程转移到另一个线程。因为线程的粒度比Rc的计数操作小,那样可能导致竞争,从而扰乱计数。
到底该使用Arc还是Rc?通常来说,唯一能够指望的就是文档了,因为在大多数语言中,线程安全和线程不安全的类型在语义上没有任何差异。
然而在Rust中,类型会被分为两种:一种是Send,表示把他们从一个线程移到另一个线程是安全的;另一种是!Send,表示把他们从一个线程移到另一个线程可能不安全。是不是一个类型的所有组成部分都是Send,那么这个类型就是Send?只能说大多数时候是。尽管某些基本类型也并不是线程安全的,但他们可以显式地使用像Arc一类的类来转变为Send,从而告诉编译器它通过了必要的同步检验。


我们在前面已经看到Channel和Mutex的这些API只作用于Send数据。因为Send数据是穿越线程边界的关键点,同时穿越线程边界也是Send数据的关键点。
结合以上这些机制,Rust程序员可以放心大胆地在多线程环境中使用一些在其他语言中线程不安全的类型,而不用担心意外地把他们从当前线程发送到其他线程去了,因为如果没有必要的同步设施,编译器将会通知你。


共享栈
到目前为止,所有我们看到的在线程间分享的数据,都是在堆上创建的。要是我们想在线程间共享一些在当前栈上的数据会怎样呢?看下面的例子:

fn parent() {
    let mut vec = Vec::new();
    // fill the vector
    thread::spawn(|| {
        print_vec(&vec)
    })
}

子线程拥有一个vec的引用,vec是驻留在parent的栈上的。当parent退出时,栈会被弹出,但是子线程并不知道,然后……呵呵呵。


为了避免出现这样的内存不安全问题,Rust创建线程的基本API看起来像下面这样:

fn spawn<F>(f: F) where F: 'static, ...
‘static 要求这个闭包不允许有borrow的数据。

呃……这种方法似乎太过简单粗暴,其实还有另一种可以保障安全的方式,确保在子线程完成之前,父线程的栈没有被弹出。这种方法我在之前设计一个自动并行化的程序语言时想到过,不过rust早就有了:)。这称之为分解/合并(fork-join)编程模型,经常应用于分治并行算法。rust提供了一个叫"scoped"的创建线程的API来支持它:

fn scoped<'a, F>(f: F) -> JoinGuard<'a> where F: 'a, ...
同上面的spawnAPI比较,存在两个关键差异:
1. 使用了‘a而不是‘static。它表示一个作用域,用做所有包含在闭包(f)中的引用的作用域。
2. 返回值是一个JoinGuard。同它名字所表示的意思一样,JoinGuard在析构时会执行一种隐形合并(join),以此来确保父线程合并(join)(等待)它的子线程。


把JoinGuard的域设置为‘a,是为了确保它不会逃离在闭包中被借用的数据的域。换句话说,rust保证父线程在弹出任何子线程可能访问的栈之前,会一直等待子线程结束。

因此在Rust中,你可以随意借用(borrow)栈上的数据到子线程中,因为编译器会做充分的同步检查,是不是炒鸡厉害:P。

至此,我们已经见识了很多东西,从而可以冒险地对Rust的并发编程方式做出强有力的评价:

编译器阻止了所有的数据竞争

这不是简简单单在语言层面上禁止程序员做某些正常的操作。而是使用一些乍一看很奇怪的特性,非常清晰的定义了一个安全的边界,并在上面做以足够的检查,保证你的代码不会出问题。这也就是我最开始说的,rust做到了没有垃圾回收的内存安全,没有数据竞争的并发,看似神话,其实都是精巧设计和先进技术的力量。从一开始就避免了很多问题。

以上是关于Rust的“并发安全”设计的主要内容,如果未能解决你的问题,请参考以下文章

Rust语言:安全地并发

Rust开发环境搭建

Rust 初识及Rust的ESApi

Rust 备忘清单_开发速查表分享

Rust 的优点是什么?

Rust 学习总结—— 2021 年 Rust 行业调研报告