面向非C/C 开发者的Rust并发

马克西姆-扎韦尔辛斯基 12分钟阅读

最低限度。

大多数来到Rust的人都有C/C++的背景,这使得他们可以轻松地过渡到Rust的并行性,因为它是如此相似。然而,对于许多来自其他语言的人来说,这是一个挑战。在这篇文章中,我们将介绍标准的Rust并行工具,以及它们背后的动机。这需要在开始时对硬件进行深入研究,然后解释低级工具,如atomics,最后解释高级工具,如Mutex。最后,我们将解释Rust如何保证多线程应用的安全。

在Rust中,当你听到人们谈论并行性和并发性时,主要是指框架,因为Rust作为一种语言并不赞成任何特定的并行性或并发性抽象,而是提供最低限度的,如标准线程和几个同步原语。这种最低限度的东西就是我们在这篇文章中要探讨的。

为什么并发是困难的

首先,我们需要了解为什么并行化很难,原因是硬件、操作系统和编译器都太复杂。由于1970年的处理器核心并不直接与内存一起工作,而是使用复杂的缓存和写缓冲器的层次结构。

摘自《原子<>武器》。

我们甚至不需要回顾整个层次结构就能理解为什么它很难。让我们删除所有的缓存,只考虑写缓冲区。写入缓冲区对于处理器的性能来说是绝对必要的,因为向内存的写入是很昂贵的,我们希望尽可能地批量写入。

摘自《原子<>武器》。

考虑以下程序,我们在两个核心上运行。该程序有一个关键部分,我们不希望它同时在两个核心上执行。确保这一点的方法之一是使用intent标志。intent标志被内核用来声明它们进入关键部分的意图。从逻辑上讲,如果其中一个内核已经进入了关键部分,那么他们的intent标志就是非零的,另一个内核就不会进入。然而,如果两个内核都写了它们的intent标志而没有Flush缓冲区,那么它们都将进入临界区,因为它们将从内存中读取意图标志的0值。

另一种思考问题的方式是认为写缓冲区通过在flag1 = 1之前执行flag2 != 0来重新安排操作的顺序。同样的,我们可以认为缓存也重新安排了操作的顺序。

操作也会被进行优化的编译器重新排序,比如子表达式的消除,以及被进行预取(prefetching)和推测(speculation)的处理器重新排序,以及其他事情。因此,源代码中的操作顺序将与特定内核执行的顺序不同。事实上,同样的代码在两个独立的内核上并行执行时,会有不同的操作顺序。

如果我们不使用运行在不同内核上的线程来相互协作,那么操作的顺序就不会成为问题。协作的线程要求我们论证,操作X发生在线程A上,然后才是线程B上的操作Y,就像上面的例子。多线程要求我们能够谈论跨线程操作之间的因果关系。如果没有特殊的工具,那是不可能的。

低级别的原语

原子是低级别的同步原语,它允许我们通过限制操作的顺序来获得因果关系。这些原语需要是处理器级的,因为除了限制编译器之外,我们还想限制处理器的缓存级重排和其他事情。原语给出了两个保证。

  • 我们可以对它们进行读/写操作,而不必担心读或写的分裂。
  • 原子操作对它们的执行顺序有一定的保证,相对于其他操作,甚至跨线程执行。事实上,原子操作甚至强制执行非原子操作的顺序,我们将在后面看到。

对原子变量的每个操作都需要有一个排序类型。

  • Ordering::Relaxed
  • Ordering::Acquire和Ordering::Release
    (或它们的联合替代品Ordering::AcqRel)
  • Ordering::SeqCst - 顺序一致性的简称

你几乎总是会使用SeqCst,它应用了最强的约束,也是最容易推理的。Relaxed应用了最弱的约束,而且非常不直观,所以除非你在开发低级别的高性能代码,否则应该远离它。就认知的复杂性而言,Acquire/Release是一个中间地带,但你仍然几乎不会喜欢它而不是SeqCst。然而,理解Acquire/Release对于理解Mutex和RwLock等高级同步原语非常有帮助。

Acquire/Release

正如我们之前所说,Acquire/Release是硬件级的操作,因为它们为硬件生成了特殊指令。我们可以通过以下方式使用 Acquire/Release。

let x = AtomicUsize::new(0);let mut result = x.load(Ordering::Acquire);result += 1;x.store(result, Ordering::Release); // The value is now 1.

Acquire只能用于加载操作,而Release只能用于存储操作。Acquire和Release有以下规则。

Acquire--代码中所有发生在它之后的内存访问都留在它之后,所有线程都可以看到(记住,线程B和C可以感知到线程A对内存的操作顺序是不同的)。

Release--代码中发生在它之前的所有内存访问都留在它之前,为所有线程所看到。

所以在下面的情况下,如果线程A执行左边的代码,线程B和C可以在里面对a,b,c的所有操作进行交换,见下文。然而,他们不能看到c = 'Bye '发生在Acquire之前。

取自Atomics <> Weapons

通过Acquire/Release,我们可以建立线程之间的因果关系。例如,在下面的代码中,我们可以假设,如果b为真,那么a一定也被设置为真。

let x = Arc::new(AtomicBool::new(false));let y = Arc::new(AtomicBool::new(false));{    let x = x.clone();    let y = y.clone();    thread::spawn(move || {        x.store(true, Ordering::Release);        y.store(true, Ordering::Release);    });}{    let x = x.clone();    let y = y.clone();    thread::spawn(move || {        let b = y.load(Ordering::Acquire);        let a = x.load(Ordering::Acquire);        if b { assert!(a); }    });}

使用原子化的 Acquire/Release,我们可以实现一个全功能的自旋锁,保护代码的某个区域不被几个线程同时访问。

while(locked.compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)) {}// Do important stuff that only one thread can execute at a time.locked.store(false, Ordering::Release);

注意,除了上面的限制,Acquire/Release操作还有一个比较隐晦的规则,可以防止像上面那样的自旋锁的干扰。Rust从C11内存模型中继承了它。

顺序的一致性

不幸的是,在许多情况下,获取/释放仍然是难以争辩的。考虑一下下面的代码。

摘自《原子<>武器》。

在这段代码中,两个消息都有可能被打印出来,这意味着线程CD对哪个事件先发生有不一致的看法。换句话说,有了Acquire/Release,就没有了全局的操作顺序。Acquire/Release只是创造了横向因果关系,SeqCst则建立了一个全局的操作顺序。如果我们用SeqCst代替上述Acquire/Release,那么最多只能打印一条信息。更正式地说,SeqCst遵循以下规则。

所有发生在SeqCst操作之前/之后的原子操作在所有线程上都保持在它之前/之后。普通的非原子读和写可以在一个原子读中向下移动,或者在一个原子写中向上移动。

在Rust中,SeqCst涉及发射一个内存barrier(不要与内存fence混淆),以防止不良的重新排序。不幸的是,SeqCst比纯粹的Acquire/Release更昂贵,然而,从全局来看,它仍然可以忽略不计,因此,强烈建议尽可能使用SeqCst。

高级原语

在上面的代码中,我们使用了线程而没有解释它们是什么。一般来说,有三种东西被人们称为线程。

  • 硬件线程,又称超线程。
  • 操作系统线程。
  • 绿色线程 Green Thread。

超线程是指处理器实际上将每个物理核心分割成两个虚拟核心,从而实现更好的负载分配。操作系统线程是由操作系统内部创建和管理的,每个线程都执行自己的代码,它们在虚拟核心上轮流运行。大多数操作系统使线程的数量实际上是无限的,不幸的是,启动它们是昂贵的,因为它需要分配一个堆栈。绿色线程是由用户软件实现的,它们在操作系统线程的基础上运行。绿色线程的优点是:它们甚至可以在没有操作系统线程支持的环境中工作;它们的启动速度比普通线程快得多。

不幸的是,Rust已经删除了绿色线程,现在只允许裸露的操作系统线程。这样做是因为绿色线程不是一个零成本的抽象,而这是Rust区别于其他语言的一个基本规则。

零成本的抽象概念。你不使用的东西,你不需要付钱。
更进一步。你使用的东西,你不可能用手写代码来写得更好。

- Bjarne Stroustrup

绿色线程需要有一个沉重的运行时间,每个程序都必须为此付费,即使它不使用它们。

然而,如果知道如何正确使用操作系统线程,就不会那么昂贵。考虑一下前面的自旋锁的例子。在等待锁被释放的过程中,该循环将燃烧CPU。我们可以通过使用 yield_now 来解决这个问题。

while(locked.compare_exchange(false, true, Ordering::Acquire,                               Ordering::Acquire) {    std::thread::yield_now();}// Do important stuff.locked.store(false, Ordering::Release);

由于操作系统的线程可能多于虚拟核心,可能还有另一个线程在等待被安排。 yield_now告诉操作系统,它可以尝试在该虚拟核心上运行另一个线程,而第一个线程在等待其锁。

Mutex和RwLock

在上一节中,我们谈到了atomics,它是在硬件层面上运行的低级同步原语。Mutex和RwLock是高层次的同步原语,它们在操作系统层面上运行。在这篇文章中,我们将不涉及Channel、CondVar和Barrier,因为我们提供了足够的背景,能够从它们的文档中了解到它们。

Mutex和RwLock类似于我们之前看过的spinlock,但有一个主要区别--spinlock会消耗CPU来等待锁,而Mutex和RwLock则是释放当前的操作系统线程,并不消耗CPU。因此,它们必须在操作系统层面而不是纯硬件层面进行操作,类似于我们对产生线程的自旋锁的修改。然而,产生自旋锁和Mutex之间的主要区别是,在Mutex中,一旦锁被释放,操作系统知道何时唤醒等待的线程,而在产生自旋锁中,操作系统将零星地唤醒等待的线程,希望锁被释放。另外,Mutex的实现是针对平台的。

RwLocks与Mutexes类似,因为它们保护代码的某个区域不被同时访问,但也有一些权衡。

  • Mutex锁定了代码的读和写,而RwLock允许并发的读,只要没有写,模仿借用检查器。
  • Mutex是一个同步制造者。在下一节中,我们将看到什么是发送和同步的特征,并将重新审视Mutex和RwLock。

通过Send和Sync实现线程安全

在上一篇文章中,我们看到了Rust是如何通过借用规则和生命期提供单线程安全的。Send和Sync特性将这种安全性扩展到多线程应用程序中。

关于Rust安全,最重要的一点是,它只能防止数据竞争,而不能防止其他。数据竞争发生在一个线程向内存区域写东西的同时,另一个线程从该区域读出或写进该区域,这就导致了读和写的分裂。

数据竞争是特别讨厌的,因为它们会导致未定义的行为。然而,它们的原因是明确的,因此可以自动检测或在语言层面上防止,就像Rust那样。

另一方面,竞争条件是语义错误。例如,我们可以错误地认为一个事件总是在另一个事件之前发生。竞争条件破坏了领域逻辑的不变性,通常是不正确的同步或缺乏同步的标志。Rust不能使我们免于犯语义层面的错误。事实上,设计这样一种语言是不可行的。死锁和活锁也是语义上的错误,是领域逻辑不变量被破坏的结果,例如,我们假设锁A总是在持有锁B时发生,但在我们代码的某个地方,我们以相反的方式实现了它,造成了死锁。因此,我们唯一要讨论的是Rust如何防止数据竞争。

Send和Sync是为了防止数据竞争。Send和Sync是自动派生的不安全的标记性的特征

  • 自动特质不是由工程师明确实现的,而是由编译器自动得出的。发送特质标志着可以在线程之间安全发送的结构,同步特质标志着可以在线程之间安全共享的结构。如果一个结构的所有字段都是Send/Sync,编译器就会决定该结构是Send/Sync。
  • 不安全特性需要不安全关键字来实现。
  • 标记性特征没有方法,仅用于表达实现这些特征的结构的某些属性;例如,Eq特征是标记性特征的另一个例子。Eq告诉我们,一个已经实现了平等操作的结构可以被当作是反射性、对称性和传递性操作来使用。

大多数原语都是Send/Sync的,因此几乎所有的类型都是Send/Sync的,除了Rc、Cell和RefCell。Rc、Cell和RefCell不是同步的,因为它们实现了内部可变性,这意味着对它们的操作,如果同时执行,会引起数据竞争。另外,Rc不是Send,因为它复制了指向相同数据的指针,所以线程不需要共享相同的副本就能引起数据竞赛;因此Rust完全禁止跨线程发送Rc。有趣的是,Cell和RefCell告诉编译器它们不安全的方式是用UnsafeCell包装它们的内部字段,UnsafeCell的全部目的是为了防止自动衍生出Sync特性。Rc没有使用UnsafeCell,而是明确地声明自己是!'Send'和 'Sync'。

一个实现了Sync的对象的引用就是Send,反之亦然。换句话说,&T: Send意味着T: Sync,T: Sync意味着&T: Send。在线程之间发送一个对象是很常见的,而共享则不太常见。通常,当我们想让线程访问同一个对象时,我们会把它包装成一个智能指针,比如Arc,这导致我们Send 它的副本,而不是真正地共享它。要共享一个对象,我们需要共享它的引用,像这样。

fn main() { let x = 42; thread::spawn(|| { println!('{}', x); }).join.unwrap();}

这在大多数情况下是行不通的,因为std::thread::spwn只接受具有静态寿命的闭包。从堆栈中实际借用变量的唯一方法是使用第三方库(如crossbeam)中的一个范围线程。

现在,让我们再谈谈Mutex与RwLock。从形式上看,它们的实现是这样的。

impl<T: ?Sized + Send> Send for Mutex<T>impl<T: ?Sized + Send> Sync for Mutex<T>

impl<T: ?Sized + Send> Send for RwLock<T>impl<T: ?Sized + Send + Sync> Sync for RwLock<T>

这意味着我们可以将一个只实现Send而不实现Sync的对象T包裹到Mutex中,Mutex<T>将同时成为Send和Sync。不过RwLock不是一个同步器。因为几个线程可以同时对底层对象进行读取访问,所以它应该是同步的。Mutex阻止了任何形式的同时访问,因此我们可以认为该对象被发送到持有锁的线程。

引用

编撰该帖时使用了以下资源。

  • Rustonomicon 中的并发性章节
  • Without Boats的Zero-Cost Async IO讲座,讲述了Rust并发性的过去和未来。
  • Atomic <> Weapons 是Herb Sutter的一个伟大的演讲,涵盖了C++并发性的许多细枝末节。
  • C++ Concurrency in Actio》是Anthony Williams的一本好书,它给出了许多有用的例子,我没能在这篇文章中包括。
  • 安东尼-威廉姆斯(Anthony Williams)的 stackoverflow 并发回答包含了他书中的一些内容。

最后,感谢Gail Hernandez;Alex Skidanov,Bowen Wang和我们Near Protocol团队的其他所有人。对于那些对Near Protocol感兴趣的人来说:我们建立了一个分片的通用区块链,非常强调实用性。

(0)

相关推荐