iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 11

(Day11) Rust 智慧指標(Smart pointers):從所有權到安全併發

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師 智慧指標(Smart pointers):從所有權到安全併發

在 Rust 中,智慧指標是為了解決一個主要問題而存在的:
如何在沒有垃圾回收器的情況下,安全、高效地管理記憶體,尤其是在複雜的所有權和併發場景下。

我們從最簡單的需求開始,一步步 tackle 真正棘手的問題,來明白為什麼 BoxRcArc 以及它們的搭檔 RefCellMutex 是這樣設計的。

問題一:如何讓資料在「堆」上有個穩定的家?

預設情況下,資料放在堆疊(stack)上。
但有時候你必須把它放到堆(heap)上,原因很簡單:

  • 體積太大:你不想在函式呼叫時複製一個巨大的物件。

  • 大小不確定:編譯器不知道這東西有多大(比如遞迴類型)。

  • 需要轉移所有權:你想明確地說:「這塊記憶體的管理權現在交給你了」。

解法:Box<T> — 唯一的堆記憶體所有者

Box<T> 是你能用的最簡單、最基礎的工具。
它的工作只有一件:在堆上申請一塊記憶體,然後成為這塊記憶體唯一的主人。

struct SomeData {
    value: i32,
}

fn main() {
    // some_data 被配置在堆上。
    // boxed_data 是指向它的唯一指標,擁有其所有權。
    let boxed_data = Box::new(SomeData { value: 42 });

    // 直接使用,沒有廢話。
    println!("Value is: {}", boxed_data.value);

} // 當 boxed_data 離開作用域,它會自動被銷毀。
  // Rust 會立即釋放堆上的記憶體。乾淨、俐落。

結論:當你需要將資料放在堆上,並且永遠只會有「一個」所有者時,用 Box<T>。別想太多,這就是它的用途。

https://ithelp.ithome.com.tw/upload/images/20250925/20124462gXG2z7G9xx.png

  • 堆疊 (Stack)

    • 我們在程式碼中宣告的變數 boxed_data 實際上位於堆疊上。

    • 但它本身不是 SomeData 這個結構,而是一個智慧指標。它的體積很小且固定,裡面存放著一個記憶體地址。

  • 堆 (Heap)

    • SomeData { value: 42 } 這個真正的、可能很龐大的資料,被配置在堆記憶體上。這就是你為它> 找到的「穩定的家」。
  • 擁有並指向 (Owns and Points to)

    • 堆疊上的 boxed_data 指標指向堆上的 SomeData

    • Box<T> 最重要的特性是所有權boxed_data 是這塊堆記憶體的唯一擁有者

    • boxed_data 離開作用域時 (例如函式結束),它會自動觸發 drop,並釋放它在堆上所擁有> > 的資料。這個過程確保了沒有記憶體洩漏,而且乾淨俐落。

問題二:如何在「單一執行緒」內『唯讀』共享資料?

想像一下,多個地方都需要讀取一份共享設定。複製 N 份是愚蠢的行為,浪費記憶體。你需要讓多個變數指向「同一份」資料。

Box 是行不通的,因為它的所有權是唯一的。

解法:Rc<T> — 非原子性的參考計數

Rc<T> (Reference Counted) 的核心思想很直接:記錄有多少個指標正指向同一塊資料。

  • Rc::new(data):創建一個 Rc,參考計數為 1。

  • Rc::clone(&rc_data)這不是深度複製! 這只是增加參考計數,然後給你一個指向相同資料的新指標。這個操作非常、非常快。

  • 當一個 Rc 指標離開作用域,計數減 1。

  • 當計數歸零,資料被自動銷毀。

use std::rc::Rc;

fn main() {
    // 初始計數為 1
    let shared_data = Rc::new(String::from("shared config"));
    println!("Initial count: {}", Rc::strong_count(&shared_data));

    // reference1 共享所有權
    let reference1 = Rc::clone(&shared_data);
    println!("Count after first clone: {}", Rc::strong_count(&shared_data));

    {
        // reference2 也共享所有權
        let reference2 = Rc::clone(&shared_data);
        println!("Count after second clone: {}", Rc::strong_count(&shared_data));
    } // reference2 離開作用域,計數減 1

    println!("Count after reference2 goes out of scope: {}", Rc::strong_count(&shared_data));
}

重要限制Rc 的計數器不是「原子」的,它在多執行緒環境下會出錯。
這是故意設計的,目的是讓你不必為你用不到的功能(執行緒安全)支付效能代價。

https://ithelp.ithome.com.tw/upload/images/20250925/201244625T1XjFOXKe.png

  • 共享所有權: 在堆疊上的三個不同變數 (shared_data, reference1, reference2) 都指向堆上的同一塊記憶體。

  • 參考計數: Rc<T> 的核心機制。在堆上的資料旁,有一個計數器。每當 Rc::clone() 被呼叫,計數器就 +1。當任何一個指標離開作用域時,計數器就 -1

  • 自動清理: 當參考計數變為 0 時,代表已經沒有任何指標指向這份資料,Rust 會自動釋放這塊堆記憶體。

問題三:如何在「多執行緒」之間『唯讀』共享資料?

如果你試圖把 Rc 丟進另一個執行緒,編譯器會直接拒絕你。這是好事,它阻止你犯下愚蠢的錯誤。

解法:Arc<T> — 原子性的參考計數

Arc<T> (Atomically Reference Counted) 就是 Rc<T> 的多執行緒版本。
API 幾乎一樣,唯一的、也是最重要的區別是:Arc 使用原子操作來增減參考計數。

原子操作確保了即使多個執行緒同時 clonedrop,計數器也不會出錯。這是執行緒安全的保證,當然也會帶來一點點效能開銷。你為安全付費。

use std::sync::Arc;
use std::thread;

fn main() {
    let shared_data = Arc::new(vec![1, 2, 3]);

    let mut handles = vec![];
    for i in 0..3 {
        let data_clone = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            println!("Thread {}: sees data {:?}", i, data_clone);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

結論:Rc 用於單執行緒,Arc 用於多執行緒。它們解決了唯讀資料的共享問題。

https://ithelp.ithome.com.tw/upload/images/20250925/201244624FvBS6By3E.png

  • 跨執行緒共享: 圖中的 Main ThreadThread 1Thread 2 代表不同的執行緒。Arc<T> 允許這三個執行緒安全地共同擁有並存取堆上的同一份資料。

  • 原子性計數: 與 Rc 的主要區別在於,Arc 的參考計數是原子性的。這表示即使多個執行緒同時嘗試增加或減少計數,計數器也能保證不會出錯,避免了資料競爭。這是它執行緒安全的關鍵。

共享與可變性

上面所有的例子都有一個共同點:共享的資料是不可變的。
現實世界沒這麼美好,我們經常需要在共享的同時修改資料。

如果你直接嘗試修改 Arc<T> 裡面的東西,編譯器會再次阻止你。
為什麼?
因為如果多個執行緒能同時修改同一塊資料,就會產生「資料競爭」(Data Race),這是萬惡之源。

為了解決這個問題,需要引入「內部可變性」(Interior Mutability)的概念,並使用同步工具。

情況 A:單執行緒下的共享與可變 -> Rc<RefCell<T>>

在單執行緒中,我們不需要 Mutex 那樣的重型武器,但仍然需要一種機制來確保在任何時候,要嘛只有一個可變引用,要嘛有多個不可變引用
RefCell<T> 把編譯器的靜態借用檢查,變成了執行期的動態檢查

  • borrow():取得一個不可變引用。如果已經存在可變引用,則在執行期 panic
  • borrow_mut():取得一個可變引用。如果已經存在任何其他引用(可變或不可變),則在執行期 panic
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    // 使用 RefCell 包裹我們想要修改的資料
    let shared_data = Rc::new(RefCell::new(vec![1, 2, 3]));

    let data1 = Rc::clone(&shared_data);
    let data2 = Rc::clone(&shared_data);

    // 我們可以透過 RefCell 修改資料
    data1.borrow_mut().push(4);
    
    // 我們也可以讀取資料
    println!("Data after modification: {:?}", data2.borrow());
}

情況 B:多執行緒下的共享與可變 -> Arc<Mutex<T>>

這是在併發程式設計中最常見的模式之一。Mutex<T> (Mutual Exclusion) 就像一把鎖。

  • Mutex 是一個鎖:它確保在任何時間點,只有一個執行緒能存取被它保護的資料。

  • lock() 方法:當一個執行緒呼叫 lock(),它會請求鑰匙。如果鑰匙可用,它就拿到鑰匙並鎖上門,開始操作資料。如果鑰匙被別的執行緒拿走了,它就必須等待,直到鑰匙被歸還。

  • 鎖衛士 (Lock Guard)lock() 會回傳一個「鎖衛士」。當這個衛士離開作用域時,鎖會被自動釋放。這極大地避免了忘記解鎖導致的死鎖問題。

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

fn main() {
    // Mutex 保護著我們的計數器
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // 請求鎖,如果被佔用就等待
            // lock() 回傳一個 Result,我們 unwrap 它
            let mut num = counter_clone.lock().unwrap();
            
            *num += 1;
            
        }); // 當 `num` (鎖衛士) 離開作用域,鎖會自動釋放
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap()); // 最終結果是 10
}

RwLock<T>Mutex<T> 的一個變種,它允許「多個讀者」或「一個寫者」,適用於讀多寫少的場景。

https://ithelp.ithome.com.tw/upload/images/20250925/20124462pI8fp8t4na.png

  • 結構: Arc 負責讓不同執行緒都能「看到」同一個 MutexMutex 則像一個保險箱,把真正的資料 0 保護在裡面。

  • 互斥 (Mutual Exclusion):

    • 執行緒 1 呼叫了 lock() 並成功取得了鎖。它得到一個 MutexGuard (鎖衛士,圖中的 num),只有透過這個衛士才能存取並修改資料。

    • 此時,執行緒 2 也想呼叫 lock(),但因為鎖已經被執行緒 1 持有,它就必須等待 (Blocked),直到執行緒 1 結束操作。

  • 自動解鎖: 當 num (鎖衛士) 在執行緒 1 中離開作用域時,鎖會自動釋放,這時執行緒 2 才能獲得鎖並繼續執行。這確保了任何時候都只有一個執行緒能修改資料。

總結與決策指南

可以按照這個思路來思考:
https://ithelp.ithome.com.tw/upload/images/20250925/20124462lpWdSFAqo9.png
這才是完整的 picture。
Rust 提供了不同層級的工具,每種工具都有其特定的使用場景和效能開銷。

相關連結與參考資源

Rust 官方文件


上一篇
(Day10) Rust 錯誤處理:Result 與 Option
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言