Rust如何保证多线程应用程序中的安全性

Rust的大部分开发人员都有C/C++背景,这让开发者很容易过渡到Rust并行性,因为它是非常相似。但是对于许多来自其他开发语言的人来说,这是一个挑战。在本文中,我们将逐步介绍标准的Rust并行性工具及其背后的动机。在一开始,这将需要对硬件进行深入研究,然后是对诸如原子之类的低级工具的解释,最后是对诸如Mutex之类的高级工具的解释。最后,我们将说明Rust如何保证多线程应用程序中的安全性。

在Rust中,当您听到人们谈论并行性和并发性时,它主要是关于框架的,因为Rust作为一种语言不支持任何特定的并行性或并发性抽象,而是提供了最低限度的要求,例如标准线程和多个同步原语。这是我们将在本文中探讨的最低限度的内容。

为什么并行性很难

首先,我们需要了解为什么并行性很难,原因是硬件,操作系统和编译器过于复杂。 自1970年以来,处理器核心不再直接与内存配合使用,而是使用复杂的缓存和写缓冲区层次结构。

我们甚至不需要查看整个层次结构就可以理解为什么如此困难。让我们删除所有缓存并仅考虑写缓冲区。写缓冲区对于处理器的性能绝对必不可少,因为对内存的写操作非常昂贵,我们希望尽可能多地对其进行批处理。

考虑下我们在两个core上运行的程序。该程序有一个关键部分,我们不想在两个core上同时执行。确保这一点的方法之一是使用目的flag。core使用flag来声明其进入关键部分的flag。从逻辑上讲,如果其中一个core进入了关键部分,则它们的flag为非零,而另一个core也就不会进入。但是,如果两个core都写了它们的flag而不刷新缓冲区,那么它们都将进入临界区,因为它们将从内存中读取flag的0值。

考虑该问题的另一种方法是认为写缓冲区通过在flag1 = 1之前执行flag2!= 0来对操作顺序进行了重新排序。类似地,我们可以认为缓存也对操作进行了重新排序。

执行优化(如子表达式消除)的编译器以及执行预取和推测等操作的处理器也会重新排序操作, 结果源代码中的操作顺序将与特定core执行的顺序不同。实际上,当在两个单独的cord上并行执行时,相同的代码可以具有不同的操作顺序。

如果我们不使用运行在不同core上的线程相互协作,那么操作顺序将不会成为问题。协作线程要求我们像上面的示例一样,认为操作X在线程B上的操作Y之前发生在线程A上。多线程要求我们能够讨论跨线程的操作之间的因果关系。没有特殊工具帮助是不可能的。

低级原语(Low-Level Primitives)

原子性是低级同步原语,它通过限制操作顺序使我们具有因果关系。这些原语必须是由处理器级,因为除了限制编译器之外,我们还希望从高速缓存级的重排序和其他方面限制处理器。 原子性提供两个保证:

· 我们可以对它们执行读/写操作,而不必担心读或写被破坏。
· 原子操作即使在线程之间也可以保证它们相对于彼此执行的顺序。 实际上,原子甚至会在非原子操作上强制执行该顺序,我们将在后面看到。

要求对原子变量进行的每个操作都必须具有排序类型:

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可以在内部进行所有操作并进行交换,如下所示。然而,在acquire之前,他们看不到a=“bye”的发生。

通过Acquire/Release,我们可以在线程之间建立因果关系。 例如在下面的代码中,我们可以假设如果b为true,则a也必须也设置为true。

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); }
    });
}

使用Atomic Acquire/Release,我们可以实现一个全功能的spinlock,它保护代码的某个区域不受多个线程的并发访问:

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内存模型继承了它。

顺序一致性

不幸的是,在许多情况下,Acquire/Release仍然存在很难的争论。请看以下代码:

在这段代码中,两条消息都有可能被打印,这意味着线程c和线程d对最先发生的事件有不一致的视图。换句话说,使用acquire/release没有全局操作顺序。acquire/release只是创建传递的因果关系,seqcst建立了一个全局操作顺序。如果我们将上述获取/发布替换为seqcst,则最多将打印一条消息。更正式地说,seqcst遵循以下规则:

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

在rustseqcst中,它会发出一个防止不需要的重新排序的内存屏障。 不幸的是,SeqCst比纯Acquire / Rease更昂贵,然而,在全局范围内仍然可以忽略不计,因此强烈建议尽可能使用seqcst。

高级原语(High-Level Primitives)

在上面的代码中,我们使用了线程,而没有解释它们是什么。 通常,人们将线程称为三件事:

硬件线程,又名超线程;
操作系统线程;
绿色线程;

超线程是指处理器将每个物理内核虚拟地拆分为两个虚拟内核时,可以实现更好的负载分配。操作系统线程由操作系统内部创建和管理,其中每个线程执行自己的代码,并轮流在虚拟内核上运行。大多数操作系统实际上使线程数不受限制,但是不幸的是,启动它们是昂贵的,因为它需要分配堆栈。绿色线程由用户软件实现,它们在OS线程之上运行。 绿色线程的优点是:即使在没有OS线程支持的环境中,它们也可以工作;它们比常规线程旋转起来快得多。

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

绿色线程将需要繁重的运行时,即使每个程序不使用它们,也必须为此付费。

但是,如果人们知道如何正确使用它们,则OS线程并不昂贵。 考虑带有自旋锁的上一个示例。 循环将在等待释放锁的同时燃烧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

在上一节中,我们讨论了原子,它们是在硬件级别运行的低级同步原语。 Mutex和RwLock是高级同步原语,它们在OS级别上运行。

Mutex和RwLock与我们之前看过的自旋锁相似,但有一个主要区别-自旋锁(spinlock)消耗CPU等待该锁的时间,而Mutex和RwLock释放当前的OS线程并且不燃烧CPU。 因此它们必须在操作系统级别而不是在纯硬件级别上进行操作,类似于我们对产生线程的自旋锁的修改。 但是产生自旋锁(spinlock)和互斥锁(Mutex)之间的主要区别在于,使用互斥锁(Mutex),操作系统知道一旦释放锁后何时唤醒等待线程,而使用产生自旋锁(spinlock),操作系统将偶尔地唤醒等待线程,希望释放锁此外,互斥锁的实现是特定于平台的。

RwLock与Mutexes相似,因为它们保护某些区域的代码不被并发访问,但需要权衡取舍:

Mutex将代码从读和写两方面锁定,而RwLock允许在没有写的情况下并发读取,类似于借用检查器;

Mutex是一个同步生成器。在下一部分中,我们将看到什么是发送和同步特征,并将再次访问Mutex和RwLock;

通过发送和同步(Send and Sync)实现安全性

在上一篇文章中,我们了解了Rust如何通过借用规则和生存期来提供单线程安全性。 发送和同步特征将这种安全性扩展到多线程应用程序中。

关于Rust安全性,最重要的了解是它只能防止数据争用,而不能阻止其他任何事情。当一个线程写入内存区域,而另一个线程从该区域读取或写入该区域时,就会发生数据争用,这会导致读写操作中断。数据争用尤其令人讨厌,因为它们可能导致未定义的行为。它们的原因是非常明确的,因此可以像Rust一样自动检测到或在语言级别上阻止它。

另一方面,竞争条件是语义错误。例如我们可以错误地假设一个事件总是在另一个事件之前发生。竞争条件打破了域逻辑不变性,通常是不正确同步或缺少同步的标志。Rust无法使我们免于犯语义级别的错误。实际上,设计这样的语言是不可行的。死锁和活锁也是语义错误,是域逻辑不变的结果,例如我们假设锁A总是在持有锁B的同时发生,但是我们在代码中的某处实现了导致死锁的另一种方式。因此我们唯一要谈论的是Rust如何防止数据竞争。

发送(send)和同步(Sync)旨在防止数据争用。 发送(send)和同步(Sync)是自动派生的,不安全和标记特征。

· 工程师没有明确实现自动特征。相反编译器会自动派生它们。发送特征标记结构可在线程之间安全发送,同步特征标记结构可在线程之间安全共享。如果结构的所有字段均为“发送/同步”,则编译器将其确定为“发送/同步”。

· 不安全的特征需要使用不安全的关键字才能实现。

· 标记特征没有特别的方法,仅用于表达实现它们的结构的某些属性。例如eq-trait是标记特性的另一个例子。EQ告诉我们,一个已经实现相等操作的结构可以被使用,好像这个操作是自反的、对称的和传递的。

大多数原语都是发送/同步(Send/Sync),因此几乎所有类型都是发送/同步(Send/Sync),除了Rc,Cell和RefCell。 Rc,Cell和RefCell不同步,因为它们实现内部可变性,这意味着对它们的操作(如果同时执行)可能导致数据争用。同样,Rc不是“发送”,因为它会将指针复制到相同的数据,因此线程不需要共享相同的副本即可引起数据竞争。因此Rust完全禁止跨线程发送Rc。 有趣的是,Cell和RefCell告诉编译器它们的不安全性的方式是通过用UnsafeCell包裹它们的内部字段,该对象的全部目的是防止Sync特性的自动派生。 Rc不使用UnsafeCell,而是显式声明自身!Send和!Sync。

实现sync的对象的引用是send,反之亦然。 换句话说,&t:send表示t:sync,t:sync表示&t:send。在线程之间发送对象非常常见,而共享则不太常见。通常当我们希望线程访问同一对象时,我们将其包装到一个智能指针(如Arc)中,这导致我们发送其副本而不是实际共享它。要共享一个对象,我们需要共享其引用,如下所示:

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

由于std :: thread :: spawn仅采用具有静态生存期的闭包,因此在大多数情况下不起作用。从堆栈中实际借用变量的唯一方法是使用第三方库(如crossbeam)中的作用域线程。

现在让我们再谈谈Mutex与RwLock。 形式上,它们的实现如下所示:

impl Send for Mutex
impl Sync for Mutex



impl        Send for RwLock
impl Sync for RwLock

这意味着我们可以将仅实现Send而不实现Sync的对象T包装到Mutex中,并且Mutex 将同时成为Send和Sync。 RwLock虽然不是同步生成器。 由于多个线程可以同时访问基础对象,因此它应该是Sync。 互斥锁阻止任何形式的同时访问,因此可以想到该对象被发送到持有锁的线程。

相关文章