執行緒安全查詢表與鏈結串列 (Lookup Table and Linked List)

Overview Table

概念 重點
查詢表介面削減 繞過迭代器(迭代器引用的元素可能被他人刪除);「新增」與「修改」合併為一個函式
底層結構選擇 二元樹(根節點瓶頸)✗、有序陣列(整體鎖)✗、雜湊表(每桶一把鎖)✓
threadsafe_lookup_table(代碼 6.11) 固定桶數(預設 19,質數)+ 每桶一把 std::shared_mutex → 併發度 ×N
get_map() 快照(代碼 6.12) 鎖住所有桶;以固定順序(遞增索引)上鎖 → 不死結
threadsafe_list(代碼 6.13) 每節點一把 mutex;push_front 只鎖 head
交錯上鎖 (hand-over-hand locking) 先鎖下一節點、再放開當前節點 → 上鎖順序固定,無死結;執行緒不能超車

執行緒安全查詢表:介面設計

查詢表(字典)把鍵值 (key) 映射到資料 (value),對應 std::map<>std::unordered_map<> 等。與堆疊/佇列不同:幾乎每個操作都修改堆疊/佇列,而查詢表讀多寫少

為細粒度鎖選擇底層結構

候選結構 細粒度鎖可行性
二元樹(如紅黑樹) ✗ 每次查找/修改都從根節點開始 → 根節點鎖成瓶頸,不比單鎖好多少
有序陣列 最糟:無法預知目標在哪一段,只能鎖整個陣列
雜湊表 ✓ 固定數量的桶,鍵屬於哪個桶由雜湊函式決定 → 每個桶可安全地各自上鎖

每桶用一把 std::shared_mutex(多讀者/單寫者),併發度提升 N 倍(N = 桶數)。雜湊函式可用 std::hash<> 模板(可特化),或仿標準無序容器把雜湊函式型別當作模板參數。

代碼 6.11:threadsafe_lookup_table

結構:std::vector<std::unique_ptr<bucket_type>> buckets,每個 bucket_type 內含一個 std::list<std::pair<Key, Value>>mutable std::shared_mutex mutex

Value value_for(Key const& key, Value const& default_value) const {
    std::shared_lock<std::shared_mutex> lock(mutex);   // 讀:共享鎖
    bucket_iterator const found_entry = find_entry_for(key);
    return (found_entry == data.end()) ? default_value
                                       : found_entry->second;
}
// add_or_update_mapping / remove_mapping 則用:
// std::unique_lock<std::shared_mutex> lock(mutex);    // 寫:獨占鎖

異常安全分析

操作 分析
value_for 不修改任何資料,拋異常也不影響結構 → 安全
remove_mapping erase 保證不拋異常 → 安全
add_or_update_mappingpush_back 異常安全:拋出時串列恢復原狀
add_or_update_mapping 之賦值(替換既有值) 唯一疑慮:賦值拋異常時原始值未變,結構整體不受影響,問題交給使用者的型別處理

代碼 6.12:取得整表快照 get_map()

把整個查詢表複製成 std::map<Key, Value> 需要鎖住所有桶(一般操作只需鎖一個桶,此操作要求獨占整個容器):

執行緒安全鏈結串列:每節點一把鎖

支援迭代的鏈結串列若提供 STL 式迭代器,迭代器需持有容器內部引用──容器被其他執行緒修改時引用必須仍有效,實際上等於要迭代器持有鎖,非常糟糕。替代方案:把迭代做成容器的成員函式(如 for_each),由容器負責上鎖。

代價:持鎖呼叫使用者代碼

for_each 等必須在持有內部鎖時呼叫使用者提供的函式,且要把元素引用傳給它(資料大時拷貝代價高)。避免死結(使用者操作又去取鎖)與避免存儲引用造成競爭條件的責任,交給使用者承擔。查詢表(6.11/6.12)的用法已知安全,故可放心使用。

需要的操作:push_front(加入元素)、remove_if(條件刪除)、find_first_if(條件查找)、for_each(逐元素處理/更新、可複製到其他容器)。指定位置插入等功能查詢表用不到,故省略。

細粒度鎖思路:每個節點都擁有一把互斥鎖(串列長 → 鎖很多)。好處:串列的不同部分可真正並行操作;持有「感興趣的節點」的鎖,移往下一節點時釋放當前的鎖。

代碼 6.13:threadsafe_list 結構與交錯上鎖

節點:struct node { std::mutex m; std::shared_ptr<T> data; std::unique_ptr<node> next; };,以一個預設建構的 node 作為 head(data 為空,類似 dummy)。

交錯上鎖 (hand-over-hand locking) 的迭代模式(for_each 核心):

      [head]      [A]        [B]        [C]
步驟1  lock ─────────────────────────────────  持 head
步驟2  lock       lock                          先鎖 A(下一個)
步驟3  unlock     hold                          再放 head
步驟4             hold  f(A) lock               處理 A;鎖 B
步驟5             unlock     hold  f(B) ...     放 A;處理 B ...
node* current = &head;
std::unique_lock<std::mutex> lk(head.m);
while (node* const next = current->next.get()) {
    std::unique_lock<std::mutex> next_lk(next->m); // 先鎖下一個
    lk.unlock();                                   // 再放當前的
    f(*next->data);
    current = next;
    lk = std::move(next_lk);                       // 鎖的所有權交接
}

安全性與併發度分析


Exam/Test Patterns

情境/關鍵字 答案
「為何執行緒安全查詢表要去掉迭代器?」 迭代器引用的元素可能被其他執行緒刪除;支持迭代器等於要求迭代器持有鎖
「新增與修改為何合併成 add_or_update_mapping?」 消除兩執行緒同時「檢查後新增」的介面競爭條件
「細粒度鎖為何選雜湊表而非二元樹/有序陣列?」 二元樹根節點鎖是瓶頸;有序陣列只能整體鎖;雜湊表每桶獨立上鎖,併發度 ×N
「桶數為何預設 19?」 任意質數皆可——雜湊表在質數個桶時工作效率最高
「get_bucket 為何不用上鎖?」 桶的數量固定不變,hasher(key) % size 的結果不受併發修改影響
「get_map 全表快照如何避免死結?」 以**固定順序(遞增桶索引)**鎖住所有桶
「hand-over-hand locking 順序?」 先鎖下一節點、再解鎖當前節點;順序固定 → 無死結、但執行緒不能超車
「remove_if 刪節點為何要先解鎖 next_lk?」 銷毀仍上鎖的 mutex 是未定義行為;持有前節點鎖保證無人能碰被刪節點