JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Send、Sync 與內部可變性

Rust 究竟如何讓安全的程式碼跨執行緒共享資料,卻連一個資料競爭都不會發生?兩個標記特徵 Send 與 Sync 把執行緒安全編碼進型別系統,而內部可變性則展示了借用規則如何能在執行期彎折,卻從不折斷。

我們即將要彎折的那條規則

本階梯到目前為止的一切,都建立在第一篇那條鐵律上:任一時刻,一個值可以有許多共享的讀取者,恰好一個寫入者,但絕不能兩者同時。這就是別名與可變性互斥——借用檢查器在編譯期強制它,而這也正是為什麼安全的 Rust 沒有資料競爭。資料競爭指的是這種情況:兩個執行緒在同一時間碰同一塊記憶體、至少一個在寫、而且沒有任何東西替它們排序。若一個值永遠不可能同時被別名又可變,那這種情況在構造上就無法觸及。本篇整個故事就是:當我們真的需要共享、可變、跨多執行緒的狀態時,要怎麼仍然守住這份保證?

首先,整篇都仰賴的一個尖銳區分。回想同步那一階:並行不等於平行,而共享可變狀態幾乎是每一個執行緒臭蟲的根源。在 C 裡,跨執行緒共享是預設,而「安全」是你必須親手加上去的東西——每一次共享寫入都需要一個你記得去拿的互斥鎖,忘記了就是未定義行為。Rust 把這件事翻轉過來:跨執行緒共享不是預設。型別系統會拒絕讓一個值跨越執行緒邊界,除非那個值的型別已經掙得了這項權利。授予這項權利的兩個特徵就叫做 Send 與 Sync。

Send 與 Sync:把執行緒安全變成兩個標記特徵

Send 與 Sync標記特徵——它們完全沒有任何方法。一個沒有方法的特徵不帶任何行為;它只是把一項事實貼到型別上,一項編譯器能檢查、能推理的事實。這兩項事實幾乎一模一樣,卻值得小心分開。一個型別是 Send,如果把該型別的一個值移動到另一個執行緒是安全的——也就是跨越邊界交出獨佔所有權。一個型別是 Sync,如果讓好幾個執行緒同時持有指向同一個值的共享參考是安全的。乾淨的關係是:型別 T 是 Sync,恰好當參考型別 &T 是 Send。「把 &T 跨執行緒共享」與「把 &T 送到另一個執行緒」是同一件事。

這裡藏著一個悄悄的精妙之處:你幾乎從不親手實作它們。它們是自動特徵。編譯器會自動替一個結構體推導出 Send 與 Sync,當且僅當它的每一個欄位都是 Send(或 Sync)。這個性質從葉子往上組合。純資料——一個 i32、一個 String、一個裝著 Send 東西的 Vec——都是 Send 也是 Sync,所以能自由地跨執行緒。非 Send 的型別就是那少數真正帶著跨執行緒危險的型別,而型別系統會把這份危險往外傳播:在你的結構體裡放進一個非 Send 的欄位,整個結構體就自動失去 Send,不需要你寫任何標註。

退一步看看剛剛發生了什麼。「這個東西跨執行緒共享安全嗎?」——歷史上系統程式設計裡最難的單一問題,那個你過去靠程式碼審查、直覺與禱告來回答的問題——如今變成了編譯器替你的型別、從底層機械地算出來的一項事實。第一篇的借用檢查器阻止兩個執行緒在語言之內又別名又可變;Send 與 Sync 把這套一模一樣的推理延伸到執行緒邊界之外。它們是借用規則與真正並行之間的那座橋。

內部可變性:在執行期彎折借用規則

現在進入下半場。有時候編譯期的規則對一個正確的程式來說太嚴了。你手上握著一個共享參考 `&T`——它通常禁止可變——可是你真的需要改動它背後的東西,而且你能向一個人類證明:沒有任何兩次改動其實會重疊。最經典的例子正是 Rc:每個共同擁有者都持有一個指向計數的共享參考,可是 clone 卻必須遞增它。解開這個結的模式就是內部可變性:一個讓你能透過 `&T` 進行改動的型別,作法是把「別名與可變性互斥」的檢查,從編譯期搬到執行期

規則本身從未改變——任一時刻至多一個寫入者依然成立。改變的是何時以及如何檢查。三樣工具排成一道階梯,每一階多付一點,換來多一點共享。`Cell<T>` 最便宜:它禁止交出任何內部參考,所以只讓你以複製的方式整個取值與設值;既然不可能有參考,連別名檢查都不需要,執行期成本是零。`RefCell<T>` 是主力:它會交出真正的借用,但在內部保留一個小小的借用計數器,在執行期檢查。以共享方式借它,計數就上升;在一個共享借用仍存活時去要一個可變借用,它不會默默損毀——它會 panic。檢查從編譯器搬到了一個執行期旗標,於是違反「別名與可變性互斥」就變成一聲響亮、確定性的崩潰,而不是未定義行為。

use std::cell::RefCell;
let c = RefCell::new(5);
let a = c.borrow();        // shared borrow  -> runtime count: 1 reader
let b = c.borrow_mut();    // wants a writer while a reader is live
// PANIC at runtime: 'already borrowed: BorrowMutError'
// the rule held -- it was just checked now, not by the compiler

// Cell, by contrast, has no references to count, so no panic is possible:
use std::cell::Cell;
let n = Cell::new(0u32);
n.set(n.get() + 1);        // get a copy, set a copy -- always fine
RefCell 把借用檢查搬到執行期:規則沒變,時機變了。

誠實面對你剛做的這筆交易。RefCell 並沒有讓你的程式比借用檢查器更安全——它讓程式更寬鬆,代價是把一個編譯錯誤換成一個可能的執行期 panic。你扛起了過去由編譯器揹著的責任。當借用模式正確、卻太動態以致檢查器跟不上時(一張圖、一個快取、一個父子結構)才用它,而不是把它當成消音借用錯誤的反射動作。一個會 panic 的 RefCell 是一個你出貨了的臭蟲,只不過它是響亮地失敗,而不是像 C 那樣默默損毀記憶體。

跨越執行緒:Mutex 是多執行緒世界的 RefCell

Cell 與 RefCell 在一個執行緒上提供內部可變性;兩者都被刻意設為非 Sync,所以編譯器會阻止你跨執行緒共享它們——它們的執行期借用旗標本身不是原子的,會發生競爭。RefCell 在多執行緒裡的對應物,就是你在同步那一階見過的 Mutex,如今穿上了 Rust 的外衣。C 的 `pthread_mutex_lock()` 守護的是一段程式碼區域,並信任你只在持有鎖時碰資料;而 Rust 的 `Mutex<T>` 是把資料包在裡面。要碰到內部值的唯一辦法,是呼叫 `.lock()`,它會阻塞到鎖屬於你為止,然後交還一個會解參考到資料的守衛(guard)。

這一個設計選擇直接關掉了 C 裡最常見的並行臭蟲:你根本碰不到受保護的資料,除非你持有鎖,因為資料只能透過守衛取得,而守衛只在成功上鎖之後才存在。而當守衛離開範圍時,它的 Drop 實作會自動釋放鎖——這正是本階梯稍早那個 RAII 模式,如今解鎖一個互斥鎖,而不是釋放記憶體。沒有 `unlock()` 可以忘記、沒有跳過它的提早 return 路徑、沒有 panic 時洩漏的鎖。鎖被綁在一個範圍上,就和一塊堆積配置被綁在一個 unique_ptr 上一模一樣。

要在執行緒之間共享那個 Mutex,你把它包進 Arc——那個用原子計數的擁有者——得到慣用的 `Arc<Mutex<T>>`:Arc 讓好幾個執行緒共同擁有同一個 Mutex(Arc 是 Send 也是 Sync),而 Mutex 讓那份共享的改動變安全。讀一遍型別,安全性的論證就自己掉出來:只要 T 是 Send,Mutex<T> 就是 Sync,因為鎖保證任一時刻只有一個執行緒在裡面,所以即使只是 Send 的 T,透過它去改動也是安全的。整座動物園——Cell、RefCell、Mutex、Rc、Arc——是同一個想法在強度上逐級遞增:取回你能證明為正確的共享可變性,並讓型別系統、一個執行期旗標、或一把鎖來揹著那份證明。

把它組合起來,以及 unsafe 從哪裡進場

讓我們把這條安全路徑,從一個共享計數器一路追到底。假設有四個執行緒,每個都需要把一個共享的整數加一。你寫下 `let n = Arc::new(Mutex::new(0));`,替每個執行緒各 clone 一次 Arc,然後在每個執行緒裡做 `*n.lock().unwrap() += 1`。把這些保證走一遍:Arc 是 Send,讓每個 clone 跨越 spawn 邊界;Arc 是 Sync,讓四個一起共同擁有;Mutex 把那些改動串列化;守衛的 Drop 在離開時解鎖。四個運作的零件,而編譯器在程式跑起來之前就把每一個都檢查過了。

  1. 先問:這真的需要跨執行緒共享嗎?如果資料由單一執行緒擁有,就不需要 Arc、不需要 Mutex、不需要原子——直接把它移進去,保持最簡單的作法。
  2. 如果你需要跨執行緒的共享所有權,就拿 Arc(Send + Sync);單執行緒的共享所有權維持較便宜的 Rc。
  3. 如果那個共享的東西還必須被改動,就把資料包進 Mutex(或 RwLock);在單執行緒上,較輕的 RefCell 或 Cell 做同樣的工作。
  4. 把組合出來的型別大聲讀出來當作規格——Arc<Mutex<T>> 字面上就在說「共享、上鎖、可變」——然後信任編譯器去拒絕那些並非執行緒安全的組合。

一條誠實的線頭,把這篇接到下一篇。Send、Sync、Cell、RefCell、Mutex、Arc——它們每一個,在最深處,都是用 unsafe 程式碼實作的。對於一個編譯器看不進去的型別(由裸指標組成的那種),Send 與 Sync 的自動推導可能是錯的,所以這種型別的作者必須寫 `unsafe impl Send for MyType {}`——一份親手簽名給編譯器的承諾:「我已經檢查過那個自動規則無法檢查的不變式。」Arc 的原子計數器、RefCell 那些受旗標檢查的借用、Mutex 守衛——全都坐在一個小小的 unsafe 核心上,那個核心被一個人類審核過一次,好讓建立在它之上的數百萬行程式碼能完全保持安全。

這才是這筆交易真正的形狀,把它直說出來是公道的。安全的 Rust 並沒有廢除底層的危險;它把危險集中起來。在 C 裡塗抹在一個多執行緒程式每一行上的那種危險推理,被聚攏進少數幾個受審核的 unsafe 積木,包裹在安全型別之中,而這些型別的簽章恰好編碼了它們何時才可以被使用。下一篇直接打開那個核心:unsafe 究竟允許什麼、你寫下它時所簽的那份契約,以及這同一套機制如何越過 FFI 邊界,回到本階梯一開始所在的那個 C 世界。