在 Rust 中讓型別能夠跨執行緒的方法

Wu Yu Wei published on
8 min, 1441 words

最近我需要用到 Cache 的功能,但在 crates.io 找了一遍後並沒有滿意的 crates,大部分都包含了 unsafe 與裸指標的引用。我自己並不排斥 unsafe 的使用,只要有詳細的註解能讓我理解使用的必要原因就好。不過這些都沒有跑過 miri,而與其要解決出錯的未定義行為,我覺得不如自己寫一個最適用於自己使用情境的一個還比較實際。所以寫了一個之後,我在 benchmark 的同時剛好需要讓該型別能跨執行緒使用,加了一些簡單的修改後我發現這個或許能幫助到剛好正在學習怎麼寫 concurrency 的人,所以我把修改時的思路與過程記錄下來。

當然在現在的程式設計中也有許多其他技巧可以使用,像是 channel、atomics 或者 lock-free 等等。但在這邊只是要用最簡單的方式而已:開多條 thread 並使用互斥鎖。這樣的方式雖然簡單,但沒有處理好的話卻也常常造成許多錯誤。不過在安全的 Rust 中就提供了許多保障,讓你在使用它們的同時就免除了許多不必要的危害。

雖然 Rust 能幫你預防 data race 甚至所有記憶體不安全的問題,但 race condition 仍然是會發生的,因為整個系統環境本來就是在互相競爭的,使用者還是有可能寫出死鎖。

首先 Rust 大部分的基礎型別都有 SyncSend traits,以這些定義的型別也會擁有這些 traits,這用來表明該型別是可以安全在執行緒間同步與傳遞的。不過因為所有權的關係一個數值就算有這些 traits 也只能被一個 thread 所擁有,要讓多條 thread 可以擁有同個實例的話,通常我們就會使用 Arc 型別,此型別在 clone 時不會複製數值,而是仍引用原本的實例然後增加原子引用計數。Arc 型別擁有 Deref trait 讓使用者可以拿到原本的引用。不過此引用是不可變的引用,想要拿到可變得用的話,我們就會再繼續加上 Mutex 來繼續取得可變引用。所以通常要讓一個型別可以跨執行緒並可變的話,我們會很直覺地使用 Arc<Mutex<T>> 而且這些型別的 API 仍然遵守著 Rust 的所有權與生命週期規則,這直接讓 Data Race 的產生成為不可能。

不過我所實作的 Cache 內部還分成了數個 shard,所以與其將整個型別用 Arc<Mutex<T>> 包起來,我採用了別種選擇:讓 Mutex 鎖的是各個 shard。這樣當某個 shard 被鎖起來時,其他 shard 在別條 thread 仍能使用。而在此 commit 短短幾行修改中,產生了一些有趣的變化,在之後的 benchmark 時我發現變的更好使用了!

首先,我對每個 thread 加上了 Mutex,而要取得每個 shard 的話就是呼叫 lock().unwrap()。取鎖得到的回傳值是一種 Result 型別的原因是因為要是該鎖在其他 thread panic 的話就會被污染。被污染的鎖就會回傳 Err。不過要是確定 thread 不會 panic 的話,直接 unwrap 是不會有問題的,以我自己的經驗要是真的在除錯時也比較容易。這邊最有趣的地方在於原本需要 &mut self 的方法可以改成 &self 就好了,因為 Mutex 提供的是內部可變性,lock() 的需求也只要 &self

這邊沒有加上 Arc 的原因是因為是使用者才要決定要不要傳給其他 thread,所以使用者不需要的話他們本來就能直接使用,而需要跨執行緒的話,他們就會本能地使用 Arc 包裝起來,而且因為所有方法都只需要不可變引用,所以他們不必再定義 Mutex(要真的定義也不是不行,只是最後會發現完全不需要鎖)。這樣一來就完成能夠跨平台的型別,確保外部不變的同時,提供內部可變性。

以上是快樂的部分,而在剛剛的 commit 中有再定義另一個型別,這則是比較惱人的部分。或者說「遇過太多次這樣的錯誤,所以下意識就會寫的部分」,在剛剛的 commit 中有再定義另一個型別:

pub struct LookupRef<'a, T>(MutexGuard<'a, LRUCache<T>>, usize);

impl<'a, T> LookupRef<'a, T> {
    /// Get the value of the lookup reference and actually update the entry to the head of its
    /// cache shard.
    pub fn value(&mut self) -> Option<&mut T> {
        self.0.lookup(self.1)
    }
}

這是因為 lock() 的回傳值 MutexGuard 是個引用型別,其產生的引用的生命週期當然不能超過它,不然就無效而無法擁有鎖提供的保障了。在 Cache 的 lookup 會希望取得該值的引用,所以常見的做法是把 MutexGuard 延長給使用者,在那裏使用完後才釋放鎖。

以上就是要讓型別跨執行緒時常見的思路,除了 Mutex 以外,RwLock 與 Atomic 型別也都適用這樣的思維。在一開始使用這些型別時,的確可能會覺得卡卡的。但習慣後會發現實際要寫的程式並不多,而且可以安全地定義並使用,享受 fealess concurrency 帶來的好處。