Rust中的併發性:Sync 和 Send Traits

睡觉谁叫發表於2024-04-30

在併發的世界中,最常見的併發安全問題就是資料競爭,也就是兩個執行緒同時對一個變數進行讀寫操作。但當你在 Safe Rust 中寫出有資料競爭的程式碼時,編譯器會直接拒絕編譯。那麼它是靠什麼魔法做到的呢?

這就不得不談 Send 和 Sync 這兩個標記 trait 了,實現 Send 的型別可以在多執行緒間轉移所有權,實現 Sync 的型別可以在多執行緒間共享引用。但它們內部都是沒有任何方法宣告以及方法體的,二者僅僅是作為一個型別約束的標記資訊提供給編譯器,幫助編譯器拒絕執行緒不安全的程式碼。

定義:

pub unsafe auto trait Send { }

pub unsafe auto trait Sync { }

本文將深入探討 SyncSend 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 型別的一些例子是:

  • 原始型別,如 i32boolchar 等。
  • 簡單的聚合型別,如元組 (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 ,這意味著該型別的值的所有權可以線上程之間轉移。

例如,像 i32bool 這樣的原始型別是 Send

因為它們線上程之間共享時沒有任何內部引用或可變而導致問題:

然而,像 Rc<i32> 這樣的型別未實現 Send ,因為它的引用計數在內部發生了變化,並且多個執行緒改變相同的引用計數可能會導致記憶體不安全:

Rc<T> 這樣的非 Send 型別不能跨執行緒傳輸,但它們仍然可以在單個執行緒中使用。當執行緒需要共享一些資料時,非 Send 型別可以被包裝在像 Arc<T> 這樣的執行緒安全的包裝器中,Arc使用原子操作來管理引用計數,並允許內部型別線上程之間共享。

總結一下,關於 Send 的幾個關鍵點是:

  • 型別 Send 可以線上程之間轉移所有權
  • i32bool 這樣的原始型別是 Send
  • 具有內部可變的型別(如 Rc<T> )通常不是 Send
  • Send 型別仍然可以在單個執行緒中使用,或者在包裝在像 Arc<T> 這樣的執行緒安全的容器中時線上程之間共享
  • 跨執行緒傳輸非 Send 型別會導致未定義的行為和記憶體不安全

自定義實現 Sync 和 Send

要建立自定義型別 SyncSend ,您只需實現型別的 SyncSend trait。

這裡有一個 持有裸指標*const u8MyBox 結構體, 由於只要複合型別中有一個成員不是 Send 或者 Sync,那麼該型別也就不是 Send 或 Sync。裸指標*const u8 均未實現 SendSync TraitMyBox 複合型別也不是 SendSync

若給 MyBox 實現了 Send 和 Sync 則藉助 Arc 可線上程間傳遞和共享資料。當然建議自己不要輕易去實現 Sync 和 Send Trait ,一旦實現就要為被實現型別的執行緒安全性負責。這件事本來就是一件很難保證的事情。

有些型別是不可能生成SyncSend的,因為它們包含非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>)來安全地從多個執行緒進行。
  • 使用訊息傳遞線上程之間進行通訊,而不是直接共享記憶體。這有助於避免資料競爭和未定義的行為。
  • 儘可能地限制為修改鎖定資料的範圍。持有鎖太長時間會影響效能和吞吐量。
  • 根據它們是否實現SyncSend仔細選擇型別。例如,線上程之間共享時,首選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 入門與實踐

相關文章