Arc<Mutex>、Arc<RwLock>
與訊息傳遞在前一天,我們理解了 Send
與 Sync
如何在編譯期劃定併發安全的契約邊界。
然而,光有契約是不夠的,我們還需要具體的執行機制來處理跨執行緒共享且可變的狀態。Arc<Mutex<T>>
正是這個機制的標準答案,但它同時也是一把雙面刃。
編譯器能攔截記憶體層面的錯誤,卻攔不住人的糟糕設計。
尤其在併發程式設計中,Arc<Mutex<T>>
這種工具,可以給了足夠的繩子把自己吊死。
多個執行緒同時讀寫同一塊記憶體,是一切混亂與複雜性的根源。
Rust 提供了標準的組合拳來應對這個挑戰:
Arc<T>
:讓我們能安全地「共享」所有權。Mutex<T>
:讓我們能「互斥」地存取資料,保證任一時刻只有一個執行緒可以修改。Arc<Mutex<T>>
組合起來,就是解決「共享且可變」問題的教科書級工具。
但我們不能濫用它,到處使用 Arc<Mutex<T>>
。
拿著錘子,看什麼都像釘子。
好的併發設計,會從根本上消滅或隔離共享狀態,而不只是用鎖把它包起來。
如果經過深思熟慮,確認共享狀態是當前設計無法避免的,那麼就要把握好這原則:
絕對不要把鎖的實作細節暴露給外部!
API 使用者不應該、也不需要關心你內部用了 Mutex
、RwLock
還是別的同步原語。必須建立一個乾淨的邊界,將鎖的複雜性完全封裝在內。
先用 Mutex
實現一個差強人意的版本,是一個計數器範例:
use std::sync::{Arc, Mutex};
// 使用者無需知道內部是 Arc,提供 Clone 即可
#[derive(Clone)]
pub struct Counter {
value: Arc<Mutex<i32>>,
}
impl Counter {
pub fn new() -> Self {
Counter {
value: Arc::new(Mutex::new(0)),
}
}
// 將鎖的範圍限制在此方法內
// 不要再用 .unwrap(),用 expect 提供清晰的錯誤訊息
// 在此,我們選擇讓程式在鎖被污染 (poisoned) 時直接崩潰,
// 這是一種比隱藏錯誤更負責任的策略
pub fn increment(&self) {
let mut num = self.value.lock().expect("Mutex was poisoned. This is a critical error.");
*num += 1;
}
pub fn get(&self) -> i32 {
*self.value.lock().expect("Mutex was poisoned.")
}
}
這個版本至少做對了兩件事:
Mutex
的存在被隱藏在 Counter
結構體內部。API 的使用者只看到 increment()
和 get()
,看不到 lock()
。expect
取代 unwrap
,至少讓程式在發生嚴重錯誤(鎖中毒)時,能留下明確的遺言。但這個設計結構依然不好。
為了一個小小的整數就動用作業系統級別的互斥鎖,就像用大砲打蚊子,其效能開銷是不可忽視的。
對於計數、狀態切換等簡單場景,答案幾乎是「不」。
還有個效能高出數個量級的工具:原子操作 (Atomics)。
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
// 資料結構從根本上改變,不再有 Mutex
#[derive(Clone)]
pub struct AtomicCounter {
value: Arc<AtomicI32>,
}
impl AtomicCounter {
pub fn new() -> Self {
AtomicCounter {
value: Arc::new(AtomicI32::new(0)),
}
}
// 沒有 .lock(),沒有 expect(),沒有阻塞,沒有中毒風險
// 直接使用 CPU 指令進行原子遞增,高效且絕對安全
pub fn increment(&self) {
self.value.fetch_add(1, Ordering::SeqCst);
}
pub fn get(&self) -> i32 {
self.value.load(Ordering::SeqCst)
}
}
透過選擇正確的、開銷更小的資料結構 (AtomicI32
),直接消滅了整個問題類別。不再有鎖、不再有執行緒阻塞、不再有潛在的死鎖、不再需要處理鎖污染。
不是把問題包起來,而是讓問題從未發生。
下次遇到併發問題時,請按這個順序思考,而不是直接跳到 Arc<Mutex<T>>
:
這是最乾淨、耦合度最低的模式。讓一個執行緒擁有資料的唯一所有權,其他執行緒透過 Channel 向它發送訊息(或命令)來操作資料。這符合 Actor 模型思想:「Do not communicate by sharing memory; instead, share memory by communicating.」
對於 i32、bool、usize 等基礎型別,原子操作永遠是首選。它無鎖、高效,且不會阻塞。
當你保護的是複雜資料結構(如 HashMap 或 Vec),且讀取操作遠多於寫入操作時,Arc<RwLock<T>>
是個好選擇。它允許多個執行緒同時讀取。
只有在讀寫頻繁、RwLock 可能導致寫入飢餓、或邏輯過於複雜時,才使用 Arc<Mutex<T>>
。並且,務必將其徹底封裝。
如果你在程式碼裡看到以下模式,它很可能就是一顆定時炸彈:
MutexGuard
。 這等於把管理鎖生命週期的責任丟給了呼叫者,極易造成死鎖。.await
。這是造成系統效能雪崩和死鎖的頭號元兇。Rust 的編譯器透過 Send
和 Sync
賜予了你記憶體安全,也賦予你善用它的責任。
編譯器能將你從資料競爭(Data Race)中拯救出來,但只有深思熟慮的設計,才能將你從死鎖(Deadlock)的泥潭中解救出來。