3.1 執行緒、Mutex 與原子操作:Send/Sync 型別系統保證#
執行緒的歷史包袱#
POSIX Threads 標準於 1995 年定稿(IEEE 1003.1c),把 DCE threads、SunOS 等各家方案統一成 pthread_create/pthread_mutex_lock。三十年來 C/C++ 程式用它寫伺服器,也用它製造了無數 race condition:1997 年 NASA 火星探路者號的優先權反轉(mutex 造成的著名案例)、2000 年代瀏覽器層出不窮的併發 use-after-free——多執行緒共享可變狀態,只要有一處忘記加鎖,災難就會在壓力測試後的某天上線。
問題的根源:C 的型別系統看不出「這塊資料會不會被兩個執行緒同時碰」。鎖是執行期約定,忘記就是未定義行為。
Rust 的答案:把共享規則寫進型別#
Rust std 的 std::thread(1.0 即有)建立在 pthread 上,但加上了兩個標記 trait(2015 年由 Niko Matsakis 的併發型別系統定稿,源自 2010 年代初的「data-race freedom」研究):
Send:型別的值可以移動到另一個執行緒(安全地轉移所有權)Sync:&T可以共用給另一個執行緒(即T: Sync⟺&T: Send)
加上借用規則「&mut 獨占或多個 & 共享,不可兼得」,直接推出一個定理:
沒有同步原語,就無法跨執行緒共享可變資料。
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let h = thread::spawn(move || { // move:所有權轉移給新執行緒
println!("{data:?}"); // 合法:data 已屬於此執行緒
});
// println!("{data:?}"); // 編譯錯誤:data 已被移走
h.join().unwrap();
}
Vec<i32> 是 Send,所以可以 move 進去;但 move 之後原執行緒就沒有了——共享沒有發生,只有轉移。想要真共享可變狀態,必須明示同步機制:
Mutex:所有權與鎖綁定#
C 的互斥鎖與它保護的資料之間沒有任何語言層關聯,只能靠註解與紀律。Rust 的 Mutex<T> 把資料放進鎖裡面:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0)); // Arc:原子引用計數,共用所有權
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut n = c.lock().unwrap(); // 鎖住才能存取
*n += 1;
} // guard drop → 自動解鎖
}));
}
for h in handles { h.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap()); // 10000,永遠
}
三個設計決策值得細看:
- 資料在鎖內:型別上不可能「拿著資料不打鎖」——
Mutex<Vec<u8>>的內容只有lock()一條路。 - MutexGuard 是 RAII:
lock()回傳 guard,guard 的 Drop 呼叫 unlock。忘記解鎖(C 常見 bug)在 Rust 不可能,因為沒有裸 unlock 可呼叫。 - guard 阻止鎖逸出:guard 的生命週期綁在借用上,回傳 guard 超出鎖的作用域會編譯失敗。
而 Arc<Mutex<T>> 的組合本身型別安全:Rc(非原子引用計數)故意不實作 Send,兩個執行緒同時 clone Rc 會造成計數 race——Rust 用「Rc 不是 Send」這一行程式碼,把整類 bug 變成編譯錯誤。
use std::rc::Rc;
let r = Rc::new(5);
// thread::spawn(move || println!("{r}"));
// error: `Rc<i32>` cannot be sent between threads safely
// the trait `Send` is not implemented for `Rc<i32>`
原子操作與記憶體順序#
lock-free 資料結構用原子操作。C++11(2011)引入 std::atomic 與六種記憶體順序;Rust 的 std::sync::atomic 對應,但必須明寫 Ordering,不能像某些 C 程式默默依賴預設:
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
static STOP: AtomicBool = AtomicBool::new(false);
fn main() {
let h = thread::spawn(|| {
while !STOP.load(Ordering::Acquire) {
// 沒有 load 的 Ordering 參數,編譯不過:你必須思考可見性
std::hint::spin_loop();
}
println!("worker stopped");
});
thread::sleep(std::time::Duration::from_millis(100));
STOP.store(true, Ordering::Release);
h.join().unwrap();
}
Acquire/Release 配對保證:worker 看到 STOP == true 時,主執行緒在 store 之前的所有寫入都對它可見。寫成 Relaxed 編譯也過,但語意變弱——Rust 的哲學是「可以選錯,但不能不選」,強迫程式設計師在型別層面留下併發決策的紀錄。
對照 C:同一個計數器#
#include <pthread.h>
#include <stdio.h>
int counter = 0; // 沒有任何語法把它和鎖綁在一起
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
void *work(void *_) {
for (int i = 0; i < 1000; i++) {
pthread_mutex_lock(&m);
counter++; // 漏了這行 lock 就是 UB,編譯器不會說
pthread_mutex_unlock(&m);
}
return NULL;
}
counter++ 不加鎖在 C 是未定義行為但能編譯、通常還能跑對(小機率跑錯);在 Rust 等僫直接編譯失敗。兩者差異不在工程師素質,在錯誤被發現的時間點:編譯期 vs 上線後。
小結#
| 錯誤類別 | C/C++ | Rust |
|---|---|---|
| 忘記加鎖就改資料 | UB | 編譯錯誤(資料在 Mutex 內) |
| 忘記解鎖 | 死鎖 | 不可能(RAII guard) |
| 鎖住之後把指標傳出去 | 災難 | guard 生命週期限制 |
| 非原子型別跨執行緒 | race | 型別不滿足 Send/Sync |
| 引用計數跨執行緒 | race | Rc 非 Send,編譯錯誤 |
習題#
- 把
Arc<Mutex<..>>換成Rc<Mutex<..>>,閱讀編譯錯誤,解釋Rc為何不是 Send。 - 把 worker 的
Ordering::Acquire改成Relaxed,想想什麼情況下會出錯。 - 實作一個生產者-消費者:
Arc<Mutex<VecDeque<T>>>+Condvar,並解釋為什麼wait需要 MutexGuard。