跳轉到

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,永遠
}

三個設計決策值得細看:

  1. 資料在鎖內:型別上不可能「拿著資料不打鎖」——Mutex<Vec<u8>> 的內容只有 lock() 一條路。
  2. MutexGuard 是 RAII:lock() 回傳 guard,guard 的 Drop 呼叫 unlock。忘記解鎖(C 常見 bug)在 Rust 不可能,因為沒有裸 unlock 可呼叫。
  3. 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,編譯錯誤

習題#

  1. 把 Arc<Mutex<..>> 換成 Rc<Mutex<..>>,閱讀編譯錯誤,解釋 Rc 為何不是 Send。
  2. 把 worker 的 Ordering::Acquire 改成 Relaxed,想想什麼情況下會出錯。
  3. 實作一個生產者-消費者:Arc<Mutex<VecDeque<T>>> + Condvar,並解釋為什麼 wait 需要 MutexGuard。