iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Rust

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

(Day20) Rust 併發安全的邊界:Send、Sync 與型別承諾

  • 分享至 

  • xImage
  •  

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

Rust 逼我成為更好的工程師 併發安全的邊界:Send、Sync 與型別承諾

身為工程師,我們都想讓程式跑得更快,而併發(Concurrency)是我們的重要武器之一。
但在其他語言中,多執行緒常常伴隨著不可預期的 Race Condition 和 Deadlock,讓我們在深夜除錯時痛苦不堪。

Rust 透過其獨特的型別系統,在編譯階段就為我們擋下絕大多數的併發問題,將執行期的災難,轉化為編譯期的對話。

Rust 的解決方案:編譯期所有權檢查

Rust 在編譯期 (compile-time) 透過 SendSync 兩個 Marker Trait 來靜態地防止資料競爭。它們不是普通的 Trait,而是一種型別必須遵守的「承諾」。

Send Trait

  • 承諾:一個型別 T 若實作 Send,代表其所有權可以安全地在執行緒間移轉
  • 反例Rc<T> 未實作 Send,因其引用計數器並非原子操作,在多執行緒下遞增或遞減會導致計數錯亂。

Sync Trait

  • 承諾:一個型別 T 若實作 Sync,代表 &T (不可變引用) 可以安全地在執行緒間共享
  • 更精確的定義T is Sync if &T is Send。換句話說,如果一個型別的「引用」可以被安全地「傳送」到另一個執行緒,那麼這個型別就是執行緒同步安全的。
  • 反例RefCell<T> 未實作 Sync,因其內部的借用檢查機制在編譯期完成,但它本身沒有內建的執行緒鎖,在多執行緒下同時進行內部可變性操作會導致資料競爭。

編譯器會強制執行這些規則。不符合承諾的型別,休想跨越執行緒的邊界。
https://ithelp.ithome.com.tw/upload/images/20251004/20124462yNnd7pz00j.png

自動推導的承諾:組合的力量

開發者通常不需要手動實作 SendSync,因為它們是自動 Trait (Auto Trait)。如果一個結構體 (struct) 的所有成員都實作了 Send,那麼這個結構體本身也會自動被編譯器標記為 SendSync 同理)。這就是 Rust 強大組合性的體現,也是為何我們自訂的型別多數時候「天生」就是執行緒安全的。

編譯器的保護:一次失敗的嘗試

在展示正確方案前,讓我們先看看如果試圖打破規則會發生什麼。
Rc<T> 是一個高效的單執行緒引用計數指標,但它沒有 Send 承諾:

use std::rc::Rc;
use std::thread;

let counter = Rc::new(5);
let counter_clone = Rc::clone(&counter);

// 下面的程式碼無法通過編譯!
// error[E0277]: `Rc<i32>` cannot be sent between threads safely
thread::spawn(move || {
    println!("Counter in thread: {}", counter_clone);
});

編譯器直接拒絕了我們的程式碼,並清晰地告知 Rc<i32> 不能安全地在執行緒間傳遞。這不是錯誤,這是保護。

正確的道路:用型別做出承諾

想要在執行緒間共享資料所有權,我們需要一個做出 SendSync 承諾的型別。
Arc<T> (Atomic Reference Counter) 就是 Rc<T> 的原子化、執行緒安全版本。
若要修改資料,則需搭配 Mutex<T>RwLock<T>

use std::sync::{Arc, Mutex};
use std::thread;

// Arc<Mutex<T>> 提供了跨執行緒共享且可變的能力
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..4 {
    // Arc::clone() 只會增加原子引用計數,成本極低
    let counter_clone = Arc::clone(&counter);
    let handle = thread::spawn(move || {
        // .lock() 會鎖定 Mutex,返回一個 MutexGuard
        // 當 MutexGuard 離開作用域時,鎖會自動釋放 (RAII)
        *counter_clone.lock().unwrap() += 1;
    });
    handles.push(handle);
}

for handle in handles {
    handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap()); // 輸出 Result: 4

常見反模式與改善

  • 誤用 Arc 以為萬事 OK

    • 問題Arc<T> 只保證能安全地「共享所有權」,如果 T 本身不是 Sync(例如 RefCell<T>),你依然不能在多執行緒中修改它。
    • 改善:請記住 Arc 只管共享,不管同步修改。修改需要 MutexRwLock
  • 將鎖作為資料結構的一部分暴露 (洩漏 MutexGuard)

    • 問題:有些 API 會直接回傳 MutexGuard,將鎖的細節暴露給外部。這會讓呼叫者持有鎖的時間變得不可控,極易造成死鎖。

    • 不好的設計

      // 直接暴露內部鎖的細節
      pub fn get_items_guard(&self) -> MutexGuard<Vec<Item>> {
          self.items.lock().unwrap()
      }
      
    • 好的設計:應該提供操作資料的方法,將鎖的範圍限制在函式內部,越短越好。

      // 提供封裝好的方法,鎖定範圍最小化
      pub fn add_item(&self, item: Item) {
          self.items.lock().unwrap().push(item);
      }
      

非執行緒安全不是罪,是邊界

Rc<T>RefCell<T> 這類非 Send/Sync 的型別不是「壞設計」,它們只是為不同的「邊界」所設計。
在單執行緒環境中,它們避免了不必要的原子操作開銷,性能更高。
選擇合適的工具,端看你的程式碼是否需要跨越執行緒的邊界。

什麼是「執行緒的邊界」?

「執行緒的邊界」是指資料的所有權或存取權從一個執行緒轉移到另一個執行緒的那個抽象的「點」

如果你的程式是一個國家,而每一個執行緒是這個國家裡的一個獨立城市。

  • 在單一城市內活動 (單一執行緒):你在台北市內活動,使用台幣 (Rc<T>)、悠遊卡、市民證,一切都很簡單、快速且高效。你不需要考慮海關或護照的問題。這就是「在執行緒邊界內」工作。

  • 跨越城市邊界 (跨執行緒):現在,你要把一個包裹(也就是你的資料)從台北(主執行緒)寄到高雄(另一個你生成的執行緒)。這個包裹離開台北、進入高雄的過程,就是「跨越執行緒的邊界」。

這個「跨越」的動作,在程式碼中主要體現在兩種情況:

  1. 所有權的轉移:你把資料的完整所有權 move 進了新的執行緒。包裹寄出後,台北的你就不能再打開它了。

  2. 資料的共享:你在台北保留了包裹的所有權,但你發了一張「通行證」(引用 &T),讓高雄的人可以「看」這個包裹。

邊界上的「海關」:編譯器

Rust 的編譯器就像是邊界上非常嚴格的海關官員。
當任何資料要「跨越邊界」時,它會嚴格檢查這份資料是否持有合法的「通行證」,也就是 SendSync Trait。

  • Send Trait (護照):代表這份資料本身可以被安全地打包、運送到另一個城市。資料的所有權可以被完整轉移。String, Vec<T>, i32 都有護照。但 Rc<T> 沒有,因為它的設計只適用於單一城市內的管理,拿到別的城市會天下大亂。

  • Sync Trait (國際共享資產證明):代表這份資料可以安全地同時被多個城市的人「觀看」(共享不可變引用 &T)。Arc<T> 就像是一份國際公認的資產,所有人都承認它的存在,可以安全地查看。但 RefCell<T> 不行,它是一種只在單一城市內有效的「內部彈性借貸」,沒有同步機制,讓多個城市的人同時操作會導致混亂。

程式碼中的「跨越邊界」

範例 1:成功跨越邊界 (所有權轉移)

thread::spawn 這個函式會建立一個新的執行緒,它就是一個具體的「邊界」。move 關鍵字明確表示我們要將資料的所有權移交過去。

let my_data = String::from("一個包裹"); // String 實作了 Send

// thread::spawn 創造了一個邊界
// `move` 關鍵字將 my_data 的所有權推過了這個邊界
thread::spawn(move || {
    // 現在 my_data 的所有權在新的執行緒裡
    println!("新執行緒收到了:{}", my_data); 
}).join().unwrap();

// 在主執行緒裡,my_data 已經不存在了,因為它被移走了
// println!("{}", my_data); // <-- 這行會編譯失敗!

在這個例子裡,my_data 成功地「跨越了執行緒的邊界」。

範例 2:被邊界攔截 (嘗試運送不合規的資料)

這裡我們試圖運送一個沒有「護照」(Send Trait) 的 Rc<T>

use std::rc::Rc;
use std::thread;

let my_local_data = Rc::new(String::from("本地包裹"));

// 編譯器 (海關官員) 在這裡攔截了我們!
// 它檢查到 Rc 沒有 Send Trait,不允許它跨越邊界。
thread::spawn(move || { // COMPILE ERROR!
    println!("新執行緒無法收到:{}", my_local_data);
});

編譯失敗,正是因為 my_local_data 被禁止跨越執行緒的邊界。

在設計你的資料結構和選擇型別時,你必須先問自己一個問題:「這份資料,未來有沒有可能需要在多個執行緒之間傳遞或共享?」

  • 如果答案是「否」:它只會在單一執行緒內被使用。那麼你就可以自由地使用像 Rc<T>RefCell<T> 這樣高效但非執行緒安全的工具。你不需要為「跨界」支付額外的性能成本(例如原子操作的開銷)。

  • 如果答案是「是」:它需要跨越執行緒邊界。那麼你必須在一開始就選擇具備 SendSync 承諾的型別,例如 Arc<T>Mutex<T>

在定義資料的那一刻,就為它未來的「旅行路徑」做出最恰當的設計。

實踐決策清單

  1. 單執行緒:需要共享所有權或內部可變性?優先使用 Rc<T>RefCell<T>
  2. 多執行緒,唯讀共享:使用 Arc<T>
  3. 多執行緒,可變共享:使用 Arc<Mutex<T>>Arc<RwLock<T>>
  4. 最高原則:無法做出 Send/Sync 承諾的型別,就不要讓它跨越執行緒。

結論

Rust 的 SendSync Trait 並非限制,而是一種強迫我們在設計階段就思考資料所有權、生命週期與共享模式的機制。
它將執行期的潛在災難,提前到編譯期的靜態檢查。

這種「先思考,再編碼」的模式,迫使我們設計出邊界清晰、職責單一的 API,而不是將同步的複雜性洩漏給使用者。
這正是 Rust 逼我們成為更嚴謹、思慮更周全的工程師的方式。

延伸閱讀


上一篇
(Day19) Rust 閉包 (Closure):所有權的邊界與 move 的作用
下一篇
(Day21) Rust `Arc<Mutex>、Arc<RwLock> `與訊息傳遞
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言