在多執行緒程式設計中,為了避免資料競爭和確保程式正確性,我們需要使用原子操作。Rust 提供了豐富的原子操作型別和方法,允許開發者在不使用鎖的情況下安全地操作分享資料。理解記憶體排序對於正確使用原子操作至關重要,它決定了不同操作之間的相對順序保證。本文將介紹 Rust 中常用的原子操作,例如載入、儲存和 Fetch-and-Modify 操作,並以實際程式碼範例展示如何在多執行緒環境下安全地更新計數器、分享狀態等。同時,我們也將簡要介紹執行緒暫停機制如何提升同步效率,並討論原子操作的注意事項,例如溢位處理和記憶體順序的選擇。

原子操作與記憶體排序

在探討不同的原子操作之前,我們需要先了解一個稱為記憶體排序(Memory Ordering)的概念。每個原子操作都需要一個 std::sync::atomic::Ordering 型別的引數,這個引數決定了我們對於不同操作之間的相對順序所能得到的保證。最簡單的變體是 Relaxed,它提供的保證最少。Relaxed 保證了單一原子變數的一致性,但對於不同變數之間的操作順序則不作任何承諾。

這意味著兩個執行緒可能會看到對不同變數的操作以不同的順序發生。例如,如果一個執行緒先寫入一個變數,然後很快地寫入另一個變數,另一個執行緒可能會看到這兩個操作以相反的順序發生。在本章中,我們只會檢視一些使用案例,在這些案例中,這不是一個問題,並且簡單地在所有地方使用 Relaxed,而不探討細節。我們將在第三章中討論記憶體排序的所有細節以及其他可用的記憶體排序。

原子載入與儲存操作

我們要檢視的前兩個原子操作是最基本的:載入(load)和儲存(store)。以 AtomicI32 為例,它們的函式簽名如下:

impl AtomicI32 {
    pub fn load(&self, ordering: Ordering) -> i32;
    pub fn store(&self, value: i32, ordering: Ordering);
}

load 方法原子地載入原子變數中儲存的值,而 store 方法原子地在其中儲存一個新值。注意,即使 store 方法修改了值,它仍然接受一個分享參考(&T),而不是獨佔參考(&mut T)。

讓我們來看看這兩個方法的一些實際使用案例。

示例:停止旗標

第一個例子使用了一個 AtomicBool 作為停止旗標。這種旗標用於通知其他執行緒停止執行。

use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::Relaxed;

fn main() {
    static STOP: AtomicBool = AtomicBool::new(false);

    // 生成一個執行緒來執行工作。
    let background_thread = thread::spawn(|| {
        while !STOP.load(Relaxed) {
            some_work();
        }
    });

    // 使用主執行緒來監聽使用者輸入。
    for line in std::io::stdin().lines() {
        match line.unwrap().as_str() {
            "help" => println!("commands: help, stop"),
            "stop" => break,
            cmd => println!("unknown command: {cmd:?}"),
        }
    }

    // 通知後台執行緒它需要停止。
    STOP.store(true, Relaxed);

    // 等待後台執行緒完成。
    background_thread.join().unwrap();
}

#### 內容解密:

這個例子中,主執行緒和後台執行緒透過 STOP 這個原子布林變數進行通訊。當使用者輸入 stop 命令時,主執行緒將 STOP 設為 true,後台執行緒在每次迭代開始時檢查 STOP 的值,如果為 true 就停止執行。loadstore 操作使用 Relaxed 排序,因為在這個例子中,不需要保證操作的順序。

示例:進度報告

在下一個例子中,我們在後台執行緒上一一處理 100 個專案,同時主執行緒定期向使用者更新進度:

use std::sync::atomic::AtomicUsize;

fn main() {
    let num_done = AtomicUsize::new(0);
    thread::scope(|s| {
        // 一個後台執行緒來處理所有 100 個專案。
        s.spawn(|| {
            for i in 0..100 {
                process_item(i); // 假設這需要一些時間。
                num_done.store(i + 1, Relaxed);
            }
        });

        // 主執行緒每秒顯示狀態更新。
        loop {
            let n = num_done.load(Relaxed);
            if n == 100 { break; }
            println!("Working.. {n}/100 done");
            thread::sleep(Duration::from_secs(1));
        }
    });
    println!("Done!");
}

#### 內容解密:

這個例子使用了一個 AtomicUsize 來報告進度。後台執行緒在處理每個專案後更新 num_done,主執行緒則定期載入 num_done 的值並顯示進度。這個例子展示瞭如何線上程之間分享進度資訊。

同步

一旦最後一個專案被處理完畢,主執行緒可能需要長達一秒的時間才能知道,這在結束時引入了不必要的延遲。為瞭解決這個問題,我們可以使用執行緒暫停(thread parking)來在有新資訊時喚醒主執行緒。

fn main() {
    let num_done = AtomicUsize::new(0);
    let main_thread = thread::current();
    thread::scope(|s| {
        // 一個後台執行緒來處理所有 100 個專案。
        s.spawn(|| {
            for i in 0..100 {
                process_item(i); // 假設這需要一些時間。
                num_done.store(i + 1, Relaxed);
                main_thread.unpark(); // 喚醒主執行緒。
            }
        });

        // 主執行緒顯示狀態更新。
        loop {
            let n = num_done.load(Relaxed);
            if n == 100 { break; }
            println!("Working.. {n}/100 done");
            thread::park_timeout(Duration::from_secs(1));
        }
    });
    println!("Done!");
}

#### 內容解密:

這個改進的例子中使用了 thread::park_timeout 而不是 thread::sleep,這樣後台執行緒可以在更新 num_done 後透過 unpark 喚醒主執行緒,從而減少了延遲。這個例子展示瞭如何使用執行緒暫停來提高同步的效率。

Fetch-and-Modify 操作在 Rust 中的應用

Fetch-and-Modify 操作是 Rust 中用於原子變數的重要功能,能夠在單一原子操作中修改原子變數並載入(提取)原始值。本章節將探討 fetch-and-modify 操作的基本用法及其在實際場景中的應用。

Fetch-and-Modify 操作概述

Rust 的標準函式庫提供了多種 fetch-and-modify 操作,包括 fetch_addfetch_subfetch_orfetch_andfetch_nandfetch_xorfetch_maxfetch_min。這些操作能夠對原子變數進行修改,並傳回原始值。

主要操作及其函式簽名

以下是使用 AtomicI32 作為範例的 fetch-and-modify 操作函式簽名:

impl AtomicI32 {
    pub fn fetch_add(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_sub(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_or(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_and(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_nand(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_xor(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_max(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_min(&self, v: i32, ordering: Ordering) -> i32;
    pub fn swap(&self, v: i32, ordering: Ordering) -> i32; // "fetch_store"
}

Fetch-and-Modify 操作範例

以下範例展示了 fetch_add 操作的工作原理:

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

fn main() {
    let a = AtomicI32::new(100);
    let b = a.fetch_add(23, Ordering::Relaxed);
    let c = a.load(Ordering::Relaxed);
    assert_eq!(b, 100);
    assert_eq!(c, 123);
}

內容解密:

在上述範例中,我們建立了一個 AtomicI32 例項並初始化為 100。接著,我們使用 fetch_add 操作將 23 加到 a 上。fetch_add 傳回原始值 100,而 a 的新值變為 123。最後,我們使用 load 操作驗證 a 的最新值確實是 123。

Fetch-and-Modify 操作的實際應用

Fetch-and-Modify 操作在多執行緒程式設計中有廣泛的應用,例如實作計數器、統計資料或進行平行計算。

範例:使用 Fetch-and-Modify 操作實作執行緒安全計數器

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

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

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

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

    println!("Final counter value: {}", counter.load(Ordering::Relaxed));
}

內容解密:

在上述範例中,我們建立了一個 AtomicUsize 計數器,並初始化為 0。接著,我們建立了 10 個執行緒,每個執行緒對計數器進行 1000 次 fetch_add 操作。最後,我們列印出最終的計數器值。

Fetch-and-Modify 操作的注意事項

  1. 溢位處理fetch_addfetch_sub 操作在溢位時會進行包裝處理,這與普通整數運算不同。
  2. 傳回值:fetch-and-modify 操作傳回原始值,如果不需要原始值,可以忽略傳回值。
  3. 記憶體順序:fetch-and-modify 操作需要指定記憶體順序(Ordering),這會影響操作的原子性和可見性。

圖表說明

  graph LR
    A[開始] --> B[初始化 AtomicI32]
    B --> C[執行 fetch_add 操作]
    C --> D[傳回原始值]
    D --> E[驗證最新值]

圖表翻譯:
此圖表展示了使用 fetch_add 操作的流程。首先,我們初始化一個 AtomicI32 變數。接著,執行 fetch_add 操作將指定值加到原子變數上,並傳回原始值。最後,我們驗證原子變數的最新值。

隨著平行計算需求的增加,fetch-and-modify 操作將在多執行緒程式設計中扮演越來越重要的角色。未來的發展可能會集中在最佳化這些操作的效能和擴充套件其應用場景。

參考資料

延伸閱讀