Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

記憶體管理與 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(進階)〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。