一、前言 #
很多 Python 開發者第一次碰 Rust 多執行緒時,會有兩種反應。第一種是,「喔,終於可以認真碰平行化了。」第二種是,「等等,我只是想開幾個 thread,怎麼編譯器突然開始管我借用規則?」這很正常。因為 Rust 的並行模型,跟 Python 的感覺真的不太一樣。在 Python 裡,threading 很容易上手。你建立 Thread,丟 function 進去,start(),join(),差不多就能跑。但如果多個執行緒共享資料,常常就要靠經驗、習慣,還有一點點祈禱。你得自己記得上鎖。你得自己小心 race condition。你得自己避免某個 thread 在奇怪時機把資料改掉。Rust 的選擇比較硬派。它不是等你跑出 bug 才提醒你。它直接在編譯期問你一句。「這個值現在到底歸誰管?」「這段共享狀態,有沒有被安全地保護起來?」一開始會覺得麻煩。但寫久了會發現,這其實是在幫你少踩很多坑。這篇文章會用 Python 工程師熟悉的角度,帶你把 Rust 多執行緒的核心概念一次搞懂。我們會依序看:
- 如何用
thread::spawn建立執行緒 - 為什麼 closure 常常要加
move - 怎麼用
join()等待執行完成 - 什麼時候需要
Mutex<T> - 為什麼還要再包一層
Arc<T> Arc<Mutex<T>>到底是在幹嘛- 實戰上怎麼設計共享狀態才不會變成災難 如果你剛從 Python 轉來 Rust,這篇很適合接在 ownership、智慧指標之後讀。如果你已經寫過幾個 Rust 小工具,但一碰到執行緒就卡住,那我們今天就是來拆這顆炸彈的。
二、安裝與建立範例專案 #
先建一個最小可執行專案。
cargo new rust-threads-demo
cd rust-threads-demo
我們今天主要會用標準函式庫。也就是說,不需要額外安裝第三方 crate。這點其實很舒服。Rust 標準庫已經內建了不少並行工具。最基本的多執行緒工作,直接用就行。先把 src/main.rs 改成這樣:
fn main() {
println!("hello from main thread");
}
接著執行:
cargo run
如果這步沒問題,我們就可以開始加執行緒。在進入程式碼之前,先記一個大方向。Rust 的並行模型很重視兩件事:
- 所有權是不是明確
- 共享資料是不是有同步保護
如果你腦中先有這兩條主線,後面的
move、Mutex、Arc都會好懂很多。
三、第一個執行緒:thread::spawn
#
最基本的建立方式是 std::thread::spawn。
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("worker thread: {i}");
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..=3 {
println!("main thread: {i}");
thread::sleep(Duration::from_millis(80));
}
handle.join().unwrap();
}
這段程式做了三件事。第一,用 thread::spawn 啟動一個新執行緒。第二,主執行緒自己也繼續往下跑。第三,用 join() 等待 worker 結束。你會看到兩邊輸出的順序交錯。這代表它們是並行進行的。如果你有 Python 背景,可以把它想成這樣:
from threading import Thread
import time
def worker():
for i in range(1, 6):
print(f"worker thread: {i}")
time.sleep(0.1)
t = Thread(target=worker)
t.start()
for i in range(1, 4):
print(f"main thread: {i}")
time.sleep(0.08)
t.join()
整體結構非常像。差別不在「怎麼開 thread」。差別在「資料怎麼交給 thread」這件事。這也是 Rust 真正精彩,或者說真正麻煩的地方。順便提醒一件事。thread::spawn 會回傳一個 JoinHandle。如果你沒有呼叫 join(),主執行緒可能先結束。一旦 main 提前結束,整個程式就收工了。所以在教學範例裡,join() 幾乎是標配。
四、為什麼 closure 常常要加 move
#
新手最常遇到的第一個 Rust 多執行緒錯誤,就是這個。你想把主執行緒裡的資料拿進 worker。例如:
use std::thread;
fn main() {
let message = String::from("hello from Rust");
let handle = thread::spawn(|| {
println!("{message}");
});
handle.join().unwrap();
}
這段通常不會過。原因是 spawn 出去的 closure,可能活得比目前作用域更久。編譯器不知道 message 在主執行緒那邊會不會先被釋放。所以它不允許你只是「借用」這個值。正確寫法通常是加上 move:
use std::thread;
fn main() {
let message = String::from("hello from Rust");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}
move 的意思是,把 closure 用到的外部值,直接搬進 closure 自己的所有權裡。這樣新執行緒就真的「擁有」message 了。編譯器也比較安心。如果你來自 Python,這種感覺有點像是顯式地把資料複製或封裝給 worker。只是 Rust 不讓你含糊帶過。你要嘛借用得很清楚。要嘛乾脆移交所有權。這種寫法剛開始很不直覺。但它真的能避免很多「主程式早就結束了,背景 thread 還在摸奇怪記憶體」的災難。這裡再補一個常見誤區。move 不一定代表深拷貝。它更準確地說,是移動所有權。如果型別本身是 Copy,那可能看起來像複製。如果是 String、Vec<T> 這種擁有 heap 資源的型別,那就是把擁有權轉走。
五、共享狀態的第一道門:Mutex<T>
#
好,現在來到真正的重點。如果多個執行緒都要改同一份資料,怎麼辦?在 Python 裡,你可能會想到 threading.Lock。在 Rust 裡,對應概念是 Mutex<T>。先看單執行緒版的感覺。
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
{
let mut num = counter.lock().unwrap();
*num += 1;
}
println!("counter = {:?}", counter);
}
Mutex<T> 可以想成「被鎖保護起來的 T」。你不能直接改裡面的值。你要先 lock()。成功之後會拿到一個 guard。這個 guard 離開作用域時,鎖就自動釋放。這個設計很 Rust。因為它把「記得解鎖」這種麻煩事,也交給型別系統幫你管。在 Python 裡,你常看到:
lock.acquire()
try:
counter += 1
finally:
lock.release()
或者比較現代一點:
with lock:
counter += 1
Rust 的 guard 就有點像 with lock: 的語意。鎖存在於 guard 的生命週期內。離開區塊,自動釋放。這樣比較不容易忘記。也比較不容易因為錯誤路徑漏掉 unlock。不過這還只是第一層。現在這個 Mutex<i32> 只有一個執行緒在用。如果你想把它分享給多個 thread,事情還沒結束。
六、為什麼光有 Mutex<T> 還不夠
#
很多人第一次看到這種寫法,直覺會想:「那我把 Mutex 丟進多個 thread 不就好了?」可惜沒那麼簡單。先看一個很像正解,但其實不行的例子:
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
這段的問題是,第一次迴圈就把 counter move 進去了。第二次迴圈時,counter 已經不在了。你不能把同一個擁有權搬走十次。這正是 Rust 在提醒你的事。「共享」不是魔法。你需要一個能安全共享所有權的容器。這個容器就是 Arc<T>。
七、共享所有權:Arc<T>
#
Arc 全名是 Atomic Reference Counted。你可以把它想成執行緒安全版的 Rc<T>。它允許多個執行緒共同擁有同一份資料。每 clone 一次,引用計數加一。當最後一個擁有者離開時,資料才真正被釋放。如果你之前看過 Rust 智慧指標:Box、Rc、Arc 完全攻略,這裡會很有既視感。差別只在於,這次我們真的要拿它進 thread。先看最常見的組合技。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("result: {}", *counter.lock().unwrap());
}
這就是 Rust 多執行緒教學裡最經典的範例之一。拆開來看:
Arc負責讓多個 thread 共享擁有權Mutex負責確保同一時間只有一個 thread 能改資料 兩者缺一不可。只有Arc,你可以共享,但不能安全修改。只有Mutex,你有鎖,但不能安全地給多個 thread 持有。所以Arc<Mutex<T>>很常一起出現。第一次看到會覺得括號好多,很像泛型俄羅斯娃娃。但功能其實很清楚。最外層解決「誰擁有」。最內層解決「誰現在可以改」。這個程式跑完之後,理論上會印出:
result: 10
因為 10 個執行緒各自把計數器加一。
八、lock() 回傳的 guard 到底是什麼
#
有些人看到這裡,會問一個很好的問題。為什麼 lock() 之後拿到的不是 &mut T?為什麼還要先存成 num,再用 *num += 1?因為 lock() 回傳的是 MutexGuard<T>。它不是單純的 mutable reference。它是一個帶有解鎖責任的 guard 物件。當 guard 存在時,鎖就保持持有。當 guard 被 drop,鎖才釋放。這讓 Rust 可以用 RAII 的方式管理鎖生命週期。也因此你常看到這種寫法:
{
let mut data = counter.lock().unwrap();
*data += 1;
}
// 這裡鎖已經釋放
這個顯式區塊其實很好用。因為它能幫你縮小鎖的持有範圍。實戰上,鎖拿越久,越容易讓別的 thread 卡住。所以能早點放就早點放。這是寫並行程式的一個基本修養。
九、實戰範例:多個 worker 統計工作數量 #
前面的例子只有加一,稍微抽象。我們來寫一個比較像真實工具的版本。假設你有一批檔案要處理。每個 worker 做完一個任務,就更新共享統計。
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
let jobs = vec!["a.txt", "b.txt", "c.txt", "d.txt"];
let completed = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for job in jobs {
let completed = Arc::clone(&completed);
let job_name = job.to_string();
let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
let mut done = completed.lock().unwrap();
done.push(job_name);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let done = completed.lock().unwrap();
println!("completed jobs: {:?}", *done);
}
這裡共享的不再只是整數。而是一個 Vec<String>。每個執行緒做完工作,就把自己的 job name push 進去。這個例子很接近真實場景。像是:
- 批次處理圖片
- 平行下載多個檔案
- 掃描多個路徑
- 同時呼叫幾個獨立 API
只要最後需要一份共享結果,就很容易出現
Arc<Mutex<Vec<T>>>這種型態。不過也要注意。如果你的工作量很大,或臨界區很長,所有 thread 都擠著搶同一把鎖,效能就未必好。Rust 幫你解決的是安全性。它不保證你自動得到最佳效能。架構還是要自己想。
十、跟 Python threading 最大的差別在哪裡
#
這裡可以直接講結論。**Rust 的痛苦比較早,Python 的痛苦比較晚。**Python 在寫 thread 時,上手通常比較快。你可以先跑起來再說。但資料共享複雜起來之後,race condition、鎖順序、狀態不一致這些問題會慢慢浮上來。Rust 則是反過來。你在寫程式時,就會一直被編譯器追問。這個值能不能跨 thread?這個 closure 擁有什麼?這裡共享可變狀態是否被保護?前期比較煩。但好處是,很多錯誤根本到不了 production。還有一個差別值得提。Python 的 threading 會受到 GIL 影響。所以 CPU-bound 任務通常不會因為多 thread 就線性加速。Rust 沒有這層限制。如果你的工作真的是 CPU-bound,Rust thread 更有機會把核心吃滿。當然,這不代表「看到迴圈就該開 20 個 thread」。並行是工具,不是儀式。任務切分、同步成本、資料結構設計,還是很重要。
十一、常見錯誤一:把鎖拿太久 #
這是實戰最常見的效能地雷之一。例如下面這樣:
let mut data = shared.lock().unwrap();
thread::sleep(Duration::from_secs(2));
data.push("done");
這代表你在持有鎖的兩秒內,其他 thread 全都得排隊。如果真正耗時的是 I/O、網路、計算,通常不應該把那些工作放在鎖內。比較好的方式是:
- 先在鎖外完成重工作
- 最後只用很短時間更新共享狀態 像這樣:
let result = do_expensive_work();
let mut data = shared.lock().unwrap();
data.push(result);
這個原則跟 Python 其實也一樣。只是 Rust 因為 guard 和作用域很明確,往往更容易看出你鎖拿了多久。
十二、常見錯誤二:以為 Arc 本身就能安全修改資料
#
這個誤解很常見。Arc<T> 只能讓你安全共享「不可變存取」或共享所有權。它不自帶可變同步。下面這種觀念是錯的:
let data = Arc::new(vec![1, 2, 3]);
你可以把 data clone 給很多 thread。但如果每個 thread 都想修改裡面的 Vec,就不行。因為 Vec<T> 本身不是 thread-safe 的共享可變狀態容器。所以才需要:
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
Arc 解決共享所有權。Mutex 解決可變存取互斥。請把這兩層分開理解。你之後看別人的程式碼會舒服很多。
十三、常見錯誤三:一看到共享狀態就全部塞進 Arc<Mutex<_>>
#
這是另一個常見陷阱。因為 Arc<Mutex<T>> 太常見了,所以新手很容易把所有東西都包進去。結果最後整個程式像一坨大鎖。大家都在等同一個資源。設計上更好的方向通常是:
- 能不共享,就不要共享
- 能複製資料,就複製
- 能切分責任,就切分
- 鎖的粒度越小越好
例如,如果每個 worker 都只是算自己的部分結果,最後再合併。那你可以讓每個 thread 擁有自己的局部資料。最後
join()完再 collect。根本不一定需要共享可變狀態。很多並行程式的效能,關鍵不在「有沒有用 thread」。而在「共享是不是太多」。
十四、什麼時候該用 thread,什麼時候該看 async #
這個問題也很常被問。如果你的工作是:
- 明確幾個獨立任務
- 可以平行跑
- 需要吃 CPU
- 或者你想用系統 thread 直接切開工作
那
std::thread很合理。但如果你的工作主要是: - 大量網路 I/O
- 高併發連線
- 一堆等待 socket / timer / request 那很多時候 async 會更合適。如果你對這塊有興趣,可以接著看 Rust Async + Tokio 入門:非同步 Rust vs Python asyncio。簡單說:
- thread 比較像真的多工人
- async 比較像少數工人同時管理很多待辦 兩者都重要。只是解的問題不完全一樣。
結語 #
Rust 的多執行緒不算「輕鬆」。至少跟 Python 比起來,它不會讓你先寫爽再說。但它很誠實。它會逼你把 ownership、共享、同步這些問題講清楚。而一旦你真的搞懂 thread::spawn、move、Mutex、Arc 這幾個核心概念,後面很多並行工具都會突然變順眼。請特別記住這句話。**Arc 是共享擁有權,Mutex 是共享可變存取。**這句一通,Arc<Mutex<T>> 就不再只是神祕咒語。它只是兩個責任分工明確的型別疊在一起。如果你剛開始學,建議先從小範例反覆練。先寫計數器。再寫共享 Vec。再試著把耗時工作移到鎖外。等這幾件事熟了,你對 Rust 並行的直覺就會長出來。那時候你會發現,編譯器前面碎念那麼多,其實是在保護你。雖然它的口氣常常很像嚴格的實驗室學長就是了。