理解 Rust 中的內存排序
我正在閱讀 Mara Bos 的 《Rust 原子操作和鎖》[1] 。第一遍閱讀時, 我並沒有真正理解 內存排序 [2] (Mara 稱之爲 "本書最複雜的主題")。所以這裏是我嘗試通過解釋來理解。
爲什麼我們關心內存排序
當你的程序有多個線程處理相同的數據 (如共享計數器或上下文對象) 時, 原子操作和內存排序就會發揮作用。
在單線程代碼中, 你可以從上到下閱讀函數的語句, 並理解它將要做什麼。較早的語句會在較晚的語句之前發生。
在多線程代碼中, 情況並非如此簡單。另一個線程可能正在以可能破壞程序預期行爲的方式讀取或寫入你正在查看的函數中的相同變量。
多線程更難以推理, 因爲還有兩個額外的因素:
- 編譯器優化 - 在編譯時, 編譯器可以重新排序和組合指令以優化你的代碼。我們通常很欣賞這些性能改進, 但如果我們不小心, 重新排序某些指令可能會破壞我們的多線程代碼。
- 處理器優化 - 在運行時, 處理器也可能重新排序指令。例如, 因爲後面指令中的變量已經在處理器緩存中可用, 而另一個變量需要從內存加載。與編譯器優化類似, 這些重新排序對單線程代碼來說沒問題, 但可能會破壞多線程代碼。
編譯器和處理器優化很好, 但它們沒有考慮多個線程。
內存排序是我們告訴編譯器和處理器哪些操作可以安全地重新排序, 哪些不能的方式。(內存排序也用於實現更高級的多線程構建塊, 如通道、Mutex
和RwLock
。)
內存排序的三個類別
雖然std::sync::atomic::Ordering
枚舉有 5 個變體, 但實際上有 3 類內存排序:
Relaxed
- Release / Acquire (
Acquire
,Release
,AcqRel
) - 順序一致性 (
SeqCst
)
讓我們深入研究這些類別, 從 "最強" 的開始。
你可能不想要順序一致性
這一點表達得非常清楚:
雖然順序一致性內存排序似乎是最容易推理的, 但在實踐中幾乎從不需要...
誤解: 順序一致性內存排序是一個很好的默認選擇, 而且總是正確的。
撇開性能考慮不談, 順序一致性內存排序通常被視爲默認使用的完美內存排序類型, 因爲它提供了強有力的保證。確實, 如果任何其他內存排序是正確的, 那麼 SeqCst 也是正確的。這可能會讓人覺得 SeqCst 總是正確的。然而, 併發算法完全有可能本身就是錯誤的, 無論使用什麼內存排序。... 建議將 SeqCst 視爲一個警告信號。在實際中看到它通常意味着要麼正在發生一些複雜的事情, 要麼作者 simply 沒有花時間分析他們與內存排序相關的假設, 這兩種情況都需要額外的審查。
哎呀。我肯定犯過在過去寫代碼時使用SeqCst
的錯誤, 只是因爲它看起來像是最安全的默認選擇。
那麼, 我們還有什麼其他選擇呢?
放鬆一點
當你想對單個變量執行原子操作, 但不關心跨線程同步不同操作時,Relaxed
內存排序很有用。例如, 如果你正在遞增單個計數器, 你可能不關心遞增操作發生的順序, 只要每個操作都是原子的即可。
Mara 給出了這段代碼的例子, 假設函數a
和b
在不同的線程上運行:
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}");
}
在這個程序中:
- 我們知道
5
會在10
之前被加到X
上。 - 我們_不知道_
b
中不同的load
操作會加載什麼值。 - 我們_確實知道_加載操作會按正確的順序觀察到加法操作 (0 → 5 → 15)。
- 同一程序的不同運行可能會以不同的方式交錯
fetch_add
和load
操作。
這裏是操作可能排序的 4 種不同方式的圖示:
再次強調,Relaxed
在以下情況下很有用:
- 你有一個想要原子更新的單個變量。
- 你不關心多個線程的更新發生的順序。
- 其他線程不立即觀察到更新的效果是可以的。
- 你有多個變量, 但不關心它們之間更新的同步。
深入研究:Release
和Acquire
排序
現在我們已經看到我們可能不想要SeqCst
, 而Relaxed
是用於不需要同步的原子操作, 讓我們把注意力轉向Release
和Acquire
。
首先要澄清一點: 你不應該將Release
和Acquire
視爲兩種不同的 "模式"。Release
只與寫入 / 存儲操作相關。Acquire
只與讀取 / 加載操作相關。
Release
標記程序整體時間線上的一個時刻, 聲明_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire
之後都將可見_。
用更簡單的話說, 假設你有兩個線程在做一些工作並讀寫數據:
Release
和Acquire
同步線程, 使得_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire
之後都將可見_。
這種排序的用例包括:
- 實現鎖, 如
Mutex
和RwLock
- 實現生產者 / 消費者模式, 如通道
- 數據的延遲初始化
- 在線程之間發信號通知任務完成
信號示例
讓我們看看 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 });
}
兩個關鍵行是:
if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
LOCKED.store(false, Release);
第一行使用Acquire
排序原子地讀取LOCKED
, 如果值爲false
, 則使用Relaxed
排序將值設置爲true
。
第二行使用Release
將LOCKED
設置回false
。正如我們之前看到的,Release
意味着_在這個時刻或之前發生的所有寫入操作在對同一變量進行Acquire
之後都將可見_。
結合這兩行保證了對DATA
的unsafe
寫入和對READY
的Release
寫入都將在另一個線程使用Acquire
排序觀察到LOCKED
爲false
之前完成。
DIY 同步原語
Mara 的書中接下來的幾章很好地介紹瞭如何使用我們在這裏討論的內存排序原則構建 自旋鎖 [3] 、 通道 [4] 和 原子引用計數指針 ([5] 。我不會在這裏詳細介紹, 但我建議你自己閱讀這些內容。
如果你想進一步深入研究, 你還可以閱讀關於 原子柵欄 [6] 的內容。這些實際上將我們一直在討論的存儲 /Release
和加載 /Acquire
操作分解爲單獨的柵欄和存儲或加載操作, 這對於一次將柵欄應用於多個變量很有用。
結論
內存排序可能很難理解, 但這裏有幾個要記住的要點:
SeqCst
並不像你想象的那樣是一個好的默認選擇, 在大多數情況下應該避免使用。- 當你想對單個變量應用原子操作, 但不關心同步多個變量的讀寫時,
Relaxed
很好用。例如, 遞增簡單計數器或收集統計信息。 Release
/Acquire
用於當你想在多個線程之間同步多個變量的讀寫時。使用Release
將值存儲到給定變量保證_在這個時刻或之前發生的所有寫入操作在使用Acquire
加載同一變量後都將可見_。Release
/Acquire
對於構建各種同步原語很有用, 包括鎖、通道和信號。可以將Release
視爲向其他線程釋放一些更改 (或鎖), 而Acquire
獲取這些更改 (或鎖)。
感謝 Mara Bos 撰寫了清晰易懂的 《Rust 原子操作和鎖》[0] 解釋, 以及 Simon Rassmussen 對本文草稿的反饋。
在 r/rust[7] 、 Lobsters[8] 或 Hacker News[9] 上討論。
#rust[10]
參考鏈接
- 《Rust 原子操作和鎖》: https://marabos.nl/atomics/
- 內存排序: https://marabos.nl/atomics/memory-ordering.html
- 自旋鎖: https://marabos.nl/atomics/building-spinlock.html
- 通道: https://marabos.nl/atomics/building-channels.html
- 原子引用計數指針 (: https://marabos.nl/atomics/building-arc.html
- 原子柵欄: https://marabos.nl/atomics/memory-ordering.html#fences
- r/rust: https://www.reddit.com/r/rust/comments/1fj9eog/understanding_memory_ordering_in_rust/
- Lobsters: https://lobste.rs/s/zofsan/understanding_memory_ordering_rust
- Hacker News: https://news.ycombinator.com/item?id=41572070
- #rust: https://emschwartz.me/blog/?q=rust
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/nIYpgJ6fkpbJffXCXVP3mQ