Rust 並行與多執行緒
主題:並行安全性(Send、Sync)
簡介
在多核心時代,並行程式設計已成為提升效能的關鍵技術。Rust 之所以在系統程式領域廣受好評,最重要的原因之一,就是它在編譯期就能保證 資料競爭安全(data‑race safety)。這項保證的核心概念,就是兩個自動衍生的 marker trait:Send 與 Sync。
Send表示「可以安全地將所有權轉移到其他執行緒」;Sync表示「同一個值的參考可以同時被多個執行緒存取」。
了解這兩個 trait 為什麼存在、什麼情況下會自動實作、什麼時候需要手動實作,對於寫出正確且高效的並行程式至關重要。本文將以淺顯易懂的方式,從概念說明到實作範例,帶領讀者一步步掌握 Rust 的並行安全模型。
核心概念
1. Send:所有權跨執行緒
在 Rust 中,所有權(ownership) 是唯一的資源管理機制。Send 告訴編譯器:「把 T 的值從目前執行緒搬移到另一個執行緒是安全的」。大多數標準型別(如 i32、String、Vec<T>)都自動實作 Send。
為什麼有 !Send?
某些型別內部包含 非執行緒安全 的資源,例如 Rc<T>(引用計數指標)或 *mut T(裸指標)。這些型別若被搬移到其他執行緒,可能會導致同時存取同一記憶體而產生資料競爭。
範例 1:thread::spawn 需要 Send
use std::thread;
fn main() {
let data = String::from("Hello, Rust!");
// `String` implements Send, 所以可以搬到新執行緒
let handle = thread::spawn(move || {
println!("{}", data);
});
handle.join().unwrap();
}
重點:
move關鍵字將data的所有權搬入閉包,編譯器檢查String: Send,若不符合則編譯失敗。
範例 2:Rc<T> 不是 Send
use std::rc::Rc;
use std::thread;
fn main() {
let rc = Rc::new(42);
// 編譯錯誤: `Rc<i32>` does not implement `Send`
// let handle = thread::spawn(move || {
// println!("{}", rc);
// });
}
提示:若需要在多執行緒間共享資料,請改用
Arc<T>(原子引用計數),它實作了Send與Sync。
2. Sync:共享參考的安全性
Sync 的定義是「&T(不可變參考)在多執行緒中同時使用是安全的」。換句話說,如果 T: Sync,那麼 &T 可以被多個執行緒同時借用而不會產生競爭。
大部分的 不可變資料(i32、String、Vec<T>)都自動實作 Sync。但如果型別內部使用了 內部可變性(例如 Cell<T>、RefCell<T>),則不會自動實作 Sync。
範例 3:Arc<T> 與 Sync
use std::sync::Arc;
use std::thread;
fn main() {
let numbers = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = Vec::new();
for i in 0..5 {
let nums = Arc::clone(&numbers);
let handle = thread::spawn(move || {
println!("thread {} sees {:?}", i, nums[i]);
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
}
Arc<T>同時滿足Send(可搬移)與Sync(可共享),因此適合在多執行緒環境下共享 只讀 資料。
範例 4:RefCell<T> 不是 Sync
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let cell = Arc::new(RefCell::new(0));
// 編譯錯誤: `RefCell<i32>` does not implement `Sync`
// let handle = thread::spawn(move || {
// *cell.borrow_mut() += 1;
// });
}
結論:若需要在多執行緒中修改資料,請改用
Mutex<T>、RwLock<T>或其他具備內部同步機制的型別。
3. 手動實作 Send / Sync(慎用)
在極少數情況下,我們會自行宣告型別為 Send 或 Sync,通常是因為型別包含 unsafe 原始指標或外部 C 函式庫的資源。千萬不要隨意 unsafe impl Send,除非能保證所有存取都是線程安全的。
範例 5:自訂裸指標型別手動實作 Send
use std::marker::PhantomData;
use std::thread;
// 假設我們有一個來自 C 的非同步 API
struct RawPtr<T> {
ptr: *mut T,
_marker: PhantomData<T>,
}
// 只要我們保證在不同執行緒不會同時寫入同一指標,就可以安全地實作 Send
unsafe impl<T> Send for RawPtr<T> {}
fn main() {
let mut value = 10;
let raw = RawPtr {
ptr: &mut value as *mut i32,
_marker: PhantomData,
};
let handle = thread::spawn(move || unsafe {
// 直接解引用裸指標
*raw.ptr = 20;
});
handle.join().unwrap();
println!("value = {}", value); // 可能是 20,也可能是未定義行為
}
警告:上述程式碼在實務上極易產生未定義行為,僅作為「如何手動實作」的示範。若無法保證唯一性,請改用
Arc<Mutex<T>>。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
誤以為 &mut T 自動 Send |
&mut T 本身不具備 Send,只有當 T: Send 時才會。 |
使用 Arc<Mutex<T>> 或 Arc<RwLock<T>> 包裝可變資料。 |
在 thread::spawn 中直接使用 Rc |
Rc 不是 Send,會導致編譯錯誤。 |
改用 Arc,或在單執行緒內部使用 Rc。 |
忘記 Sync 需求的共享參考 |
只要有 &T 被多執行緒同時借用,就需要 T: Sync。 |
使用 Mutex<T> 包裝需要內部可變性的資料,或改寫為無內部可變性。 |
手動 unsafe impl Send/Sync |
若沒有徹底檢查,極易產生資料競爭或未定義行為。 | 盡量避免;若必須,務必在文件中說明安全前提,並寫單元測試驗證。 |
在 static 變數中使用非 Sync 型別 |
static 變數在多執行緒間是全域共享的,必須是 Sync。 |
使用 lazy_static! 或 once_cell::sync::Lazy 搭配 Mutex/RwLock 包裝。 |
最佳實踐小結
- 預設使用安全抽象:
Arc、Mutex、RwLock、channel等都是經過驗證的同步原語。 - 最小化共享範圍:盡量將資料所有權保持在單一執行緒,僅在必要時才共享。
- 利用編譯器檢查:讓編譯器幫你捕捉
Send/Sync的違規,除非真的需要unsafe,否則不要自行實作。 - 寫測試:對於自行實作的
Send/Sync,加入多執行緒測試,確保不會產生競爭。
實際應用場景
1. Web 伺服器的請求處理
在使用 tokio、async-std 等非阻塞執行環境時,常見的做法是把 共享的設定或緩存 放入 Arc<RwLock<T>>,讓每個工作執行緒(或 task)可以同時讀取,必要時再寫入。
use std::sync::{Arc, RwLock};
use tokio::task;
#[derive(Debug, Clone)]
struct Config {
max_connections: usize,
}
#[tokio::main]
async fn main() {
let cfg = Arc::new(RwLock::new(Config { max_connections: 100 }));
// 模擬 10 個同時處理的請求
let mut handles = Vec::new();
for i in 0..10 {
let cfg_clone = Arc::clone(&cfg);
handles.push(task::spawn(async move {
// 只讀設定
let read = cfg_clone.read().unwrap();
println!("task {} sees max = {}", i, read.max_connections);
}));
}
for h in handles {
h.await.unwrap();
}
}
2. 資料庫連線池
連線池本質上是一個 可共享且可變 的容器。典型實作會把池子包在 Arc<Mutex<Vec<Connection>>>,每次取得連線時鎖住池子,使用完畢再釋放。
3. 多媒體編碼/解碼
編碼器往往需要 大量 CPU,會把工作分配給多個執行緒。每個執行緒只處理自己的緩衝區(Vec<u8>),而共享的設定則放在 Arc<Config>,確保 Config: Send + Sync。
總結
Send與Sync是 Rust 保證 資料競爭安全 的兩大基石。Send讓 所有權 能安全搬移至其他執行緒;Sync讓 不可變參考 能在多執行緒中同時使用。- 大多數標準型別自動實作這兩個 trait,只有涉及 內部可變性 或 裸指標 的型別會被排除。
- 避免自行
unsafe impl Send/Sync,除非能提供嚴格的安全證明;否則使用Arc、Mutex、RwLock等安全抽象。 - 在實務開發中,將共享資源包裝成
Arc<...>,配合適當的同步原語,既能保持高效能,又能讓編譯器幫你檢查錯誤。
掌握了 Send、Sync 的概念後,你就能在 Rust 中自信地寫出 安全、可擴充且效能卓越 的多執行緒程式。祝你在並行程式設計的旅程中玩得開心! 🚀