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

Rust原子操作实现

2021-07-247.9k 阅读

Rust 原子操作基础概念

在并发编程中,原子操作起着至关重要的作用。原子操作是不可分割的操作,在执行过程中不会被其他线程干扰。在 Rust 中,原子类型提供了一种安全且高效的方式来进行线程间的数据共享和同步。

原子类型概述

Rust 标准库在 std::sync::atomic 模块中提供了一系列原子类型,例如 AtomicBoolAtomicI8AtomicI16AtomicI32AtomicI64AtomicU8AtomicU16AtomicU32AtomicU64AtomicUsizeAtomicPtr 等。这些原子类型实现了 Atomic trait,这意味着它们提供了各种原子操作方法。

内存顺序

原子操作通常与内存顺序(memory order)紧密相关。内存顺序定义了原子操作与其他内存操作之间的可见性和顺序关系。Rust 中的原子操作支持几种不同的内存顺序:

  1. SeqCst(顺序一致性):这是最严格的内存顺序。所有线程以相同的顺序观察所有 SeqCst 原子操作。使用 SeqCst 顺序的原子操作就像在一个全局的顺序中执行一样,所有线程都能看到一致的操作顺序。
  2. ReleaseAcquireRelease 顺序用于存储操作,它确保在该原子存储之前的所有内存操作对其他线程可见,当其他线程以 Acquire 顺序读取这个原子变量时。Acquire 顺序用于加载操作,它确保在该原子加载之后的所有内存操作在看到 Release 存储之前不会被重新排序。
  3. Relaxed:这是最宽松的内存顺序。Relaxed 原子操作只保证操作本身的原子性,不提供任何内存顺序保证。也就是说,其他线程可能不会立即看到这个原子操作的结果,并且这个原子操作可以与其他内存操作自由地重新排序。

Rust 原子操作实现方式

AtomicBool 的操作

AtomicBool 是一个简单的原子布尔类型,常用于实现线程间的简单同步。以下是一些常见的操作示例:

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

fn main() {
    let flag = AtomicBool::new(false);

    // 使用 Relaxed 顺序设置值
    flag.store(true, Ordering::Relaxed);

    // 使用 Relaxed 顺序获取值
    let value = flag.load(Ordering::Relaxed);
    println!("The value of flag is: {}", value);
}

在上述代码中,我们创建了一个 AtomicBool 实例 flag 并初始化为 false。然后我们使用 store 方法以 Relaxed 顺序将其设置为 true,接着使用 load 方法以 Relaxed 顺序获取其值并打印。

整数类型的原子操作

对于整数类型的原子操作,Rust 提供了丰富的方法。以 AtomicI32 为例:

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

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

    // 使用 Relaxed 顺序增加计数器的值
    counter.fetch_add(1, Ordering::Relaxed);

    // 使用 Relaxed 顺序获取并增加计数器的值
    let old_value = counter.fetch_add(1, Ordering::Relaxed);
    println!("The old value of counter is: {}", old_value);

    // 使用 Relaxed 顺序获取当前计数器的值
    let current_value = counter.load(Ordering::Relaxed);
    println!("The current value of counter is: {}", current_value);
}

在这个例子中,我们创建了一个 AtomicI32 实例 counter 并初始化为 0。首先,我们使用 fetch_add 方法以 Relaxed 顺序增加计数器的值。然后,再次使用 fetch_add 方法获取并增加计数器的值,并打印旧值。最后,使用 load 方法获取当前计数器的值并打印。

原子指针操作

AtomicPtr 用于原子地操作指针。这在一些底层编程场景中非常有用,例如实现无锁数据结构。以下是一个简单的示例:

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

fn main() {
    let data = Box::new(42);
    let ptr = Box::into_raw(data);
    let atomic_ptr = AtomicPtr::new(ptr);

    // 使用 Relaxed 顺序加载指针
    let loaded_ptr = atomic_ptr.load(Ordering::Relaxed);

    // 使用 Relaxed 顺序存储指针
    let new_data = Box::new(100);
    let new_ptr = Box::into_raw(new_data);
    atomic_ptr.store(new_ptr, Ordering::Relaxed);

    // 记得释放指针指向的内存
    unsafe {
        drop(Box::from_raw(loaded_ptr));
        drop(Box::from_raw(new_ptr));
    }
}

在上述代码中,我们首先创建了一个堆上的整数 42,并将其指针转换为原始指针,然后将这个原始指针存储在 AtomicPtr 中。接着,我们使用 load 方法加载指针,再使用 store 方法存储一个新的指针。注意,在处理原始指针时,需要使用 unsafe 块来确保内存安全,这里我们使用 Box::from_raw 来正确释放内存。

原子操作在并发编程中的应用

实现简单的自旋锁

自旋锁是一种简单的锁机制,它通过让线程在等待锁时不断重试来避免线程上下文切换。在 Rust 中,可以使用原子操作来实现一个简单的自旋锁:

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

struct SpinLock {
    locked: AtomicBool,
}

impl SpinLock {
    fn new() -> Self {
        SpinLock {
            locked: AtomicBool::new(false),
        }
    }

    fn lock(&self) {
        while self.locked.swap(true, Ordering::Acquire) {
            // 自旋等待
            thread::yield_now();
        }
    }

    fn unlock(&self) {
        self.locked.store(false, Ordering::Release);
    }
}

fn main() {
    let spin_lock = SpinLock::new();

    let handle = thread::spawn(|| {
        spin_lock.lock();
        println!("Thread has acquired the spin lock");
        // 模拟一些工作
        thread::sleep(std::time::Duration::from_secs(2));
        spin_lock.unlock();
        println!("Thread has released the spin lock");
    });

    spin_lock.lock();
    println!("Main thread has acquired the spin lock");
    // 模拟一些工作
    thread::sleep(std::time::Duration::from_secs(1));
    spin_lock.unlock();
    println!("Main thread has released the spin lock");

    handle.join().unwrap();
}

在这个实现中,SpinLock 结构体包含一个 AtomicBool 来表示锁的状态。lock 方法使用 swap 方法尝试获取锁,如果锁已经被持有,则通过 thread::yield_now() 让出 CPU 时间片,继续自旋等待。unlock 方法则将锁的状态设置为 false。在 main 函数中,我们创建了一个 SpinLock 实例,并在主线程和一个新线程中尝试获取和释放锁。

无锁数据结构的实现

原子操作对于实现无锁数据结构至关重要。以无锁队列为例,我们可以使用 AtomicPtrAtomicUsize 来实现一个简单的单生产者 - 单消费者无锁队列:

use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering};
use std::mem;
use std::ptr;

struct Node<T> {
    data: T,
    next: *mut Node<T>,
}

struct LockFreeQueue<T> {
    head: AtomicPtr<Node<T>>,
    tail: AtomicPtr<Node<T>>,
    capacity: AtomicUsize,
    size: AtomicUsize,
}

impl<T> LockFreeQueue<T> {
    fn new(capacity: usize) -> Self {
        let head = Box::into_raw(Box::new(Node {
            data: unsafe { mem::uninitialized() },
            next: ptr::null_mut(),
        }));
        let tail = head;
        LockFreeQueue {
            head: AtomicPtr::new(head),
            tail: AtomicPtr::new(tail),
            capacity: AtomicUsize::new(capacity),
            size: AtomicUsize::new(0),
        }
    }

    fn enqueue(&self, data: T) -> bool {
        let new_node = Box::into_raw(Box::new(Node {
            data,
            next: ptr::null_mut(),
        }));

        loop {
            let tail = self.tail.load(Ordering::Acquire);
            let next = unsafe { (*tail).next };

            if next.is_null() {
                if self.tail.compare_exchange(
                    tail,
                    new_node,
                    Ordering::Release,
                    Ordering::Acquire,
                ).is_ok() {
                    unsafe { (*tail).next = new_node };
                    if self.size.fetch_add(1, Ordering::Relaxed) < self.capacity.load(Ordering::Relaxed) {
                        return true;
                    } else {
                        // 如果队列已满,需要回滚操作
                        self.size.fetch_sub(1, Ordering::Relaxed);
                        self.tail.compare_exchange(
                            new_node,
                            tail,
                            Ordering::Release,
                            Ordering::Acquire,
                        ).ok();
                        unsafe { (*tail).next = ptr::null_mut() };
                        return false;
                    }
                }
            } else {
                // 帮助移动 tail 指针
                self.tail.compare_exchange(
                    tail,
                    next,
                    Ordering::Release,
                    Ordering::Acquire,
                ).ok();
            }
        }
    }

    fn dequeue(&self) -> Option<T> {
        loop {
            let head = self.head.load(Ordering::Acquire);
            let tail = self.tail.load(Ordering::Acquire);
            let next = unsafe { (*head).next };

            if head == tail {
                if next.is_null() {
                    return None;
                }
                // 帮助移动 tail 指针
                self.tail.compare_exchange(
                    tail,
                    next,
                    Ordering::Release,
                    Ordering::Acquire,
                ).ok();
            } else {
                let data = unsafe { (*next).data.clone() };
                if self.head.compare_exchange(
                    head,
                    next,
                    Ordering::Release,
                    Ordering::Acquire,
                ).is_ok() {
                    self.size.fetch_sub(1, Ordering::Relaxed);
                    let _ = unsafe { Box::from_raw(head) };
                    return Some(data);
                }
            }
        }
    }
}

fn main() {
    let queue = LockFreeQueue::new(10);
    queue.enqueue(10);
    queue.enqueue(20);
    let result1 = queue.dequeue();
    let result2 = queue.dequeue();
    println!("Dequeued: {:?}, {:?}", result1, result2);
}

在这个无锁队列的实现中,LockFreeQueue 结构体包含 AtomicPtr 类型的 headtail 指针,以及 AtomicUsize 类型的 capacitysizeenqueue 方法通过循环尝试将新节点添加到队列尾部,使用 compare_exchange 方法确保操作的原子性。dequeue 方法同样通过循环尝试从队列头部取出节点,并处理可能的指针移动和队列空的情况。在 main 函数中,我们创建了一个容量为 10 的无锁队列,并进行入队和出队操作。

原子操作的性能考量

不同内存顺序的性能差异

不同的内存顺序对性能有显著影响。Relaxed 顺序由于提供最少的内存顺序保证,通常具有最好的性能。在一些对数据一致性要求不高,但对性能要求极高的场景,如统计计数器,可以使用 Relaxed 顺序。例如,在一个分布式系统中,各个节点可能会对某个事件进行计数,并且不需要立即同步到全局一致的状态,此时使用 Relaxed 顺序的原子操作来更新计数器可以获得更好的性能。

SeqCst 顺序提供了最强的一致性保证,但也带来了最高的性能开销。因为所有线程需要以相同的顺序观察 SeqCst 原子操作,这涉及到更多的内存屏障(memory barrier)操作,以确保内存操作的顺序。在需要严格一致性的场景,如实现分布式锁,可能需要使用 SeqCst 顺序。

ReleaseAcquire 顺序在性能和一致性之间提供了一个较好的平衡。在许多并发编程场景中,例如生产者 - 消费者模型,ReleaseAcquire 顺序可以满足数据同步的需求,同时保持相对较好的性能。生产者在向共享缓冲区写入数据后,可以使用 Release 顺序存储一个标志,消费者在读取数据前,使用 Acquire 顺序读取这个标志,这样可以确保消费者看到生产者写入的数据。

原子操作与锁的性能对比

在一些简单的并发场景中,原子操作可能比锁具有更好的性能。例如,在多线程同时更新一个计数器的场景下,如果使用锁来保护计数器的更新,每次更新都需要获取和释放锁,这涉及到线程上下文切换等开销。而使用原子操作,如 AtomicI32fetch_add 方法,可以在不使用锁的情况下实现计数器的安全更新,从而避免了锁带来的开销。

然而,在一些复杂的并发场景中,锁可能更加适用。例如,当需要保护一段复杂的临界区,其中涉及多个相互关联的操作时,使用锁可以更方便地保证数据的一致性。原子操作虽然可以保证单个操作的原子性,但在处理复杂逻辑时,可能需要更多的技巧和复杂的同步机制。

原子操作的错误处理和陷阱

内存顺序使用不当

使用不恰当的内存顺序是原子操作中常见的错误之一。例如,在需要数据同步的场景中使用 Relaxed 顺序,可能会导致数据不一致。假设有一个生产者 - 消费者模型,生产者使用 Relaxed 顺序存储数据到共享缓冲区,消费者也使用 Relaxed 顺序读取数据。由于 Relaxed 顺序不提供任何内存顺序保证,消费者可能读取到旧的数据,因为其他线程对共享缓冲区的修改可能还没有对消费者可见。

原子类型的未初始化使用

在使用原子类型时,确保正确初始化非常重要。例如,AtomicI32 如果未初始化就进行读取操作,会导致未定义行为。在创建原子类型实例时,应使用合适的构造函数进行初始化,如 AtomicI32::new(0)

原子操作与非原子操作的混合使用

在处理共享数据时,应避免原子操作与非原子操作混合使用。如果一部分代码使用原子操作来访问共享数据,而另一部分使用非原子操作,可能会导致数据竞争和未定义行为。例如,一个线程使用 AtomicI32fetch_add 方法更新共享计数器,而另一个线程直接通过普通的加法操作更新同一个计数器,这会破坏原子性,导致不可预测的结果。

通过深入理解 Rust 原子操作的实现原理、应用场景、性能考量以及注意事项,可以在并发编程中更有效地利用原子操作,实现高效且安全的多线程程序。无论是实现简单的同步机制,还是构建复杂的无锁数据结构,原子操作都是 Rust 并发编程的重要工具。