iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 21

(Day21) Rust `Arc<Mutex>、Arc<RwLock> `與訊息傳遞

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師 Arc<Mutex>、Arc<RwLock> 與訊息傳遞

在前一天,我們理解了 SendSync 如何在編譯期劃定併發安全的契約邊界
然而,光有契約是不夠的,我們還需要具體的執行機制來處理跨執行緒共享且可變的狀態。Arc<Mutex<T>> 正是這個機制的標準答案,但它同時也是一把雙面刃。

編譯器能攔截記憶體層面的錯誤,卻攔不住人的糟糕設計。
尤其在併發程式設計中,Arc<Mutex<T>> 這種工具,可以給了足夠的繩子把自己吊死。

「共享可變狀態」是萬惡之源

多個執行緒同時讀寫同一塊記憶體,是一切混亂與複雜性的根源。
Rust 提供了標準的組合拳來應對這個挑戰:

  • Arc<T>:讓我們能安全地「共享」所有權。
  • Mutex<T>:讓我們能「互斥」地存取資料,保證任一時刻只有一個執行緒可以修改。

Arc<Mutex<T>> 組合起來,就是解決「共享且可變」問題的教科書級工具。
但我們不能濫用它,到處使用 Arc<Mutex<T>>
拿著錘子,看什麼都像釘子。

好的併發設計,會從根本上消滅或隔離共享狀態,而不只是用鎖把它包起來。

鎖是最後手段,清晰的 API 邊界才是答案

如果經過深思熟慮,確認共享狀態是當前設計無法避免的,那麼就要把握好這原則:

絕對不要把鎖的實作細節暴露給外部!

API 使用者不應該、也不需要關心你內部用了 MutexRwLock 還是別的同步原語。必須建立一個乾淨的邊界,將鎖的複雜性完全封裝在內。

先用 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.")
    }
}

這個版本至少做對了兩件事:

  1. 徹底封裝Mutex 的存在被隱藏在 Counter 結構體內部。API 的使用者只看到 increment()get(),看不到 lock()
  2. 明確的錯誤處理:用 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>>

1. 不要共享,用訊息傳遞 (Message Passing)

這是最乾淨、耦合度最低的模式。讓一個執行緒擁有資料的唯一所有權,其他執行緒透過 Channel 向它發送訊息(或命令)來操作資料。這符合 Actor 模型思想:「Do not communicate by sharing memory; instead, share memory by communicating.」

2. 如果必須共享,優先考慮原子操作 (Atomics)

對於 i32、bool、usize 等基礎型別,原子操作永遠是首選。它無鎖、高效,且不會阻塞。

3. 如果原子操作不夠,才考慮讀寫鎖 RwLock

當你保護的是複雜資料結構(如 HashMap 或 Vec),且讀取操作遠多於寫入操作時,Arc<RwLock<T>> 是個好選擇。它允許多個執行緒同時讀取。

4.Mutex 是你的最後防線

只有在讀寫頻繁、RwLock 可能導致寫入飢餓、或邏輯過於複雜時,才使用 Arc<Mutex<T>>。並且,務必將其徹底封裝。

https://ithelp.ithome.com.tw/upload/images/20251005/20124462hNXCszL8Ci.png

不好的做法

如果你在程式碼裡看到以下模式,它很可能就是一顆定時炸彈:

  • 在公開 API 中回傳 MutexGuard 這等於把管理鎖生命週期的責任丟給了呼叫者,極易造成死鎖。
  • 在鎖定的區域內執行耗時或可能阻塞的操作。 這包括檔案 I/O、網路請求、等待另一個鎖、甚至 .await。這是造成系統效能雪崩和死鎖的頭號元兇。
  • 複雜的鎖鏈。 需要先鎖 A,再鎖 B,然後鎖 C。只要順序稍有差錯,死鎖就在不遠處等你。如果你的設計需要這麼做,說明設計本身已經爛掉了,請回到第一步重新思考。

結論

Rust 的編譯器透過 SendSync 賜予了你記憶體安全,也賦予你善用它的責任。

編譯器能將你從資料競爭(Data Race)中拯救出來,但只有深思熟慮的設計,才能將你從死鎖(Deadlock)的泥潭中解救出來。


上一篇
(Day20) Rust 併發安全的邊界:Send、Sync 與型別承諾
下一篇
(Day22) Rust 零成本抽象與效能剖析:先對,再快
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言