Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

記憶體管理與 RAII

C++ 記憶體管理與 RAII(進階)

拆開 shared_ptr 的控制區塊、看清原子計數的硬體代價,從 Rule of Five/Rule of Zero、自訂刪除器,一路深入 placement new、對齊與 C++17 多型記憶體資源(PMR)——理解 RAII「歸還資源」背後的整套機制棧。

一個 shared_ptr 為什麼比裸指標「胖」兩倍?

你已經知道 std::unique_ptr 的大小跟一個裸指標一樣大,是名副其實的零成本抽象。那麼換成 std::shared_ptr 呢?在 64 位元平台上,一個裸指標是 8 個位元組,但 sizeof(std::shared_ptr<int>) 通常是 16 個位元組——整整大了一倍。這多出來的 8 個位元組藏著什麼?為什麼參考計數要放在「另一塊」記憶體裡,而不是緊貼著物件本身?當你寫 auto b = a; 把計數加一時,這個加法是執行緒安全的嗎?它的成本又是多少?

入門篇告訴你「shared_ptr 內部維護一個參考計數」。這一篇要把那層包裝拆開,看清楚控制區塊(control block)的真實佈局、原子計數的硬體成本、weak_ptr 如何在物件死後仍安全存活,以及為什麼一個看似無害的 make_shared 反而可能讓記憶體「死不瞑目」。我們也會走進入門篇沒碰的領域:自訂刪除器(custom deleter)、Rule of Five 與 Rule of Zero 的權衡、placement new 與對齊(alignment),以及 C++17 的多型記憶體資源(PMR)。讀完你會明白,C++ 的記憶體管理不只是「記得 delete」,而是一整套關於所有權拓樸資源生命週期工程的設計學問。

記憶體管理與 RAII進階概念示意圖

控制區塊:shared_ptr 的隱形孿生兄弟

一個 shared_ptr<T> 在記憶體裡其實握著兩個指標:一個指向受管物件 T,另一個指向所謂的控制區塊(control block)。控制區塊是一塊獨立配置的小結構,至少包含兩個計數器:

  • 強參考計數(strong count / use_count):有多少個 shared_ptr 共同擁有這個物件。歸零時,物件被解構。
  • 弱參考計數(weak count):有多少個 weak_ptr 觀察這個物件,再加上「強計數是否大於零」這個邏輯上的 +1。歸零時,控制區塊本身才被釋放。
#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> sp = std::make_shared<int>(42);
    std::weak_ptr<int>   wp = sp;

    std::cout << "use_count = " << sp.use_count() << "\n";  // 1
    std::cout << "expired?  " << wp.expired()     << "\n";  // 0(還活著)

    sp.reset();   // 強計數歸零 → 物件解構;但控制區塊還在(wp 仍指著)
    std::cout << "after reset, expired? " << wp.expired() << "\n";  // 1
    // wp 析構時弱計數歸零 → 控制區塊釋放
    return 0;
}

這個「兩段式釋放」是 weak_ptr 能安全運作的關鍵。物件死了,控制區塊還在;weak_ptr::expired() 只要去讀控制區塊裡的強計數是否為零,就能在不解參考已死物件的前提下判斷物件還在不在。wp.lock() 則嘗試把強計數從非零原子地加一,成功就回傳一個有效的 shared_ptr、失敗就回傳空指標——這是檢查與升級的不可分割操作,比「先 expired 再 lock」更正確。

make_shared 的甜蜜與陷阱

入門篇建議你用 std::make_shared<T>(...) 而非 shared_ptr<T>(new T(...))。原因之一是效能:make_shared物件控制區塊配置在同一塊連續記憶體裡,只呼叫一次配置器(allocator),而 shared_ptr<T>(new T) 要配置兩次(一次給物件、一次給控制區塊)。一次配置不只更快,也讓兩者在快取上相鄰。

但這個優化藏著一個反直覺的後果。既然物件與控制區塊綁在同一塊記憶體,那麼只要還有任何一個 weak_ptr 存活,整塊記憶體就無法釋放——即使物件本身早已解構。物件佔的那幾百個位元組會被一個孤零零的 weak_ptr 扣留著:

struct Huge { char payload[1 << 20]; };   // 1 MB 的大物件

std::weak_ptr<Huge> leak_observer() {
    auto sp = std::make_shared<Huge>();    // 物件 + 控制區塊同一塊
    return sp;                              // sp 死亡,強計數歸零,物件解構…
}                                          // …但若回傳的 weak_ptr 還活著,
                                           // 這 1 MB 不會還給系統!

如果你預期會有長壽的 weak_ptr 觀察一個大物件,反而該用 shared_ptr<T>(new T) 的兩段配置,讓物件記憶體能在強計數歸零時立刻獨立釋放。這是少數 make_shared 不是最佳解的場景——一個只有理解了控制區塊佈局才看得懂的取捨。

原子計數的真實成本:為什麼 shared_ptr 不能濫用

入門篇說「維護參考計數有成本」,但沒說成本到底在哪。關鍵字是原子操作(atomic operation)

shared_ptr 的強計數必須是執行緒安全的:兩條執行緒可能同時複製同一個 shared_ptr,計數的加一不能遺失。因此標準規定計數的增減是原子的——底層用的是 std::atomicfetch_add / fetch_sub,對應到硬體的 LOCK XADD(x86)或 LL/SC(ARM)指令。

原子遞增比普通的 ++count 貴得多。普通遞增可能只是一個暫存器運算;原子遞增則要求記憶體屏障(memory barrier)與快取一致性協定的介入。在多核心系統上,如果多條執行緒頻繁複製指向同一物件的 shared_ptr,那個計數器所在的快取行(cache line)會在各核心間來回彈跳(cache-line bouncing),造成嚴重的爭用。

// 反例:在熱迴圈裡按值傳遞 shared_ptr,每次傳參都做兩次原子操作
void process(std::shared_ptr<Data> d);   // 進入:強計數 +1;離開:−1

for (int i = 0; i < 10'000'000; ++i)
    process(shared_data);                // 兩千萬次原子操作!

// 正解:傳 const 參考,不碰計數
void process(const std::shared_ptr<Data>& d);   // 零原子操作
// 更好:如果函式根本不參與所有權,就傳裸的觀察指標
void process(const Data& d);             // 連 shared_ptr 都不需要看見

這帶出一條現代 C++ 的所有權守則:用智慧指標表達「所有權」,用裸指標或參考表達「借用」。 一個只是要「看一眼」資料、不延長其生命週期的函式,不該收 shared_ptr 甚至 unique_ptr——它該收 const T&T*。把所有權與觀察區分開,既避免不必要的原子成本,也讓介面誠實地說出「我會不會延長你的壽命」。

值得強調的是:shared_ptr計數是執行緒安全的,但它指向的物件不是。兩條執行緒各自持有 shared_ptr 副本、同時呼叫物件的非 const 方法,仍然是資料競爭(data race)。控制區塊的原子性只保護「誰來釋放」,不保護「誰來讀寫資料」。

Rule of Five 與 Rule of Zero:誰該寫解構函式?

入門篇展示了一個自寫 RAII 的 IntArray,它有建構子與解構子。但那個類別其實埋了一顆地雷:它能被複製嗎?

C++ 編譯器會自動為類別合成複製建構子與複製賦值運算子,預設行為是逐成員複製(member-wise copy)。對 IntArray 而言,這意味著複製時只把指標 data_ 照抄一份——兩個物件現在指向同一塊堆積記憶體。當它們先後解構,同一塊記憶體會被 delete[] 兩次,這就是入門篇警告過的「重複釋放(double free)」。

IntArray a(5);
IntArray b = a;   // 預設複製:b.data_ == a.data_(淺複製!)
// a 與 b 解構時都會 delete[] 同一個位址 → double free,未定義行為

只要你的類別手動管理了一項資源(自己持有裸指標、檔案描述子、互斥鎖…),編譯器合成的那些特殊成員函式幾乎一定是錯的。這催生了 Rule of Five(五法則):一旦你需要自訂以下五個特殊成員函式之中的任何一個,通常就該慎重考慮其餘四個:

  1. 解構函式 destructor
  2. 複製建構子 copy constructor
  3. 複製賦值運算子 copy assignment
  4. 移動建構子 move constructor
  5. 移動賦值運算子 move assignment

看一個例子:把 IntArray 補成正確的 Rule of Five

#include <algorithm>
#include <cstddef>

class IntArray {
    int*   data_;
    size_t size_;
public:
    explicit IntArray(size_t n) : data_(new int[n]{}), size_(n) {}

    ~IntArray() { delete[] data_; }                      // (1) 解構

    IntArray(const IntArray& o)                          // (2) 複製建構:深複製
        : data_(new int[o.size_]), size_(o.size_) {
        std::copy(o.data_, o.data_ + o.size_, data_);
    }

    IntArray& operator=(const IntArray& o) {             // (3) 複製賦值(copy-and-swap)
        IntArray tmp(o);                                 // 先做一份安全的副本
        swap(tmp);                                       // 再與自己交換
        return *this;                                    // tmp 帶走舊資源一起解構
    }

    IntArray(IntArray&& o) noexcept                      // (4) 移動建構:偷指標
        : data_(o.data_), size_(o.size_) {
        o.data_ = nullptr;                               // 把來源置空,避免雙重釋放
        o.size_ = 0;
    }

    IntArray& operator=(IntArray&& o) noexcept {         // (5) 移動賦值
        if (this != &o) {
            delete[] data_;                              // 先還掉自己手上的
            data_ = o.data_;  size_ = o.size_;
            o.data_ = nullptr; o.size_ = 0;
        }
        return *this;
    }

    void swap(IntArray& o) noexcept {
        std::swap(data_, o.data_);
        std::swap(size_, o.size_);
    }
    int& operator[](size_t i) { return data_[i]; }
    size_t size() const { return size_; }
};

兩個值得深究的細節。第一,複製賦值用了 copy-and-swap 慣用法:先複製出一個臨時物件 tmp,若這一步因記憶體不足丟出例外,*this 完全沒被動過——這天然提供了入門篇深入段提到的強例外保證(strong guarantee)swapnoexcept 的,交換後 tmp 在解構時帶走舊資源。一個函式同時處理了複製、釋放舊資源、自我賦值(a = a 也安全)與例外安全,極其優雅。

第二,移動函式標了 noexcept。這不只是裝飾。std::vector 在擴容、需要把舊元素搬到新緩衝區時,會檢查元素的移動建構子是否 noexcept:若是,它移動($O(1)$ 每個元素);若移動可能丟例外,為了維持強保證它只好退而複製($O(n)$ 每個元素,且不偷不搶以便出錯時回滾)。漏標 noexcept 會讓你的型別在 vector 裡悄悄損失一大截效能。

Rule of Zero:更高明的「什麼都不寫」

Rule of Five 教你正確地寫那五個函式。但更高的境界是 Rule of Zero(零法則)設計類別時,盡量讓你一個都不必寫。

怎麼做到?把資源管理委派給已經正確實作了 Rule of Five 的成員——也就是標準容器與智慧指標。如果 IntArray 內部用 std::vector<int>std::unique_ptr<int[]> 而非裸指標,編譯器自動合成的解構、複製、移動就全部正確,因為它們會逐成員地呼叫成員自己正確的版本:

#include <vector>

class IntArray {
    std::vector<int> data_;        // 由 vector 負責所有資源管理
public:
    explicit IntArray(size_t n) : data_(n) {}
    // 不需要解構、複製、移動——編譯器合成的全都對!
    int& operator[](size_t i) { return data_[i]; }
    size_t size() const { return data_.size(); }
};

這就是現代 C++ 的核心審美:手動管理資源的程式碼應該集中在極少數精心打造的 RAII 包裝裡(標準庫已經幫你寫好絕大多數),其餘所有類別都該是 Rule of Zero。 你寫的解構函式越少,能出錯的地方就越少。

自訂刪除器:當「釋放」不只是 delete

入門篇的智慧指標都假設資源是「new 出來的堆積記憶體、用 delete 歸還」。但真實世界的資源五花八門:C 函式庫回傳的 FILE* 要用 fclose 關、malloc 的記憶體要用 free 還、作業系統的 handle 要呼叫特定 API 釋放。智慧指標允許你指定自訂刪除器(custom deleter),把任意「清理動作」納入 RAII。

#include <memory>
#include <cstdio>

int main() {
    // 用 unique_ptr 管理一個 FILE*,離開作用域自動 fclose
    auto closer = [](std::FILE* f) { if (f) std::fclose(f); };
    std::unique_ptr<std::FILE, decltype(closer)> fp(
        std::fopen("data.txt", "r"), closer);

    if (fp) {
        // ... 讀檔 ...
    }   // fp 解構 → 自動呼叫 closer → fclose,即使中途丟例外也保證關檔
    return 0;
}

這裡有個值得注意的型別細節:unique_ptr 的刪除器型別是模板參數的一部分unique_ptr<FILE, decltype(closer)>),刪除器若是無捕獲的函式物件,unique_ptr 不會因此變大——刪除器型別在編譯期就確定。但 shared_ptr 不同:它的刪除器是型別抹除(type-erased)的,存在控制區塊裡,所以 shared_ptr<FILE> 不論用什麼刪除器,大小都不變,刪除器資訊在執行期才被取用:

std::shared_ptr<std::FILE> sp(
    std::fopen("data.txt", "r"),
    [](std::FILE* f){ if (f) std::fclose(f); });   // 刪除器藏進控制區塊

這個差異不是偶然,而是兩種智慧指標設計哲學的縮影:unique_ptr 把一切推到編譯期、追求零成本;shared_ptr 為了共享與型別抹除的彈性,接受一點執行期間接性。理解了控制區塊,你才看得懂為什麼同樣是「自訂刪除器」,一個進型別、一個進物件。

重點回顧

  • shared_ptr 內含兩個指標——指向物件與指向控制區塊;控制區塊藏著強計數與弱計數,採「物件先死、控制區塊後亡」的兩段式釋放,這是 weak_ptr 安全的根基。
  • 參考計數是原子操作,在多核心熱路徑上會造成快取行爭用;用智慧指標表達所有權、用 const& 或裸指標表達借用,能避開不必要的原子成本。
  • Rule of Five:一旦手動管理資源,五個特殊成員函式要一起照顧;移動函式務必標 noexcept,否則在 vector 中會退化為複製。
  • Rule of Zero 才是目標:把資源委派給標準容器與智慧指標,讓編譯器合成的特殊成員全部自動正確。
  • 自訂刪除器讓 RAII 涵蓋 FILE*、OS handle 等非記憶體資源;unique_ptr 把刪除器放進型別、shared_ptr 放進控制區塊。

深入探討(研究所視角)

配置器、placement new 與對齊

到目前為止我們把「配置記憶體」和「在記憶體上建構物件」混為一談——new T 其實是兩個動作的合體:先呼叫 operator new 取得一塊未初始化的原始記憶體,再在那塊記憶體上呼叫 T 的建構子。標準容器之所以能做到「配置了容量卻還沒建構元素」(vector::reserve),正是因為它把這兩步拆開了。

拆開的工具是 placement new——在「一塊已存在的記憶體」上建構物件,不再配置:

#include <new>        // placement new
#include <cstdlib>

struct Point { double x, y; Point(double a, double b): x(a), y(b) {} };

int main() {
    // 1. 取得原始記憶體(未初始化)
    void* raw = std::malloc(sizeof(Point));
    // 2. 在這塊記憶體上「就地」建構物件
    Point* p = new (raw) Point(3.0, 4.0);   // placement new,不配置、只建構
    // 3. 手動解構(placement new 沒有對應的 placement delete)
    p->~Point();                            // 顯式呼叫解構子
    // 4. 歸還原始記憶體
    std::free(raw);
    return 0;
}

這是標準庫配置器(std::allocator)與 std::vector 內部真正在做的事:用 allocate 取得原始記憶體、用 construct(placement new)逐一就地建構元素、用 destroy(顯式解構)逐一摧毀、用 deallocate 歸還。把配置與建構解耦,容器才能精準控制「何時付出建構成本」、實現攤銷 $O(1)$ 的 push_back

這裡潛伏著一個常被忽略的正確性議題:對齊(alignment)。每個型別都有對齊需求——例如 double 通常要求位址是 8 的倍數,SIMD 向量型別可能要求 16 或 32 的倍數。在未對齊的位址上建構物件是未定義行為,在某些架構上會直接觸發硬體例外。std::malloc 只保證滿足「基本對齊」,對於過度對齊(over-aligned)的型別必須改用 C++17 的 std::aligned_alloc 或對齊版的 operator newalignof(T) 查得到需求,alignas 指定得了需求,自訂配置器時兩者缺一不可。

多型記憶體資源(PMR):把配置策略變成執行期參數

傳統的 std::vector<int, MyAllocator> 把配置器塞進模板參數,意味著 vector<int, A>vector<int, B> 是兩個不同的型別,不能互相賦值、不能放進同一個容器。這在工程上很僵硬:你無法寫一個函式收「任意配置策略的 vector」。

C++17 的 多型記憶體資源(polymorphic memory resource, PMR) 用一層執行期多型解決了這個矛盾。std::pmr::vector<int> 的配置器型別是固定的(std::pmr::polymorphic_allocator<int>),真正的配置策略則藏在一個執行期可替換的 std::pmr::memory_resource* 指標背後:

#include <memory_resource>
#include <vector>
#include <array>

int main() {
    // 在「堆疊上」開一塊 4 KB 緩衝區當作記憶體來源
    std::array<std::byte, 4096> buffer;
    std::pmr::monotonic_buffer_resource pool{buffer.data(), buffer.size()};

    // 這個 vector 的所有配置都從上面那塊堆疊緩衝區切出來——零 malloc!
    std::pmr::vector<int> v{&pool};
    for (int i = 0; i < 100; ++i) v.push_back(i);   // 不碰系統堆積
    return 0;
}

monotonic_buffer_resource 是一種「只配置、不個別釋放」的策略:所有配置從預備好的緩衝區依序切出,直到資源物件本身解構才一次性整批歸還。對「建立大量短命物件、然後同時全部丟棄」的工作負載(解析器、遊戲單幀的暫存物件、編譯器的 AST 節點),這種 arena / monotonic 配置可以把成千上萬次 malloc/free 縮減成一次,並徹底消除記憶體碎片。其他 PMR 資源如 unsynchronized_pool_resource(同尺寸物件的池化)、synchronized_pool_resource(執行緒安全版本)各有適用場景。

PMR 的設計把 C++ 記憶體管理推到一個更高的抽象層:配置策略成為一個可組合、可注入的執行期物件,而非綁死在型別裡的編譯期參數。memory_resource 還能鏈接(chaining)——一個 pool 資源耗盡時可向上游(upstream)資源索取,預設上游是 new_delete_resource()。這套設計與作業系統的多級記憶體配置器、自訂遊戲引擎的 arena allocator 在思想上一脈相承。

把這條進階線索收束起來:入門篇教你「讓 RAII 保證資源被歸還」,這一篇則讓你看見歸還背後的整個機制棧——控制區塊如何協調共享所有權、原子計數的硬體代價、Rule of Five/Zero 如何把所有權語意編碼進型別系統,以及配置器與 PMR 如何把「記憶體從哪來、何時還」變成一等公民的設計決策。C++ 之所以能在自動安全與手動極致之間自由滑動,正是因為它把這每一層都攤開讓你掌控——當你需要時。

AI 共讀助教正在陪你讀:C++ 記憶體管理與 RAII(進階)
嗨!我是這篇文章的共讀助教,只根據〈C++ 記憶體管理與 RAII(進階)〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。