Rust:深入理解Rust中的内存顺序和Ordering
在并发编程中,正确管理内存操作的顺序是保证程序正确性的关键。Rust通过提供原子操作和内存顺序(Ordering
)枚举,使得开发者能够在多线程环境下安全高效地操作共享数据。本文旨在详细介绍Rust中Ordering
的原理和使用方法,帮助开发者更好地理解和运用这一强大的工具。
内存顺序的基础
现代处理器和编译器为了优化性能,会对指令和内存操作进行重排。这种重排在单线程程序中通常不会引发问题,但在多线程环境下,如果不适当控制,可能会导致数据竞争和状态不一致的问题。为了解决这一问题,引入了内存顺序的概念,通过为原子操作指定内存顺序来确保并发环境中的内存访问正确同步。
Rust中的Ordering枚举
Rust标准库中的Ordering
枚举提供了不同级别的内存顺序保证,允许开发者根据具体需求选择合适的顺序模型。以下是Rust中可用的内存顺序选项:
Relaxed
Relaxed
提供了最基本的保证,即保证单个原子操作的原子性,但不保证操作间的顺序。这适用于单纯的计数或状态标记,其中操作的相对顺序不影响程序的正确性。
Acquire 和 Release
Acquire
和Release
用于控制操作间的偏序关系。Acquire
保证当前线程在执行后续操作前,能看到与之匹配的Release
操作所做的修改。它们常用于实现锁和其他同步原语,确保资源在访问前被正确初始化。
AcqRel
AcqRel
结合了Acquire
和Release
的效果,适用于需要同时读取和修改值的操作,确保这些操作相对于其他线程是有序的。
SeqCst
SeqCst
,或顺序一致性,提供了最强的顺序保证。它确保所有线程看到相同顺序的操作,适用于需要全局执行顺序的场景。
使用Ordering的实践
选择合适的Ordering
是关键。过于宽松的顺序可能导致程序逻辑错误,而过于严格的顺序可能不必要地降低性能。以下是几个使用Ordering
的Rust代码示例:
这个示例展示了如何在多线程环境中使用Relaxed
顺序来进行简单的计数操作。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
let counter = AtomicUsize::new(0);
thread::spawn(move || {
counter.fetch_add(1, Ordering::Relaxed);
}).join().unwrap();
println!("Counter: {}", counter.load(Ordering::Relaxed));
- 这里创建了一个
AtomicUsize
类型的原子计数器counter
,并初始化为0。 - 使用
thread::spawn
启动一个新线程,在该线程中对计数器执行fetch_add
操作,即对计数器的值增加1。 Ordering::Relaxed
保证了这个增加操作在物理上是原子的,但不保证操作的顺序性。这意味着,如果有多个线程同时对counter
进行fetch_add
操作,所有操作都将安全地完成,但它们的执行顺序是不确定的。- 使用
Relaxed
适用于这种简单计数的场景,因为我们不关心增加操作的具体执行顺序,只关心最终的计数值。
示例2:使用Acquire
和Release
同步数据访问
这个示例展示了使用Acquire
和Release
来同步两个线程间的数据访问。
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::thread;
let data_ready = Arc::new(AtomicBool::new(false));
let data_ready_clone = Arc::clone(&data_ready);
// Producer thread
thread::spawn(move || {
// Prepare data
// ...
data_ready_clone.store(true, Ordering::Release);
});
// Consumer thread
thread::spawn(move || {
while !data_ready.load(Ordering::Acquire) {
// Wait until data is ready
}
// Safe to access the data prepared by producer
});
- 这里创建了一个
AtomicBool
标志data_ready
来表示数据是否准备好,初始状态为false
。 - 使用
Arc
来共享data_ready
,确保在多个线程间安全共享。 - 生产者线程准备数据后,使用
store
方法和Ordering::Release
来更新data_ready
的状态为true
,表示数据已准备好。 - 消费者线程使用
load
方法和Ordering::Acquire
循环检查data_ready
,直到其值为true
。这里Acquire
和Release
配对使用,确保生产者线程中准备数据的所有操作,在消费者线程看到data_ready == true
之前完成,从而安全地访问这些数据。
示例3:使用AcqRel
进行读-修改-写操作
这个示例展示了如何使用AcqRel
来保证在进行读-修改-写操作时的正确同步。
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use std::thread;
let some_value = Arc::new(AtomicUsize::new(0));
let some_value_clone = Arc::clone(&some_value);
// 修改线程
thread::spawn(move || {
// 这里的fetch_add既读取了值,又进行了修改,因此使用AcqRel
some_value_clone.fetch_add(1, Ordering::AcqRel);
}).join().unwrap();
println!("some_value: {}", some_value.load(Ordering::SeqCst));
-
AcqRel
是Acquire
和Release
的结合体,适用于同时需要Acquire
和Release
语义的场景,即在同一个操作中既读取(acquire)了数据,又修改(release)了数据。 -
在这个示例中,
fetch_add
是一个读-修改-写(RMW)操作。它首先读取some_value
的当前值,然后增加1,最后写回新值。因此,这个操作需要确保:- 读取的值是最新的,即之前的所有修改(在其他线程中可能已经发生)对当前线程可见(
Acquire
语义)。 - 对
some_value
的修改对其他线程立即可见(Release
语义)。
- 读取的值是最新的,即之前的所有修改(在其他线程中可能已经发生)对当前线程可见(
-
使用
AcqRel
保证了在当前线程中,任何在fetch_add
操作之前的读或写操作都不会被重新排序到它之后,同时任何在fetch_add
之后的读或写操作也不会被重新排序到它之前。这样确保了在修改some_value
时,与之相关的操作都能正确同步。
示例4:使用SeqCst
保证全局顺序
这个示例展示了如何使用SeqCst
来保证操作的全局顺序。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
let counter = AtomicUsize::new(0);
thread::spawn(move || {
counter.fetch_add(1, Ordering::SeqCst);
}).join().unwrap();
println!("Counter: {}", counter.load(Ordering::SeqCst));
-
与示例1相似,这里也是对原子计数器进行增加操作。
-
不同之处在于,这里使用的是
Ordering::SeqCst
顺序。SeqCst
是最严格的内存顺序,它不仅保证了单个操作的原子性,还保证了全局操作顺序的一致性。只有在多线程中需要强一致的情况下,才会用到,例如时间同步,游戏中的玩家同步,状态机同步等。使用seqcst可以保证同步的正确性。 -
当使用
SeqCst
时,所有线程中的SeqCst
操作看起来就像是按照某种单一的顺序执行的,这对于需要严格操作顺序的场景非常有用,比如确保增加操作按照特定的顺序发生。from Pomelo_刘金,转载请注明原文链接。感谢!
转载自:https://juejin.cn/post/7330571394550743074