Rust 的无畏并发

2015 年 4 月 10 日 · Aaron Turon

Rust 项目的启动是为了解决两个棘手的问题:

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

最初,这些问题看似无关,但令我们惊讶的是,解决方案竟然是相同的:让 Rust 安全的工具也帮助你直接解决并发问题

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

对于内存安全,这意味着你可以无需垃圾回收器,无需担心段错误,因为 Rust 会捕获你的错误。

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

以下是 Rust 中并发的一些体验:

  • 一个 通道(channel)会转移沿其发送的消息的所有权,因此你可以从一个线程向另一个线程发送指针,而不必担心这些线程稍后会通过该指针竞争访问。Rust 的通道强制实现线程隔离。

  • 一个 锁(lock)知道它保护哪些数据,Rust 保证只有在持有锁时才能访问这些数据。状态永远不会被意外共享。Rust 强制执行“锁定数据,而非代码”。

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

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

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

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

背景:所有权

我们将首先概述 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 做任何想做的事情,包括通过 push 来修改它。在作用域结束时,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 可能导致竞态条件,或者更糟,导致 use-after-free 错误。

相反,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 时,你会将该数据的所有权转移 Mutex 中,并立即放弃对其的访问。(锁在首次创建时是未锁定的。)

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

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

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 强制执行锁定规范:除了持有锁之外,它不会让你访问受锁保护的数据。任何其他尝试都将生成编译器错误。例如,考虑以下有 bug 的“重构”:

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 通过在其析构函数中执行隐式 join(如果尚未显式发生),确保父线程等待其子线程(加入)。

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

因此,通过调整我们之前的例子,我们可以修复 bug 并让编译器满意:

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 的并发故事可以不断发展,涵盖新的范式并捕获新的 bug 类型。像 syncboxsimple_parallel 这样的库正在迈出第一步,我们预计在接下来的几个月里将在这个领域投入大量精力。敬请关注!