Rust 中无惧并发

2015 年 4 月 10 日 · Aaron Turon

Rust 项目的初衷是为了解决两个棘手的问题

  • 如何进行安全的系统编程?
  • 如何使并发变得轻松?

最初,这两个问题似乎是正交的,但令我们惊讶的是,解决方案竟然是相同的:**使 Rust 变得安全的工具也帮助你直接解决并发问题**。

内存安全漏洞和并发漏洞通常归结为代码在不应该访问数据时访问了数据。Rust 的秘密武器是所有权,这是一种系统程序员试图遵循的访问控制规则,但 Rust 的编译器会为你静态检查。

对于内存安全,这意味着你可以编写程序而无需垃圾收集器,并且无需担心段错误,因为 Rust 会捕获你的错误。

对于并发,这意味着你可以从各种各样的范式中选择(消息传递、共享状态、无锁、纯函数式),Rust 会帮助你避免常见的陷阱。

以下是 Rust 中并发的一个示例

  • 一个通道 传递发送到它的消息的所有权,因此你可以将指针从一个线程发送到另一个线程,而无需担心线程随后会争夺对该指针的访问。**Rust 的通道强制执行线程隔离。**

  • 一个 知道它保护什么数据,Rust 保证只有在持有锁时才能访问数据。状态永远不会意外地共享。**“锁定数据,而不是代码”在 Rust 中得到强制执行。**

  • 每种数据类型都知道它是否可以安全地发送到多个线程之间或被多个线程访问,Rust 会强制执行这种安全使用;即使对于无锁数据结构,也不存在数据竞争。**线程安全不仅仅是文档;它是法律。**

  • 你甚至可以在线程之间共享堆栈帧,Rust 会静态地确保帧在其他线程使用它们时保持活动状态。**即使是最大胆的共享形式,在 Rust 中也保证是安全的。**

所有这些好处都来自 Rust 的所有权模型,实际上,锁、通道、无锁数据结构等等是在库中定义的,而不是在核心语言中定义的。这意味着 Rust 对并发的处理方式是开放式的:新的库可以通过添加使用 Rust 的所有权特性的 API 来拥抱新的范式并捕获新的错误。

这篇文章的目的是让你了解它是如何做到的。

背景:所有权

我们将从概述 Rust 的所有权和借用系统开始。如果你已经熟悉这些内容,你可以跳过两个“背景”部分,直接跳到并发部分。如果你想了解更多介绍,我强烈推荐Yehuda Katz 的文章。并且Rust 手册 包含所有细节。

在 Rust 中,每个值都有一个“拥有范围”,传递或返回值意味着将所有权(“移动”它)转移到一个新的范围。当范围结束时,仍然被拥有的值会在该点自动销毁。

让我们看一些简单的例子。假设我们创建一个向量并在其中添加一些元素

fn make_vec() {
    let mut vec = Vec::new(); // owned by make_vec's scope
    vec.push(0);
    vec.push(1);
    // scope ends, `vec` is destroyed
}

创建值的范围也最初拥有它。在本例中,make_vec 的主体是 vec 的拥有范围。所有者可以对 vec 做任何它喜欢的事情,包括通过推送来修改它。在范围结束时,vec 仍然被拥有,因此它会自动被释放。

如果向量被返回或传递,情况会变得更加有趣

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 参数,并且向量的所有权被它的调用者转移到它。由于 print_vec 没有将所有权转移到任何其他地方,因此在它的范围结束时,向量会被销毁。

一旦所有权被放弃,就不能再使用该值。例如,考虑 use_vec 的这个变体

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

    for i in vec.iter() {  // continue using `vec`
        println!("{}", i * 2)
    }
}

如果你将这个版本提供给编译器,你会得到一个错误

error: use of moved value: `vec`

for i in vec.iter() {
         ^~~

编译器正在说 vec 不再可用;所有权已被转移到其他地方。这很好,因为向量此时已经被释放了!

灾难避免了。

背景:借用

到目前为止的故事并不完全令人满意,因为我们并不希望 print_vec 销毁它接收的向量。我们真正想要的是授予 print_vec 对向量的临时访问权限,然后继续使用该向量。

这就是借用的用武之地。如果你在 Rust 中访问一个值,你可以将该访问权限借给你的调用函数。**Rust 会检查这些租约是否超过被借用的对象的生存期。**

要借用一个值,你需要使用 & 运算符创建对它的引用(一种指针)

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 接受对向量的引用,use_vec 通过编写 &vec 来借出向量。由于借用是临时的,use_vec 保留了向量的所有权;它可以在 print_vec 函数返回(并且它对 vec 的租约到期)之后继续使用它。

每个引用都有一个有限的范围,编译器会自动确定。引用有两种类型

  • 不可变引用 &T,允许共享但不允许修改。可以同时存在多个 &T 引用指向同一个值,但当这些引用处于活动状态时,该值不能被修改。

  • 可变引用 &mut T,允许修改但不允许共享。如果存在一个 &mut T 引用指向一个值,那么此时不能存在其他活动引用,但该值可以被修改。

Rust 在编译时检查这些规则;借用没有运行时开销。

为什么有两种类型的引用?考虑一个像这样的函数

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

这个函数遍历一个向量的每个元素,并将它推送到另一个向量中。迭代器在向量中当前位置和最终位置保留一个指针,并向另一个方向移动一步。

如果我们用同一个向量作为两个参数调用这个函数会怎么样?

push_all(&vec, &mut vec)

这将是一场灾难!当我们向向量中添加元素时,它有时需要调整大小,分配一块新的内存并将其元素复制到其中。迭代器将保留指向旧内存的悬空指针,导致内存不安全(伴随段错误或更糟)。

幸运的是,Rust 确保**只要可变借用处于活动状态,就不会有其他对该对象的借用处于活动状态**,从而产生以下消息

error: cannot borrow `vec` as mutable because it is also borrowed as immutable
push_all(&vec, &mut vec);
                    ^~~

灾难避免了。

消息传递

现在我们已经介绍了 Rust 中的基本所有权故事,让我们看看它对并发意味着什么。

并发编程有许多风格,但一种特别简单的风格是消息传递,其中线程或 actor 通过相互发送消息来通信。这种风格的支持者强调它将共享和通信联系在一起的方式

不要通过共享内存来通信;相反,通过通信来共享内存。

--Effective Go

**Rust 的所有权使将这条建议变成编译器检查的规则变得容易。**考虑以下通道 API(Rust 标准库中的通道 有点不同)

fn send<T: Send>(chan: &Channel<T>, t: T);
fn recv<T: Send>(chan: &Channel<T>) -> T;

通道是泛型的,它们传输的数据类型(API 中的 <T: Send> 部分)。Send 部分意味着 T 必须被认为是可以在线程之间安全发送的;我们将在本文后面再讨论这个问题,但现在,只要知道 Vec<i32>Send 就足够了。

与 Rust 中的任何情况一样,将 T 传递给 send 函数意味着将它的所有权转移出去。这个事实具有深远的影响:这意味着像以下代码这样的代码将生成编译器错误。

// Suppose chan: Channel<Vec<i32>>

let mut vec = Vec::new();
// do some computation
send(&chan, vec);
print_vec(&vec);

在这里,线程创建了一个向量,将其发送到另一个线程,然后继续使用它。接收向量的线程可以在这个线程继续运行时修改它,因此对 print_vec 的调用可能会导致竞争条件,或者更确切地说,可能会导致使用后释放错误。

相反,Rust 编译器将在对 print_vec 的调用中产生一个错误消息

Error: use of moved value `vec`

灾难避免了。

另一种处理并发的办法是让线程通过被动的、共享的状态来通信。

共享状态并发声名狼藉。很容易忘记获取锁,或者在错误的时间修改错误的数据,从而导致灾难性的后果——如此容易,以至于许多人完全避开这种风格。

Rust 的观点是

  1. 共享状态并发仍然是一种基本的编程风格,对于系统代码、最大性能以及实现其他并发风格是必需的。

  2. 问题实际上是关于意外共享状态。

Rust 旨在为你提供直接征服共享状态并发的工具,无论你使用的是锁定技术还是无锁技术。

在 Rust 中,线程由于所有权的原因,会自动“隔离”彼此。写入操作只能在线程拥有可变访问权限时进行,无论是拥有数据,还是对数据进行可变借用。无论哪种方式,**线程都保证在该时刻是唯一拥有访问权限的线程**。为了了解其运作方式,让我们看看锁。

请记住,可变借用不能与其他借用同时发生。锁通过运行时的同步提供相同的保证(“互斥”)。这导致了一个直接与 Rust 的所有权系统挂钩的锁定 API。

这是一个简化版本(标准库 的版本更符合人体工程学)

// create a new mutex
fn mutex<T: Send>(t: T) -> Mutex<T>;

// acquire the lock
fn lock<T: Send>(mutex: &Mutex<T>) -> MutexGuard<T>;

// access the data protected by the lock
fn access<T: Send>(guard: &mut MutexGuard<T>) -> &mut T;

这个锁 API 在几个方面是不寻常的。

首先,Mutex 类型是泛型的,它接受一个类型 T,表示**由锁保护的数据**。当你创建一个 Mutex 时,你将该数据的拥有权转移到锁中,立即放弃对它的访问权限。(锁在首次创建时是解锁的。)

稍后,你可以使用 lock 来阻塞线程,直到获取到锁。这个函数也不寻常,因为它提供了一个返回值,MutexGuard<T>MutexGuard 在被销毁时会自动释放锁;没有单独的 unlock 函数。

访问锁的唯一方法是通过 access 函数,它将对守卫的可变借用转换为对数据的可变借用(租期更短)

fn use_lock(mutex: &Mutex<Vec<i32>>) {
    // acquire the lock, taking ownership of a guard;
    // the lock is held for the rest of the scope
    let mut guard = lock(mutex);

    // access the data by mutably borrowing the guard
    let vec = access(&mut guard);

    // vec has type `&mut Vec<i32>`
    vec.push(3);

    // lock automatically released here, when `guard` is destroyed
}

这里有两个关键要素

  • access 返回的可变引用不能超出它借用的 MutexGuard 的生命周期。

  • 锁只有在 MutexGuard 被销毁时才会释放。

结果是**Rust 强制执行锁定纪律:它不允许你在没有持有锁的情况下访问受锁保护的数据**。任何试图这样做都会导致编译器错误。例如,考虑以下有问题的“重构”

fn use_lock(mutex: &Mutex<Vec<i32>>) {
    let vec = {
        // acquire the lock
        let mut guard = lock(mutex);

        // attempt to return a borrow of the data
        access(&mut guard)

        // guard is destroyed here, releasing the lock
    };

    // attempt to access the data outside of the lock.
    vec.push(3);
}

Rust 会生成一个错误,指出问题所在

error: `guard` does not live long enough
access(&mut guard)
            ^~~~~

灾难避免了。

线程安全和“Send”

通常,我们会将某些数据类型区分成“线程安全”和“非线程安全”。线程安全的数据结构使用足够的内部同步,以便可以安全地被多个线程并发使用。

例如,Rust 附带了两种用于引用计数的“智能指针”

  • Rc<T> 通过正常的读写操作提供引用计数。它不是线程安全的。

  • Arc<T> 通过原子操作提供引用计数。它是线程安全的。

Arc 使用的硬件原子操作比 Rc 使用的普通操作更昂贵,因此使用 Rc 比使用 Arc 更有利。另一方面,Rc<T> 永远不能从一个线程迁移到另一个线程,因为这会导致破坏计数的竞争条件。

通常,唯一的办法是仔细的文档;大多数语言在语义上没有区分线程安全和非线程安全类型。

在 Rust 中,世界被划分为两种数据类型:一种是 Send,这意味着它们可以安全地从一个线程移动到另一个线程,另一种是 !Send,这意味着这样做可能不安全。如果一个类型的所有组件都是 Send,那么该类型也是 Send——这涵盖了大多数类型。然而,某些基本类型本身并不是线程安全的,因此也可以显式地将像 Arc 这样的类型标记为 Send,告诉编译器:“相信我;我已经验证了这里的必要同步。”

当然,ArcSend,而 Rc 不是。

我们已经看到,ChannelMutex API 只能与 Send 数据一起使用。由于它们是数据跨线程边界传输的点,因此它们也是 Send 强制执行的点。

将所有这些放在一起,Rust 程序员可以放心地利用 Rc 和其他线程不安全类型的好处,因为他们知道,如果他们不小心尝试将它们发送到另一个线程,Rust 编译器会说

`Rc<Vec<i32>>` cannot be sent between threads safely

灾难避免了。

共享栈:“scoped”

注意:这里提到的 API 是一个旧的 API,它已经从标准库中移除了。你可以在 crossbeam (scope() 的文档) 和 scoped_threadpool (scoped() 的文档) 中找到等效的功能。

到目前为止,我们看到的所有模式都涉及在堆上创建数据结构,这些数据结构在线程之间共享。但是,如果我们想启动一些线程,这些线程使用我们栈帧中的数据呢?这可能很危险

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 约束是一种粗略地说,在闭包中不允许借用数据的方式。这意味着像上面的 parent 这样的函数会生成一个错误

error: `vec` does not live long enough

本质上是捕获了 parent 的栈帧弹出的可能性。灾难避免了。

但是,还有另一种方法可以保证安全:确保父栈帧一直保留到子线程完成。这就是fork-join编程的模式,通常用于分治并行算法。Rust 通过提供一个 “scoped” 的线程生成变体来支持它

fn scoped<'a, F>(f: F) -> JoinGuard<'a> where F: 'a, ...

与上面的 spawn API 相比,有两个关键区别

  • 使用参数 'a,而不是 'static。这个参数代表一个范围,它包含闭包 f 中的所有借用。

  • 返回值,一个 JoinGuard。顾名思义,JoinGuard 通过在其析构函数中执行隐式连接(如果还没有显式连接)来确保父线程连接(等待)其子线程。

JoinGuard 中包含 'a 确保 JoinGuard **不能超出闭包借用的任何数据的范围**。换句话说,Rust 保证父线程在弹出子线程可能访问的任何栈帧之前,会等待子线程完成。

因此,通过调整我们之前的示例,我们可以修复错误并满足编译器

fn parent() {
    let mut vec = Vec::new();
    // fill the vector
    let guard = thread::scoped(|| {
        print_vec(&vec)
    });
    // guard destroyed here, implicitly joining
}

因此,在 Rust 中,你可以自由地将栈数据借用到子线程中,确信编译器会检查是否进行了足够的同步。

数据竞争

到目前为止,我们已经看到了足够的信息,可以对 Rust 的并发方法做出强有力的声明:**编译器阻止所有数据竞争。**

数据竞争是指任何涉及写入操作的未同步的并发数据访问。

这里的同步包括像原子指令这样的低级操作。本质上,这是一种说法,即你不能意外地“共享状态”在线程之间;所有(修改)状态的访问都必须通过某种形式的同步来进行。

数据竞争只是竞争条件的一种(非常重要)类型,但通过阻止它们,Rust 通常可以帮助你阻止其他更微妙的竞争条件。例如,更新不同位置时,它们通常需要原子地发生:其他线程要么看到所有更新,要么一个也看不到。在 Rust 中,同时拥有对相关位置的 &mut 访问权限**保证了对它们的更新的原子性**,因为其他线程不可能同时拥有读访问权限。

值得花点时间思考一下这个保证在更广泛的语言环境中的意义。许多语言通过垃圾回收来提供内存安全。但是,垃圾回收不会帮助你阻止数据竞争。

Rust 而是使用所有权和借用,来提供其两个关键价值主张

  • 没有垃圾回收的内存安全。
  • 没有数据竞争的并发。

未来

当 Rust 最初开始时,它将通道直接烘焙到语言中,对并发采取了非常强势的态度。

在今天的 Rust 中,并发完全是库的事情;这篇文章中描述的所有内容,包括 Send,都在标准库中定义,也可以在外部库中定义。

这非常令人兴奋,因为它意味着 Rust 的并发故事可以不断发展,不断发展,以包含新的范式,并捕获新的错误类别。像 syncboxsimple_parallel 这样的库正在迈出第一步,我们预计在未来几个月内会在这个领域投入大量资金。敬请关注!