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

灾难得以避免。

共享栈:“作用域化”

注意:这里提到的 API 是一个旧的 API,已被移出标准库。你可以在 crossbeamscope() 的文档)和 scoped_threadpoolscoped() 的文档)中找到等效的功能。

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

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 通过提供线程生成的 “作用域化” 变体来支持它。

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

与上面的 spawn API 有两个主要区别:

  • 使用了参数 'a,而不是 'static。此参数表示一个范围,该范围涵盖闭包 f 中的所有借用。

  • 返回值是一个 JoinGuard。顾名思义,JoinGuard 通过在其析构函数中执行隐式 join(如果尚未显式发生),来确保父线程 join(等待)其子线程。

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 这样的库正在迈出第一步,我们希望在未来几个月内在这个领域投入大量资金。敬请期待!