本文 AI 產出,尚未審核

Rust 智能指標:記憶體洩漏與循環參考

簡介

在 Rust 中,所有權借用機制讓大多數記憶體問題在編譯期就能被捕捉,然而當我們開始使用 智能指標(如 Rc<T>Arc<T>RefCell<T>)時,仍有可能踩到記憶體洩漏或循環參考的陷阱。這類問題不會產生編譯錯誤,卻會在執行時讓程式持續佔用資源,最終導致效能下降或系統崩潰。
本篇文章將從概念、實作範例、常見陷阱與最佳實踐,帶領讀者完整掌握 Rust 中的記憶體洩漏與循環參考,並提供實務上可直接套用的解決方式。


核心概念

1. 為什麼會有記憶體洩漏?

Rust 的編譯器會在變數離開作用域時自動呼叫 drop,釋放其擁有的資源。但如果一段記憶體 被多個所有者 共同持有(例如 Rc<T>),且這些所有者之間形成 循環參考,則引用計數永遠不會降為 0,drop 也就不會被觸發,造成記憶體洩漏。

2. Rc<T>Arc<T>:引用計數的雙刃劍

智能指標 主要用途 執行緒安全性
Rc<T> 單執行緒共享所有權 安全
Arc<T> 多執行緒共享所有權 安全(原子計數)

兩者都使用 引用計數(reference counting)來追蹤有多少個指標指向同一塊記憶體。只要計數 > 0,記憶體就不會被釋放。

3. RefCell<T>:在執行期檢查可變借用

RefCell<T> 允許 在執行期 進行可變借用檢查,配合 Rc<T> 常被用來建立 可變的共享結構。但若 RefCell<T> 包含 Rc<T>,就更容易產生循環參考。

4. 循環參考的形成

最典型的例子是「雙向鏈結串列」或「樹狀結構」中,父節點持有子節點的 Rc<T>,而子節點同時持有父節點的 Rc<T>。此時兩個節點互相引用,引用計數永遠不會降為 0。


程式碼範例

以下範例分別示範 正常使用產生循環參考、以及 打破循環 的技巧。每段程式碼皆附上說明註解,方便讀者快速掌握要點。

範例 1:Rc<T> 的基本使用(不會洩漏)

use std::rc::Rc;

fn main() {
    // a 為第一個 Rc,指向整數 5
    let a = Rc::new(5);
    println!("a 的計數 = {}", Rc::strong_count(&a)); // 1

    {
        // b 共享 a 的所有權
        let b = Rc::clone(&a);
        println!("b 的計數 = {}", Rc::strong_count(&b)); // 2
        println!("a + b = {}", *a + *b); // 10
    } // b 離開作用域,計數減 1

    println!("a 的最終計數 = {}", Rc::strong_count(&a)); // 1
} // a 離開作用域,計數變 0,記憶體被釋放

重點:只要沒有形成循環,Rc 會在最後一個持有者離開時自動釋放資源。


範例 2:最簡單的循環參考(記憶體洩漏)

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    // 子節點的集合,使用 RefCell 允許在執行期修改
    children: RefCell<Vec<Rc<Node>>>,
    // 父節點的指標,若直接使用 Rc 會形成循環
    parent: RefCell<Option<Rc<Node>>>,
}

fn main() {
    // 建立根節點
    let root = Rc::new(Node {
        children: RefCell::new(vec![]),
        parent: RefCell::new(None),
    });

    // 建立子節點,並把父節點指向 root
    let child = Rc::new(Node {
        children: RefCell::new(vec![]),
        parent: RefCell::new(Some(Rc::clone(&root))),
    });

    // 把子節點加入 root 的 children
    root.children.borrow_mut().push(Rc::clone(&child));

    // 此時 root <-> child 形成雙向 Rc 循環
    println!("root 計數 = {}", Rc::strong_count(&root));   // 2
    println!("child 計數 = {}", Rc::strong_count(&child)); // 2

    // 程式結束時,兩個 Rc 的計數仍為 2,記憶體不會被釋放
}

警告:上述程式在 main 結束後仍佔用記憶體,Rust 的檢測工具(如 valgrindcargo leak)會報告 memory leak


範例 3:使用 Weak<T> 打破循環

Weak<T>Rc<T> 的「弱引用」版本,不會增加引用計數。常用於「指向父節點」的情況,讓父節點可以被子節點「觀察」但不「擁有」。

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Option<Weak<Node>>>, // 改為 Weak
}

fn main() {
    let root = Rc::new(Node {
        children: RefCell::new(vec![]),
        parent: RefCell::new(None),
    });

    let child = Rc::new(Node {
        children: RefCell::new(vec![]),
        parent: RefCell::new(Some(Rc::downgrade(&root))), // 產生 Weak
    });

    root.children.borrow_mut().push(Rc::clone(&child));

    // 檢查計數
    println!("root 強計數 = {}", Rc::strong_count(&root)); // 1
    println!("root 弱計數 = {}", Rc::weak_count(&root));   // 1 (child 持有 Weak)
    println!("child 強計數 = {}", Rc::strong_count(&child)); // 1

    // 若想取得父節點,必須升級 Weak
    if let Some(parent) = child.parent.borrow().as_ref().and_then(|w| w.upgrade()) {
        println!("child 的父節點仍在:{:?}", parent);
    }
} // 所有 Rc 離開作用域,計數歸零,記憶體正確釋放

要點Weak 只在需要「暫時」存取資源時使用 upgrade(),若升級失敗代表資源已被釋放。


範例 4:Arc<T> + Mutex<T> 的多執行緒循環(同樣需要 Weak

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

#[derive(Debug)]
struct Node {
    children: Mutex<Vec<Arc<Node>>>,
    parent: Mutex<Option<Weak<Node>>>,
}

fn main() {
    let root = Arc::new(Node {
        children: Mutex::new(vec![]),
        parent: Mutex::new(None),
    });

    let child = Arc::new(Node {
        children: Mutex::new(vec![]),
        parent: Mutex::new(Some(Arc::downgrade(&root))),
    });

    root.children.lock().unwrap().push(Arc::clone(&child));

    // 多執行緒讀取
    let handles: Vec<_> = (0..4)
        .map(|_| {
            let c = Arc::clone(&child);
            thread::spawn(move || {
                if let Some(p) = c.parent.lock().unwrap().as_ref().and_then(|w| w.upgrade()) {
                    println!("子執行緒看到父節點:{:?}", p);
                }
            })
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
} // 所有 Arc 在此釋放,記憶體正確回收

說明:在多執行緒環境下,必須使用 Arc(原子引用計數)以及同步原語(Mutex)保護可變資料,循環參考的解法仍是 使用 Weak


範例 5:手動觸發記憶體洩漏(mem::forget

雖然 Rust 預設安全,但仍提供 std::mem::forget 讓開發者刻意讓值不被 drop,這在 FFI 或特殊需求時會使用。示範如下:

use std::rc::Rc;
use std::mem;

fn main() {
    let a = Rc::new(String::from("leak"));
    println!("計數前 = {}", Rc::strong_count(&a)); // 1

    // 忘記釋放 a,導致記憶體永遠不會被回收
    mem::forget(a);

    // 之後若再檢查計數,已無法取得 a 的引用
    // 這裡僅示意,實務上不建議這樣做
}

提醒mem::forget 會直接跳過 drop,若不慎使用會造成永久記憶體洩漏。僅在確定外部系統會自行回收時才使用。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案
直接在子節點使用 Rc<T> 指向父節點 形成循環,記憶體永不釋放 改用 Weak<T>,只在需要時 upgrade()
RefCell<T> 中持有 Rc<T>,卻忘記檢查循環 运行時 panic(borrow 检查失敗)或洩漏 使用 Weak<T>,或將結構改寫為不可變(Rc + Vec
在多執行緒環境使用 Rc<T> 競爭條件、未定義行為 改用 Arc<T>,配合 Mutex/RwLock
過度使用 mem::forget 記憶體永久佔用,難以偵測 只在 FFI 需要手動釋放時使用,並確保外部會回收
忘記升級 Weak<T> 前檢查 None upgrade() 失敗導致 unwrap() panic 使用 if let Some(p) = weak.upgrade()match 處理 None

實用技巧

  1. 在資料結構的 API 中隱蔽 Weak
    為避免使用者忘記升級,可在結構提供 fn parent(&self) -> Option<Rc<Node>>,內部自行處理 WeakRc 的轉換。

  2. 使用 Rc::downgrade 產生 Weak
    downgrade 是唯一安全產生 Weak 的方式,避免手動建構導致錯誤。

  3. 檢查引用計數
    在除錯階段,可透過 Rc::strong_countRc::weak_count 觀察是否有意外的循環。

  4. 利用 cargo treecargo bloat
    這類工具能幫助分析二進位大小與依賴結構,間接偵測過度使用 Arc/Rc 帶來的記憶體開銷。


實際應用場景

場景 為什麼需要智能指標 可能的循環 解法
GUI 框架的 Widget 樹 子 Widget 需要存取父 Widget 的屬性 父 → 子 (Rc),子 → 父 (Rc) 父使用 Rc,子使用 Weak 指向父
文件系統的目錄結構 目錄與檔案共享同一個 Node 結構 目錄 → 子目錄 (Rc),子目錄 → 父目錄 (Rc) 父節點 Rc,子節點 Weak
遊戲引擎的實體-組件系統 多個系統同時持有同一實體的引用 系統 A 持有 Rc<Entity>,系統 B 也持有,同時實體內部持有系統的回呼 (Rc) 系統回呼改為 Weak,避免實體↔系統的雙向持有
多執行緒緩存 (Cache) 多執行緒共享緩存資料 緩存條目持有 Arc<T>,條目內部又持有指向緩存本身的 Arc 使用 Weak 讓條目可觀測緩存,但不阻止緩存釋放

總結

  • 智能指標RcArc)提供了共享所有權,但若不慎形成 循環參考,會導致記憶體永遠無法釋放。
  • Weak<T> 是打破循環的關鍵工具:它不會增加引用計數,只在需要時升級為 Rc/Arc
  • 單執行緒 使用 Rc + RefCell,在 多執行緒 使用 Arc + Mutex/RwLock,並配合 Weak 以避免循環。
  • 最佳實踐 包括:隱蔽 Weak 的升級邏輯、在除錯時檢查計數、避免在不需要共享所有權的情況下使用 Rc/Arc
  • 透過上述概念與範例,您可以在 Rust 中安全地構建複雜的資料結構,同時保持記憶體的自動回收機制,避免隱蔽的記憶體洩漏與效能問題。

記得:Rust 的安全保證是基於「所有權」與「借用」的靜態檢查,而 智能指標的使用 是唯一需要在程式設計階段自行負責的記憶體管理部分。掌握 Rc/ArcWeak 的正確搭配,您就能寫出既高效又安全的 Rust 程式。祝開發順利!