MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Rust原子操作的获取和修改策略

2022-06-156.3k 阅读

Rust原子操作概述

在多线程编程中,确保对共享资源的安全访问至关重要。Rust通过原子操作(Atomic Operations)来实现这一点,原子操作是不可中断的操作,在执行过程中不会被其他线程干扰。这对于避免数据竞争(data races)和保证线程安全至关重要。

Rust的std::sync::atomic模块提供了一系列原子类型,如AtomicBoolAtomicI32AtomicU64等,这些类型支持各种原子操作,包括获取和修改操作。

获取策略

1. load 方法

load方法用于从原子变量中读取值。它接受一个Ordering参数,这个参数决定了内存顺序(memory ordering)。内存顺序定义了不同线程对内存操作的可见性和顺序。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let atomic_var = AtomicI32::new(42);
    let value = atomic_var.load(Ordering::SeqCst);
    println!("Loaded value: {}", value);
}

在上述代码中,我们创建了一个AtomicI32类型的原子变量atomic_var,并初始化为42。然后使用load方法读取其值,这里使用的Ordering::SeqCst(顺序一致性)是最严格的内存顺序,它保证所有线程以相同的顺序看到所有的内存操作。

2. Relaxed 顺序

Relaxed顺序是最宽松的内存顺序。在这种顺序下,原子操作仅保证自身的原子性,而不保证与其他线程操作的顺序关系。这意味着不同线程对同一原子变量的操作可能以不同的顺序被观察到。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let atomic_var = AtomicI32::new(0);

    let handle1 = thread::spawn(move || {
        atomic_var.store(1, Ordering::Relaxed);
    });

    let handle2 = thread::spawn(move || {
        let value = atomic_var.load(Ordering::Relaxed);
        println!("Loaded value in thread 2: {}", value);
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

在这个例子中,thread1使用Relaxed顺序存储值1,thread2使用Relaxed顺序读取值。由于Relaxed顺序的宽松性,thread2有可能读取到0,尽管thread1已经存储了1。

3. Acquire 顺序

Acquire顺序保证在读取原子变量后,所有后续的内存读取操作都不会被重排序到这个读取操作之前。这确保了在读取原子变量后,之前其他线程对共享内存的修改对当前线程是可见的。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let atomic_var = AtomicI32::new(0);
    let shared_data = vec![10, 20, 30];

    let handle1 = thread::spawn(move || {
        atomic_var.store(1, Ordering::Release);
        shared_data[0]; // 对共享数据的操作
    });

    let handle2 = thread::spawn(move || {
        while atomic_var.load(Ordering::Acquire) != 1 {
            // 等待值变为1
        }
        let value = shared_data[0];
        println!("Loaded shared data value: {}", value);
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

在这个例子中,thread1使用Release顺序存储值1,并对共享数据shared_data进行操作。thread2使用Acquire顺序等待atomic_var的值变为1,然后读取shared_data的值。Acquire顺序保证当thread2读取到值1时,thread1shared_data的修改对thread2是可见的。

4. SeqCst 顺序

SeqCst(顺序一致性)是最严格的内存顺序。所有线程以相同的顺序看到所有的内存操作,就好像这些操作是按照代码顺序依次执行的。虽然SeqCst提供了最强的一致性保证,但它也是最昂贵的,因为它会限制编译器和处理器的优化能力。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let atomic_var1 = AtomicI32::new(0);
    let atomic_var2 = AtomicI32::new(0);

    let handle1 = thread::spawn(move || {
        atomic_var1.store(1, Ordering::SeqCst);
        atomic_var2.store(2, Ordering::SeqCst);
    });

    let handle2 = thread::spawn(move || {
        while atomic_var1.load(Ordering::SeqCst) != 1 {
            // 等待atomic_var1变为1
        }
        let value = atomic_var2.load(Ordering::SeqCst);
        println!("Loaded value of atomic_var2: {}", value);
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

在这个例子中,thread1按顺序使用SeqCst顺序存储值到atomic_var1atomic_var2thread2先等待atomic_var1变为1,然后读取atomic_var2的值。由于SeqCst顺序,thread2一定能读取到值2。

修改策略

1. store 方法

store方法用于将值写入原子变量。与load方法类似,它也接受一个Ordering参数来指定内存顺序。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let atomic_var = AtomicI32::new(0);
    atomic_var.store(42, Ordering::SeqCst);
    let value = atomic_var.load(Ordering::SeqCst);
    println!("Stored and loaded value: {}", value);
}

在这个例子中,我们使用store方法将值42写入AtomicI32变量atomic_var,然后使用load方法读取并打印这个值。

2. Release 顺序

Release顺序保证在写入原子变量之前,所有之前的内存写入操作都不会被重排序到这个写入操作之后。这确保了在写入原子变量后,当前线程对共享内存的修改对其他线程是可见的,当其他线程以AcquireSeqCst顺序读取这个原子变量时。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let atomic_var = AtomicI32::new(0);
    let shared_data = vec![10, 20, 30];

    let handle1 = thread::spawn(move || {
        shared_data[0]; // 对共享数据的操作
        atomic_var.store(1, Ordering::Release);
    });

    let handle2 = thread::spawn(move || {
        while atomic_var.load(Ordering::Acquire) != 1 {
            // 等待值变为1
        }
        let value = shared_data[0];
        println!("Loaded shared data value: {}", value);
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

在这个例子中,thread1先对shared_data进行操作,然后使用Release顺序存储值1到atomic_varthread2使用Acquire顺序等待atomic_var的值变为1,然后读取shared_data的值。ReleaseAcquire顺序的组合保证了thread1shared_data的修改对thread2是可见的。

3. Compare - and - Swap(CAS)操作

Rust的原子类型提供了compare_and_swap方法(在较新版本中也可以使用compare_exchangecompare_exchange_weak)。CAS操作将原子变量的当前值与预期值进行比较,如果相等,则将原子变量的值更新为新值。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let atomic_var = AtomicI32::new(42);
    let expected = 42;
    let new_value = 99;

    let result = atomic_var.compare_and_swap(expected, new_value, Ordering::SeqCst);
    if result == expected {
        println!("Value successfully updated to: {}", new_value);
    } else {
        println!("Value was not as expected. Current value: {}", result);
    }
}

在这个例子中,我们使用compare_and_swap方法尝试将atomic_var的值从42更新为99。如果atomic_var的当前值是42(即与expected相等),则更新为99,并返回旧值42。否则,返回当前值,并且不更新atomic_var

4. Fetch - and - 操作

Rust的原子类型还提供了一系列fetch_and_*方法,如fetch_addfetch_sub等。这些方法会先读取原子变量的当前值,然后进行指定的操作(如加法、减法等),并返回旧值。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let atomic_var = AtomicI32::new(10);
    let old_value = atomic_var.fetch_add(5, Ordering::SeqCst);
    println!("Old value: {}, New value: {}", old_value, atomic_var.load(Ordering::SeqCst));
}

在这个例子中,我们使用fetch_add方法将atomic_var的值增加5,并返回旧值10。然后我们使用load方法读取更新后的值15。

复杂场景下的原子操作

1. 多线程计数器

在多线程环境中,实现一个线程安全的计数器是常见的需求。我们可以使用原子操作来实现这一点。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let counter = AtomicI32::new(0);
    let num_threads = 10;
    let mut handles = vec![];

    for _ in 0..num_threads {
        let counter_clone = counter.clone();
        let handle = thread::spawn(move || {
            for _ in 0..100 {
                counter_clone.fetch_add(1, Ordering::SeqCst);
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    let final_value = counter.load(Ordering::SeqCst);
    println!("Final counter value: {}", final_value);
}

在这个例子中,我们创建了一个AtomicI32类型的计数器counter,并在10个线程中分别对其进行100次递增操作。由于使用了fetch_add原子操作,每个线程的递增操作都是安全的,最终我们得到正确的计数器总和1000。

2. 共享状态管理

假设有一个多线程应用程序,需要管理一个共享的状态,例如一个表示系统运行状态的标志。不同线程可能需要读取和修改这个状态。

代码示例

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

fn main() {
    let running_state = AtomicBool::new(true);
    let mut handles = vec![];

    let handle1 = thread::spawn(move || {
        while running_state.load(Ordering::Acquire) {
            // 执行任务
            println!("Thread 1 is running...");
            thread::sleep(std::time::Duration::from_millis(100));
        }
    });
    handles.push(handle1);

    let handle2 = thread::spawn(move || {
        thread::sleep(std::time::Duration::from_millis(500));
        running_state.store(false, Ordering::Release);
    });
    handles.push(handle2);

    for handle in handles {
        handle.join().unwrap();
    }
}

在这个例子中,thread1通过Acquire顺序读取running_state,并在其为true时执行任务。thread2在延迟500毫秒后,使用Release顺序将running_state设置为falseAcquireRelease顺序的组合确保了thread1能正确感知到thread2running_state的修改。

原子操作与缓存一致性

现代处理器通常都有多层缓存(如L1、L2、L3缓存),以提高内存访问速度。然而,这也带来了缓存一致性问题,即在多处理器系统中,不同处理器的缓存可能会存储同一内存位置的不同值。

Rust的原子操作通过内存顺序(如AcquireReleaseSeqCst等)来帮助解决缓存一致性问题。例如,Release顺序会将修改后的值写回主内存,并使其他处理器的缓存失效,而Acquire顺序会从主内存重新读取值,确保看到最新的修改。

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;

fn main() {
    let atomic_var = AtomicI32::new(0);

    let handle1 = thread::spawn(move || {
        atomic_var.store(1, Ordering::Release);
    });

    let handle2 = thread::spawn(move || {
        while atomic_var.load(Ordering::Acquire) != 1 {
            // 等待值变为1
        }
        println!("Value updated as expected.");
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

在这个例子中,thread1使用Release顺序存储值1,这会确保修改后的值被写回主内存并使其他处理器缓存失效。thread2使用Acquire顺序读取值,这会从主内存读取,从而能看到thread1的修改。

原子操作的性能考量

不同的内存顺序对性能有显著影响。Relaxed顺序是最宽松的,因此通常性能最好,因为它允许编译器和处理器进行更多的优化。然而,它仅保证原子性,不保证操作顺序,因此只适用于对顺序要求不高的场景。

AcquireRelease顺序提供了一定的顺序保证,同时相对SeqCst顺序对性能的影响较小。它们适用于大多数需要保证数据一致性,但又希望有较好性能的场景。

SeqCst顺序提供了最强的一致性保证,但由于其严格的顺序要求,会限制编译器和处理器的优化能力,从而导致性能下降。因此,只有在确实需要严格顺序一致性的场景下才使用SeqCst

代码示例

use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;
use std::time::Instant;

fn main() {
    let atomic_var = AtomicI32::new(0);
    let num_iterations = 1000000;

    let start = Instant::now();
    for _ in 0..num_iterations {
        atomic_var.fetch_add(1, Ordering::Relaxed);
    }
    let elapsed_relaxed = start.elapsed();

    let start = Instant::now();
    for _ in 0..num_iterations {
        atomic_var.fetch_add(1, Ordering::SeqCst);
    }
    let elapsed_seqcst = start.elapsed();

    println!("Time taken with Relaxed: {:?}", elapsed_relaxed);
    println!("Time taken with SeqCst: {:?}", elapsed_seqcst);
}

在这个例子中,我们分别使用RelaxedSeqCst顺序对AtomicI32变量进行100万次递增操作,并测量每次操作的耗时。可以预期,Relaxed顺序的操作会比SeqCst顺序快得多。

总结

Rust的原子操作提供了强大的工具来实现多线程编程中的线程安全。通过合理选择获取和修改策略,如选择合适的内存顺序,可以在保证数据一致性的同时,尽可能地提高性能。在实际应用中,需要根据具体的需求和场景来选择合适的原子操作和内存顺序,以实现高效且线程安全的多线程程序。同时,理解原子操作与缓存一致性的关系以及性能考量,对于编写高性能的多线程代码也至关重要。