[Rust] Box vs. Arc 完整指南:所有權模型、性能比較與多執行緒共享

[Rust] Box vs. Arc 完整指南:所有權模型、性能比較與多執行緒共享
Photo by Hans Eiskonen / Unsplash

Rust 新生第一道送命題:Arc 還是 Box?

別急著翻官方文件,先把三個靈魂拷問擺在桌上:

  1. 誰會摸這塊資料? 只有你自己,還是一群飢渴執行緒?
  2. 它常常要被改嗎? 讀寫頻率決定你願不願意為鎖或原子計數付出開銷
  3. 快 vs. 安心:你更怕延遲還是怕資料競爭?

如果以上問題讓你腦袋卡死,那恭喜,這篇文章就是為此存在。我不會直接把「標準答案」硬塞進你腦袋,而是陪你拆零件、跑 benchmark、畫記憶體佈局,最後讓你自己踩線分勝負

Box : Rust 新生必修的 Heap 小魔法

官方文件 ↗

「我只想塞一個 10 MB 的陣列,為什麼編譯器吵著 stack overflow?」
如果你有過這種困惑,那八成就是 Box 要登場的時候了

Box 是什麼?

  • 把任何 T 搬到 heap,並在 stack 留下一支 8 bytes(在 64‑bit 平台)的指標
  • 想像你租了一個倉庫(heap),把笨重家具(資料)拖進去,手上只留鑰匙(指標)。房租(配置成本)不免費,但空間大、樓板不會塌

什麼時候該用 Box ?

根據 Rust Book 的建議:

  1. 資料超大或大小編譯期算不出來:避免把巨無霸塞爆 stack
  2. 遞迴資料結構:tree、linked list 的節點無法在編譯期確定大小,用 Box 打破「無窮遞迴」僵局
  3. 大量資料轉移所有權:避免 stack 複製
  4. Trait Object:需要動態分派時

什麼時候不該用 Box ?

對於小物件、短生命週期 :

  • 使用 Stack 分配更快,無 heap 分配開銷
  • 沒有額外的記憶體管理成本
  • Stack 非常快,是 Rust 預設的記憶體分配位置
  • 經驗表明,將 allocation rate 降低 10 allocations per million instructions 就能帶來可測量的性能提升

我真的很菜,能看個例子嗎?

// ❌ 小物件不需要 Box
let x = Box::new(5);  // 不必要的 heap 分配

// ✅ 大型資料使用 Box
struct LargeData {
    data: [u8; 1_000_000]
}
let large = Box::new(LargeData { data: [0; 1_000_000] });

// ✅ 遞迴結構必須用 Box
enum List {
    Cons(i32, Box<List>),  // 沒有 Box 會編譯錯誤
    Nil,
}

常見陷阱

  • 以為 Box = 共用指標 → 不,它只有 單一所有者,別跨執行緒複製指標(編譯器也不讓你過)
  • 掉進雙重 BoxBox<Box<T>> 99% 情況下是 bad smell,要的多半是別的東西(也許 Vec<T>、也許 Arc<T>
  • 忘記大小寫Box唯一 使用 PascalCase 命名的原生智慧指標,別手滑打成 box(那是保留關鍵字)

Box 快速小結

Box = 單執行緒、單 owner、堆放大物的保險箱

如果你的程式碼同時滿足以上三斷言,就放心把資料塞進 Box──乾淨、快速、零借用煩惱

Arc:多執行緒共享的團體票

「這塊資料不只我一條執行緒要用,怎麼辦?」
Arc 的答案:原子引用計數——誰拿走就+1,誰還回就-1,計數歸零才真正釋放

Arc 是什麼?

  • Atomically Reference Counted,原子引用計數
  • 替任意 T 發一張「多人共乘卡」,透過 AtomicUsize 追蹤票數,確保釋放時機萬無一失
  • 一桶共享飲料(資料)放在冰箱(heap)。每來一個室友(執行緒)就貼一張姓名貼(Arc::clone),只要牆上的貼紙 > 0,飲料就不會被倒掉
  • Thread Safe:透過原子操作,確保多個執行緒能夠安全地創建和釋放 Arc 實例,而不會引起資料競爭或未定義的行為

什麼時候該用 Arc?

  1. 跨執行緒共讀:靜態設定、字典、只讀快取等
  2. 任務池 / worker threads:把同一份資料傳給多個 thread::spawntokio::spawn
  3. 需要可變又想共享:配合 Mutex / RwLock (Arc<Mutex<T>>) 實現 可變 + 多執行緒 雙保險

不該用 Arc 的時機 ?

  • 單執行緒Rc(甚至 Box)更輕盈,少掉原子指令的成本。
  • 高頻 clone / drop 熱點:在極端高併發計數場景,原子爭用可能成為瓶頸;可考慮 thread‑local refcount 方案(如 triomphe::Arc)

簡單例子

use std::sync::{Arc, Mutex};
use std::thread;

let numbers = Arc::new(Mutex::new(vec![0; 1_000]));
let mut handles = vec![];

for _ in 0..4 {
    let nums = Arc::clone(&numbers); // +1
    handles.push(thread::spawn(move || {
        let mut data = nums.lock().unwrap();
        data[0] += 1; // 安全可變,因為有 Mutex
    }));
}

for h in handles { h.join().unwrap(); } // 四條執行緒跑完,引用計數歸零,自動釋放

常見陷阱

  • Arc ≠ 資料自動 thread safe → Arc 只確保 指標 安全共享,要改值要上鎖
  • 過度 clone → 每次 Arc::clone 都是原子加法,把 Arc 傳到函式裡直接移動(fn foo(x: Arc<T>)) 可省掉一次
  • Reference cycle → 互指的 Arc 形成強循環;請搭配 Weak 解環

Thread Safety in Arc(指標安全 ≠ 資料安全)

  • Arc 保護的是「指標」
    Arc::clonedrop 都用 原子加減,再怎麼多執行緒同時操作也不會衝突
  • Arc 不會自動上鎖
    如果內容 T 需要「多人同時改」,請包 Arc<Mutex<T>>Arc<RwLock<T>>,不包只能共讀,硬改會被編譯器擋下
  • 原子操作的成本
    • 每次 clone / drop 多 2‒3 ns 的原子開銷
    • 高併發下 Arc 的計數欄位會產生 cache‑line contention如果成為 hot spot,考慮 triomphe::Arc 或其他 thread‑local refcount 技術
  • Arc<T> 什麼時候是 Send?
    只要 T: Sync + Send,整個 Arc<T> 既可移動 (Send) 又可共用 (Sync) -> 否則編譯器會報錯提醒你「別跨執行緒」或「包一層鎖」

Arc vs. Rc:怎麼挑?

ArcRc
執行緒安全✔ (Send + Sync)✘(單執行緒限定)
計數類型AtomicUsizeusize
clone / drop 成本微幅增加(原子指令)最低(非原子)
典型用途多執行緒共讀,Arc<Mutex<T>> 共寫單執行緒多所有者、圖/樹結構
只在「確定永遠單執行緒」且「計數操作很熱」的情境下選 Rc,其餘直接用 Arc 省重構

延伸閱讀: Rc 与 Arc 实现 1vN 所有权机制

選用建議

  1. 確定單執行緒 & 對效能斤斤計較 → 選 Rc,少掉原子開銷
  2. 可能日後上多執行緒、或嫌判斷麻煩 → 直接用 Arc,未雨綢繆成本低
  3. 要可變共享 → 包 Mutex / RwLock,兩者同理

Arc 快速小結

Arc = 多執行緒、共讀資料、安全退場的團體票

若你的資料有「大家都要看偶爾要改」這兩需求,把它塞進 Arc<Mutex<T>> 幾乎是 Rust 社群的通用配方

大型資料 : Box 還是 Arc,還是……都要 ?

手上握著一坨 200 MB 的 JSON,要餵 4 條執行緒統計分析。該怎麼包?
  1. 只有一條執行緒
    • 讀多寫少Box<T>,最省事、最省錢
    • 寫也不少 → 仍用 Box,照常可變,不用把自己嚇到上 Arc
  2. 至少兩條執行緒
    • 純讀Arc<T>,原子引用計數的成本,和磁碟 IO 相比幾乎是噪音
    • 需要改Arc<Mutex<T>>Arc<RwLock<T>>,鎖比資料毀損便宜
  3. 主執行緒吃最多,其餘偶爾查Arc<Box<T>>
    • 大塊資料一次 Box 進 heap
    • 偶爾分享時再 Arc::clone 指標,少量原子操作換到彈性
// 範例:大型 read-only 字典,hotspot 在 main thread
let big_map = Box::new(build_big_hashmap()?);   // heap 一次
let shared   = Arc::new(big_map);               // 0‑cost 轉 Arc

for _ in 0..4 {
    let reader = Arc::clone(&shared);           // +1 (atomic)
    std::thread::spawn(move || {
        // 唯讀 → 不用鎖
        println!("{}", reader.get("answer").unwrap());
    });
}

你真正關心的三件事

指標BoxArcRc
初始化成本1× heap alloc1× heap + 1× atomic同 Arc
clone 成本move = free原子加法 ≈ +2 ns同 Arc
快取友善度✔ 單層解引用✘ 計數器 + cache‑line contention✔/✘ 視讀寫比例

處理極大型的資料結構時,需要仔細考慮記憶體碎片化和配置模式:

  • Box 的優勢:配置通常更符合快取友好性,且更容易被配置器優化
  • Arc 的挑戰:可能因為資料和引用計數是分別配置的,導致更多的記憶體碎片

決策關鍵不是資料大小,而是 所有權執行緒,先畫清楚誰會動資料,再挑對智慧指標,少走回頭路

常見地雷

  • Box 裡再包 Vec<u8> ? 可能重複配置。先問自己:真的需要雙重 indirection 嗎?
  • 高頻 Arc::clone / drop → 計數器成為熱點,把指標 移交 給 closure、或用 triomphe::Arc 的 thread‑local refcount
  • 「鎖一定慢」的迷思 → 鎖的常數成本遠小於資料損壞的代價,別為了幾十奈秒去當記憶體炸彈客

結語

你現在總算翻到卷尾,應該對 BoxArc 這兩位「智慧指標界的大小姐」有了親身相處的心得。此刻的我不打算再幫你划重點,課本背完不代表你真的會開車,真正的功夫得在專案裡摔個幾跤,才知道哪些理論能撐住、哪些幻想會碎掉

所有權模型的美在於「逼你思考關係」。誰該擁有?誰能共用?誰必須讓步?那其實跟協作開發、團隊權責分配、甚至人生抉擇一樣──沒有完美答案,只有更清楚的取捨

在下一行 clone()Box::new() 之前,先想清楚背後的故事,再優雅地下指令 (或按tab)