記憶體回收策略 (Memory Reclamation Strategies)

Overview Table

策略 核心機制 弱點
(1) 執行緒計數 + 待刪鏈 threads_in_pop 原子計數;不能刪的節點掛到 to_be_deleted 串列,計數歸 1 時嘗試回收 高負載下計數幾乎不為 0 → 待刪鏈無限成長(等同洩漏)
(2) hazard pointer(風險指標) 讀者先在全域表宣告「我正在用這個指標」;刪除者掃表,有人用就延後回收 每次 pop 都要掃描整個 hazard 表(原子操作昂貴);受 IBM 專利限制
(3) 引用計數 每節點統計訪問中的執行緒數;外部/內部雙計數器,總和歸零才刪 atomic<counted_node_ptr> 需平台支援雙字 CAS,否則退化為有鎖

共同思想:三種技術都是在追蹤「有多少執行緒正在訪問某個節點」,確認為零才真正 delete。刪除節點前必須確認沒有其他執行緒還持有它的指標——這是無鎖結構與有鎖結構最大的差異(有 GC 的語言沒有此問題)。

策略一:執行緒計數 + 待刪鏈(代碼 7.4–7.5)

try_reclaim(old_head) 的決策流程:

threads_in_pop == 1 ?
 ├─ 是 → nodes = to_be_deleted.exchange(nullptr)  ← 原子「認領」整條待刪鏈
 │        --threads_in_pop 後 == 0 ?
 │         ├─ 是 → delete_nodes(nodes)   ← 無人在 pop,整鏈安全刪除
 │         └─ 否 → 把 nodes 重新掛回待刪鏈(有人半路進來了)
 │        delete old_head               ← 我是唯一 pop 者,剛摘的節點可直接刪
 └─ 否 → chain_pending_node(old_head);--threads_in_pop
只適合低負載

高負載時永遠有執行緒在 pop() 裡,threads_in_pop 不會歸 1/0,to_be_deleted 無限增長——實質上還是洩漏。「必須等所有 pop 者離開」條件太強,需要更精準的機制:只追蹤「誰在用這一個節點」。

策略二:hazard pointer(風險指標,代碼 7.6–7.8)

源自 Maged Michael 的研究。核心約定:讀者訪問節點前先設定 hazard pointer 昭告天下;刪除者刪除前檢查所有 hazard pointer,有人引用就延後。(類比:龍舟賽開賽前,任一舵手舉手,裁判就不能鳴槍。)

讀取執行緒 (pop)                           刪除執行緒
----------------                           ----------
old_head = head.load()
   ┌─────────────────────┐
   │ hp.store(old_head)  │──宣告────▶ ┌─────────────────────┐
   │ head 變了?          │            │ hazard pointer 全域表 │
   │  ├ 是 → 重讀並重設hp │            │ [tid | 指標] × N     │
   │  └ 否 → 跳出         │            └─────────────────────┘
   └─────────────────────┘                     ▲
安全解引用 old_head                            │掃描
CAS 摘下節點成功                               │
hp.store(nullptr)  ←── 解除宣告        欲刪 p:表中有 p?
                                        ├ 有 → reclaim_later(p)  → 待回收鏈
                                        └ 無 → delete p
                                       之後呼叫 delete_nodes_with_no_hazards()
                                       重掃待回收鏈,能刪的刪、不能刪的放回

get_hazard_pointer_for_current_thread() 的實作(代碼 7.7):

unsigned const max_hazard_pointers = 100;
struct hazard_pointer {                       // 全域固定長度表
    std::atomic<std::thread::id> id;
    std::atomic<void*> pointer;
};
hazard_pointer hazard_pointers[max_hazard_pointers];

std::atomic<void*>& get_hazard_pointer_for_current_thread() {
    thread_local static hp_owner hazard;      // 每執行緒一個槽位擁有者
    return hazard.get_pointer();
}

成本與改良:

專利問題

hazard pointer 技術受 IBM 專利申請涵蓋;GPL 自由軟體等情境可能豁免,商用需確認授權。這也是引用計數方案(無專利限制)存在的理由之一。已有提案將 hazard pointer 納入未來 C++ 標準。

策略三:引用計數(代碼 7.9–7.12)

hazard pointer 把「使用中的節點」記在全域表;引用計數則在每個節點上統計訪問它的執行緒數

捷徑:std::shared_ptr<>——若平台的 std::atomic_is_lock_free(&some_shared_ptr) 回傳 true,直接用 std::shared_ptr<node> + std::atomic_load/atomic_compare_exchange_weak 寫出的堆疊(代碼 7.9)就是無鎖的,回收全自動。並行技術規範擴展另提供 std::experimental::atomic_shared_ptr<T>(代碼 7.10),語義更直覺。

shared_ptr 的原子操作很少是無鎖的

多數實作中 std::shared_ptr 的原子操作內部用鎖;atomic_shared_ptr 也不保證無鎖。此路不通時,就得手動管理引用計數

外部/內部雙計數器設計(split reference count):

struct counted_node_ptr {      // 跟著「指標」走的外部計數
    int external_count;
    node* ptr;
};
struct node {
    std::shared_ptr<T> data;
    std::atomic<int> internal_count;   // 跟著「節點」走的內部計數
    counted_node_ptr next;
};
std::atomic<counted_node_ptr> head;    // 整個結構體做原子 CAS
計數器 位置 時機
external_count counted_node_ptr 內,隨指標(head/next)存放 每次讀取指標(準備解引用)前 +1(increase_head_count 以 CAS 對整個結構體累加)
internal_count node 內 執行緒結束訪問節點時 −1;外部計數器棄用時把「external−2」併入
刪除條件 兩計數總和為 0(實作上:internal_count 歸 0)
std::atomic<counted_node_ptr> 要無鎖有前提

counted_node_ptr 必須小到能放進平台的原子指令(如雙字比較交換 DWCAS);否則 std::atomic<>改用內部鎖,整個「無鎖」演算法就變成基於鎖。指標位元有剩時(如 64 位指標實際只用 48 位)可把計數塞進指標同一機器字,但屬平台特定技巧。

三策略比較

面向 待刪鏈 + 計數 hazard pointer 引用計數(雙計數器)
追蹤粒度 「有無執行緒在 pop」(全域) 「哪個執行緒在用哪個指標」 「每個節點被幾個執行緒用」
高負載表現 差(待刪鏈無限成長) 好,但 pop 常數成本高(掃表) 好;熱點在 head 的 CAS
實作複雜度 中(全域表、槽位管理、型別抹除刪除器) 高(雙計數合併、−2 推理)
其他限制 IBM 專利;max_hazard_pointers 上限 需 DWCAS 否則退化有鎖

Exam/Test Patterns

情境/關鍵字 答案
「什麼時候刪節點才安全?」 確認沒有其他執行緒持有該節點指標時(三種策略都是在追蹤這件事)
「threads_in_pop 方案高負載會怎樣?」 計數永不歸 1,to_be_deleted 無限成長(等同洩漏)
「為何 try_reclaim 要檢查兩次計數?」 首檢與 exchange 認領之間可能有新 pop 者已持有待刪節點指標
「hazard pointer 設定後為何要重讀 head?」 防止設定前節點已被摘除刪掉;head 變了就重來,直到 hp 與 head 一致
「hazard 版 pop 為何用 compare_exchange_strong?」 weak 偽失敗會白白重設 hazard pointer,成本高
「get_hazard_pointer_for_current_thread 怎麼配槽?」 全域「id-指標」表 + thread_local hp_owner,以 CAS 認領空槽,解構時歸還
「external_count 何時 +1?internal_count 何時 −1?」 讀取指標時外部 +1;結束訪問時內部 −1;總和 0 才刪
「fetch_add(external_count − 2) 的 −2?」 節點離開串列 −1 + 本執行緒不再訪問 −1
「counted_node_ptr 太大會怎樣?」 std::atomic<> 退化為內部用鎖,演算法不再無鎖(需 DWCAS 支援)
「哪個策略有專利問題?」 hazard pointer(IBM);引用計數無此限制