在併發的世界中,最常見的併發安全問題就是資料競爭,也就是兩個執行緒同時對一個變數進行讀寫操作。但當你在 Safe Rust 中寫出有資料競爭的程式碼時,編譯器會直接拒絕編譯。那麼它是靠什麼魔法做到的呢?
這就不得不談 Send 和 Sync 這兩個標記 trait 了,實現 Send 的型別可以在多執行緒間轉移所有權,實現 Sync 的型別可以在多執行緒間共享引用。但它們內部都是沒有任何方法宣告以及方法體的,二者僅僅是作為一個型別約束的標記資訊提供給編譯器,幫助編譯器拒絕執行緒不安全的程式碼。
定義:
pub unsafe auto trait Send { }
pub unsafe auto trait Sync { }
本文將深入探討 Sync
和 Send
traits,瞭解為什麼某些型別實現這些 traits,而另一些則沒有,並討論 Rust 中併發程式設計的最佳實踐。
The Sync Trait
Sync
trait 表示一個型別可以安全地被多個執行緒同時訪問。這裡的訪問指的是隻讀共享安全。Rust 中幾乎所有的原始型別都實現了 Sync
trait
例如:
let x = 5; // i32 is Sync
i32
型別實現了 Sync
,所以線上程間共享 i32
值是安全的。
另一方面,提供內部可變性的型別(內部可變性指的是在擁有不可變引用的時候,依然可以獲取到其內部成員的可變引用,進而對其資料進行修改。),如 Mutex<T>
,其中 T 未實現 Sync
trait。
#[stable(feature = "rust1", since = "1.0.0")]
unsafe impl<T: ?Sized + Send> Send for Mutex<T> {}
#[stable(feature = "rust1", since = "1.0.0")]
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}
因為 Mutex
使用鎖來保護對內部資料的訪問,如果多個執行緒同時訪問它,可能會導致資料競爭或死鎖。
舉例來說:
use std::sync::Mutex;
let m = Mutex::new(5); //Mutex<i32> is not Sync
Mutex<i32>
型別沒有實現 Sync
,所以跨執行緒共享是不安全的。
為在多個執行緒安全地訪問非 Sync
型別(如 Mutex<i32>
),我們必須使用適當的同步操作,如獲取鎖,執行操作和釋放鎖,在本文後面看到使用互斥鎖和其他執行緒安全型別的示例。
支援 Sync 的型別
Rust 中的 Sync trait 確保了對同一資料的多個引用(無論是可變的還是不可變的)可以安全地從多個執行緒併發訪問。任何實現 Sync trait 的型別 T
都可以被認為是“執行緒安全”的。
Rust 中的 Sync 型別的一些例子是:
- 原始型別,如
i32
、bool
、char
等。 - 簡單的聚合型別,如元組
(i32, bool)
- 原子型別,如
AtomicBool
另一方面,非同步型別不能同時使用多個引用,因為這可能導致資料競爭。非同步型別的一些示例包括:
Mutex<i32>
- 在訪問內部 i32 之前需要鎖定互斥體。RefCell<i32>
- 在訪問內部值之前需要借用 RefCell。Rc<i32>
- 共享了內部 i32 的所有權,所以多個可變借用是不安全的。
非 Sync 型別多執行緒訪問
Mutex
為在多個執行緒安全地訪問非同步型別,我們需要使用同步原語,如互斥鎖。若僅僅使用 Mutex 而不使用 Arc ,可使用像作用域執行緒(crossbeam),例如:
這裡,我們使用 Mutex<i32>
來安全地從多個執行緒中修改和讀取內部 String。 lock()
方法獲取鎖,阻止其他執行緒訪問互斥體。
Atomic
像 AtomicU64
這樣的原子型別也可以使用像 fetch_add()
這樣的原子操作從多個執行緒安全地訪問。例如:
總結
因此,總而言之,要在 Rust 中跨執行緒共享資料,資料必須:
- 型別為
Sync
(原始/不可變型別) - 封裝在互斥或原子型別中(Mutex、RwLock、Atomic*)
- 使用像通道這樣的訊息傳遞技術來跨執行緒傳遞資料的所有權。
The Send Trait
Rust 中的 Send
trait 表示型別可以安全地跨執行緒邊界傳輸。如果一個型別實現了 Send
,這意味著該型別的值的所有權可以線上程之間轉移。
例如,像 i32
和 bool
這樣的原始型別是 Send
,
因為它們線上程之間共享時沒有任何內部引用或可變而導致問題:
然而,像 Rc<i32>
這樣的型別未實現 Send
,因為它的引用計數在內部發生了變化,並且多個執行緒改變相同的引用計數可能會導致記憶體不安全:
像 Rc<T>
這樣的非 Send
型別不能跨執行緒傳輸,但它們仍然可以在單個執行緒中使用。當執行緒需要共享一些資料時,非 Send
型別可以被包裝在像 Arc<T>
這樣的執行緒安全的包裝器中,Arc
總結一下,關於 Send
的幾個關鍵點是:
- 型別
Send
可以線上程之間轉移所有權 - 像
i32
和bool
這樣的原始型別是Send
- 具有內部可變的型別(如
Rc<T>
)通常不是Send
- 非
Send
型別仍然可以在單個執行緒中使用,或者在包裝在像Arc<T>
這樣的執行緒安全的容器中時線上程之間共享 - 跨執行緒傳輸非
Send
型別會導致未定義的行為和記憶體不安全
自定義實現 Sync 和 Send
要建立自定義型別 Sync
或 Send
,您只需實現型別的 Sync
和 Send
trait。
這裡有一個 持有裸指標*const u8
的 MyBox
結構體, 由於只要複合型別中有一個成員不是 Send 或者 Sync,那麼該型別也就不是 Send 或 Sync。裸指標*const u8
均未實現 Send
和 Sync Trait
故 MyBox
複合型別也不是 Send
或 Sync
。
若給 MyBox 實現了 Send 和 Sync 則藉助 Arc 可線上程間傳遞和共享資料。當然建議自己不要輕易去實現 Sync 和 Send Trait ,一旦實現就要為被實現型別的執行緒安全性負責。這件事本來就是一件很難保證的事情。
有些型別是不可能生成Sync
和Send
的,因為它們包含非Sync
/非Send
型別或允許多執行緒的可變。例如, Rc<T>
不能被設定為Send
,因為引用計數需要被原子地更新,而RefCell<T>
不能被設定為 Sync
,因為它的借用檢查不是執行緒安全的。
同步/傳送規則和最佳實踐
重要的是要記住混合Sync
/Send
和非Sync
/非Send
型別的規則。一些需要遵守的關鍵規則:
型別必須是Send
才能線上程之間移動。這意味著像Rc<T>
這樣的型別不能跨執行緒共享,因為它們不是Send
。
- 如果一個型別包含一個非
Send
型別,那麼外部型別不能是Send
。例如Option<Rc<i32>>
不是Send
,因為Rc<i32>
不是Send
。 Sync
型別可以透過共享引用從多個執行緒併發使用。非Sync
型別不能同時使用它們的值,並且一次只能在一個執行緒中可變。- 如果一個型別包含一個非
Sync
型別,那麼外部型別不能是Sync
。例如Mutex<Rc<i32>>
不是Sync
,因為Rc<i32>
不是Sync
。
併發 Rust 程式碼的一些最佳實踐:
- 儘可能避免可變。支援不可變的資料結構和邏輯。
- 當需要修改時,使用同步原語(如
Mutex<T>
)來安全地從多個執行緒進行。 - 使用訊息傳遞線上程之間進行通訊,而不是直接共享記憶體。這有助於避免資料競爭和未定義的行為。
- 儘可能地限制為修改鎖定資料的範圍。持有鎖太長時間會影響效能和吞吐量。
- 根據它們是否實現
Sync
和Send
仔細選擇型別。例如,線上程之間共享時,首選Arc<T>
而不是Rc<T>
。 - 使用
atomic
型別進行簡單的併發訪問原語型別。它們允許從多個執行緒訪問而不加鎖。
參考連結
Concurrency in Rust: The Sync and Send Traits | by Technocrat | CoderHack.com | Medium
[基於 Send 和 Sync 的執行緒安全 - Rust 語言聖經(Rust Course)](https://course.rs/advance/concurrency-with-threads/send-sync.html "基於 Send 和 Sync 的執行緒安全 - Rust 語言聖經(Rust Course "基於 Send 和 Sync 的執行緒安全 - Rust 語言聖經(Rust Course)")")
Rust 中的 Arc 和 Mutex|關鍵在於 --- Arc and Mutex in Rust | It's all about the bit
Rust 入門與實踐