理解 Rust 中的內存排序

我正在閱讀 Mara Bos 的 《Rust 原子操作和鎖》[1] 。第一遍閱讀時, 我並沒有真正理解 內存排序 [2] (Mara 稱之爲 "本書最複雜的主題")。所以這裏是我嘗試通過解釋來理解。

爲什麼我們關心內存排序

當你的程序有多個線程處理相同的數據 (如共享計數器或上下文對象) 時, 原子操作和內存排序就會發揮作用。

在單線程代碼中, 你可以從上到下閱讀函數的語句, 並理解它將要做什麼。較早的語句會在較晚的語句之前發生。

在多線程代碼中, 情況並非如此簡單。另一個線程可能正在以可能破壞程序預期行爲的方式讀取或寫入你正在查看的函數中的相同變量。

多線程更難以推理, 因爲還有兩個額外的因素:

編譯器和處理器優化很好, 但它們沒有考慮多個線程。

內存排序是我們告訴編譯器和處理器哪些操作可以安全地重新排序, 哪些不能的方式。(內存排序也用於實現更高級的多線程構建塊, 如通道、MutexRwLock。)

內存排序的三個類別

雖然std::sync::atomic::Ordering枚舉有 5 個變體, 但實際上有 3 類內存排序:

  1. Relaxed
  2. Release / Acquire (Acquire, Release, AcqRel)
  3. 順序一致性 (SeqCst)

讓我們深入研究這些類別, 從 "最強" 的開始。

你可能不想要順序一致性

這一點表達得非常清楚:

雖然順序一致性內存排序似乎是最容易推理的, 但在實踐中幾乎從不需要...

誤解: 順序一致性內存排序是一個很好的默認選擇, 而且總是正確的。
撇開性能考慮不談, 順序一致性內存排序通常被視爲默認使用的完美內存排序類型, 因爲它提供了強有力的保證。確實, 如果任何其他內存排序是正確的, 那麼 SeqCst 也是正確的。這可能會讓人覺得 SeqCst 總是正確的。然而, 併發算法完全有可能本身就是錯誤的, 無論使用什麼內存排序。... 建議將 SeqCst 視爲一個警告信號。在實際中看到它通常意味着要麼正在發生一些複雜的事情, 要麼作者 simply 沒有花時間分析他們與內存排序相關的假設, 這兩種情況都需要額外的審查。

哎呀。我肯定犯過在過去寫代碼時使用SeqCst的錯誤, 只是因爲它看起來像是最安全的默認選擇。

那麼, 我們還有什麼其他選擇呢?

放鬆一點

當你想對單個變量執行原子操作, 但不關心跨線程同步不同操作時,Relaxed內存排序很有用。例如, 如果你正在遞增單個計數器, 你可能不關心遞增操作發生的順序, 只要每個操作都是原子的即可。

Mara 給出了這段代碼的例子, 假設函數ab在不同的線程上運行:

static X: AtomicI32 = AtomicI32::new(0);

fn a() {
    X.fetch_add(5, Relaxed);
    X.fetch_add(10, Relaxed);
}

fn b() {
    let a = X.load(Relaxed);
    let b = X.load(Relaxed);
    let c = X.load(Relaxed);
    let d = X.load(Relaxed);
    println!("{a} {b} {c} {d}");
}

在這個程序中:

這裏是操作可能排序的 4 種不同方式的圖示:

再次強調,Relaxed在以下情況下很有用:

深入研究:ReleaseAcquire排序

現在我們已經看到我們可能不想要SeqCst, 而Relaxed是用於不需要同步的原子操作, 讓我們把注意力轉向ReleaseAcquire

首先要澄清一點: 你不應該將ReleaseAcquire視爲兩種不同的 "模式"。Release只與寫入 / 存儲操作相關。Acquire只與讀取 / 加載操作相關。

Release標記程序整體時間線上的一個時刻, 聲明_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire之後都將可見_。

用更簡單的話說, 假設你有兩個線程在做一些工作並讀寫數據:

eTP3yH

ReleaseAcquire同步線程, 使得_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire之後都將可見_。

這種排序的用例包括:

信號示例

讓我們看看 Mara 使用AtomicBool來表示數據何時準備好的例子:

use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::atomic::Ordering::{Acquire, Relaxed, Release};

static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    std::thread::spawn(|| {
        DATA.store(123, Relaxed);
        READY.store(true, Release); // 在這個存儲之前的所有內容 ..
    });
    while !READY.load(Acquire) { // .. 在這裏加載爲`true`之後可見。
        std::thread::sleep(std::time::Duration::from_millis(100));
        println!("waiting...");
    }
    println!("{}", DATA.load(Relaxed));
}

主線程正在等待DATA準備就緒, 每次檢查之間睡眠 100 毫秒。

關鍵是,READY.store(true, Release);這一行確保了_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire之後都將可見_。注意DATA是在Release時刻_之前_寫入的, 並且只使用了Relaxed排序。

當主線程最終觀察到READY.load(Acquire)true時, 我們跳出while循環並最終讀取值。即使DATA.load(Relaxed)使用Relaxed, 它也保證能看到該值。寫入發生在_READY變量的Release時刻之前_, 而這個load發生在READY對應的Acquire之後。

這是此代碼唯一可能的序列:

鎖定示例

Mara 關於Release / Acquire排序的下一個例子是一個非常基本的鎖, 它使用AtomicBool來保護對String的訪問。這裏是該示例的一個稍微修改的版本:

use std::sync::atomic::Ordering::{Acquire, Relaxed, Release};
use std::sync::atomic::AtomicBool;

static mut DATA: String = String::new();
static LOCKED: AtomicBool = AtomicBool::new(false);

fn f() {
    if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
        // 安全性:我們持有獨佔鎖,所以沒有其他東西在訪問DATA。
        unsafe { DATA.push('!') };
        LOCKED.store(false, Release);
    }
}

fn main() {
    std::thread::scope(|s| {
        for _ in 0..100 {
            s.spawn(f);
        }
    });
    println!("{}", unsafe { &DATA });
}

兩個關鍵行是:

  1. if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
  2. LOCKED.store(false, Release);

第一行使用Acquire排序原子地讀取LOCKED, 如果值爲false, 則使用Relaxed排序將值設置爲true

第二行使用ReleaseLOCKED設置回false。正如我們之前看到的,Release意味着_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire之後都將可見_。

結合這兩行保證了對DATAunsafe寫入和對READYRelease寫入都將在另一個線程使用Acquire排序觀察到LOCKEDfalse之前完成。

DIY 同步原語

Mara 的書中接下來的幾章很好地介紹瞭如何使用我們在這裏討論的內存排序原則構建 自旋鎖 [3] 、 通道 [4] 和 原子引用計數指針 ([5] 。我不會在這裏詳細介紹, 但我建議你自己閱讀這些內容。

如果你想進一步深入研究, 你還可以閱讀關於 原子柵欄 [6] 的內容。這些實際上將我們一直在討論的存儲 /Release和加載 /Acquire操作分解爲單獨的柵欄和存儲或加載操作, 這對於一次將柵欄應用於多個變量很有用。

結論

內存排序可能很難理解, 但這裏有幾個要記住的要點:

感謝 Mara Bos 撰寫了清晰易懂的 《Rust 原子操作和鎖》[0] 解釋, 以及 Simon Rassmussen 對本文草稿的反饋。

在 r/rust[7] 、 Lobsters[8] 或 Hacker News[9] 上討論。

#rust[10]

參考鏈接

  1. 《Rust 原子操作和鎖》: https://marabos.nl/atomics/
  2. 內存排序: https://marabos.nl/atomics/memory-ordering.html
  3. 自旋鎖: https://marabos.nl/atomics/building-spinlock.html
  4. 通道: https://marabos.nl/atomics/building-channels.html
  5. 原子引用計數指針 (: https://marabos.nl/atomics/building-arc.html
  6. 原子柵欄: https://marabos.nl/atomics/memory-ordering.html#fences
  7. r/rust: https://www.reddit.com/r/rust/comments/1fj9eog/understanding_memory_ordering_in_rust/
  8. Lobsters: https://lobste.rs/s/zofsan/understanding_memory_ordering_rust
  9. Hacker News: https://news.ycombinator.com/item?id=41572070
  10. #rust: https://emschwartz.me/blog/?q=rust
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nIYpgJ6fkpbJffXCXVP3mQ