快轉到主要內容
  1. 教學文章/

Rust 多執行緒入門:threads、Mutex、Arc 與共享狀態完全攻略

·10 分鐘· loading · loading · ·
Rust Thread Mutex Arc Concurrency Python
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Rust 入門 - 本文屬於一個選集。
§ 13: 本文

featured

一、前言
#

很多 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 的並行模型很重視兩件事:

  1. 所有權是不是明確
  2. 共享資料是不是有同步保護 如果你腦中先有這兩條主線,後面的 moveMutexArc 都會好懂很多。

三、第一個執行緒: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,那可能看起來像複製。如果是 StringVec<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、網路、計算,通常不應該把那些工作放在鎖內。比較好的方式是:

  1. 先在鎖外完成重工作
  2. 最後只用很短時間更新共享狀態 像這樣:
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::spawnmoveMutexArc 這幾個核心概念,後面很多並行工具都會突然變順眼。請特別記住這句話。**Arc 是共享擁有權,Mutex 是共享可變存取。**這句一通,Arc<Mutex<T>> 就不再只是神祕咒語。它只是兩個責任分工明確的型別疊在一起。如果你剛開始學,建議先從小範例反覆練。先寫計數器。再寫共享 Vec。再試著把耗時工作移到鎖外。等這幾件事熟了,你對 Rust 並行的直覺就會長出來。那時候你會發現,編譯器前面碎念那麼多,其實是在保護你。雖然它的口氣常常很像嚴格的實驗室學長就是了。

延伸閱讀
#

Rust 入門 - 本文屬於一個選集。
§ 13: 本文

相關文章

Rust 測試入門:unit test、integration test 與 cargo test 完全攻略
·10 分鐘· loading · loading
Rust Testing Unit-Test Integration-Test Cargo Python
Rust Async + Tokio 入門:非同步 Rust vs Python asyncio
·8 分鐘· loading · loading
Rust Async Tokio Python Asyncio 非同步
Rust 智慧指標:Box、Rc、Arc 完全攻略
·8 分鐘· loading · loading
Rust Smart Pointers Box Rc Arc Memory Management
Rust Closures + Iterators:用 FP 風格寫出優雅的 Rust
·9 分鐘· loading · loading
Rust Python Closures Iterators Functional Programming
Rust Collections vs Python:Vec 和 HashMap 的生存指南
·7 分鐘· loading · loading
Rust Python Collections Vec Hashmap
Rust 生命週期(Lifetime)入門:編譯器教你管記憶體
·8 分鐘· loading · loading
Rust Lifetime Borrowing Memory-Safety Python