保護共享資料的替代方案 (Alternative Data Protection)

Overview Table

概念 重點
保護初始化過程 資料只在初始化時需要保護,初始化後鎖成了純開銷
雙重檢查鎖定 (DCLP) 聲名狼藉:未同步的讀 vs 上鎖的寫 → 資料競爭、未定義行為
std::call_once + std::once_flag 標準解法:保證初始化恰好完整執行一次,開銷比 mutex 小
C++11 static 區域變數 初始化保證執行緒安全——全域唯一實例的最簡解法
std::shared_mutex 讀寫鎖 讀者共享 (shared_lock)、寫者獨占 (lock_guard/unique_lock)
std::recursive_mutex 同執行緒可重複上鎖;通常是設計壞味道

保護共享資料的初始化過程

延遲初始化 (lazy initialization):昂貴資源(資料庫連線、大量記憶體)等到真正需要時才建立。單執行緒寫法:

std::shared_ptr<some_resource> resource_ptr;
void foo() {
    if (!resource_ptr)
        resource_ptr.reset(new some_resource);  // 只有這行需要保護
    resource_ptr->do_something();
}

多執行緒下,天真的做法是整段上鎖——所有執行緒在此被迫序列化,即使資源早已初始化完畢,每次呼叫仍要排隊拿鎖,純粹浪費:

void foo() {
    std::unique_lock<std::mutex> lk(resource_mutex);  // 所有執行緒在此序列化
    if (!resource_ptr)
        resource_ptr.reset(new some_resource);
    lk.unlock();
    resource_ptr->do_something();
}

雙重檢查鎖定 (Double-Checked Locking) 為何聲名狼藉

「聰明」的最佳化:先無鎖檢查一次,為空才上鎖並再檢查一次:

void undefined_behaviour_with_double_checked_locking() {
    if (!resource_ptr) {                      // 1 無鎖讀取指標
        std::lock_guard<std::mutex> lk(resource_mutex);
        if (!resource_ptr)                    // 2 雙重檢查
            resource_ptr.reset(new some_resource);  // 3 上鎖後寫入
    }
    resource_ptr->do_something();             // 4 可能操作到半成品
}
這是資料競爭,未定義行為

①處未受鎖保護的讀與③處受鎖保護的寫之間沒有同步——競爭不只發生在指標本身,還包括指標指向的物件:執行緒可能看到指標已非空,卻看不到新建物件的完整內容(建構未寫入完成),於是④得到錯誤結果。這是典型的資料競爭 (data race),C++ 標準定義為未定義行為(記憶體模型細節見第5章)。

std::call_once 與 std::once_flag

C++ 標準庫的正解:每個執行緒只需呼叫 std::call_once,返回時保證指標已被某個執行緒初始化完成,開銷比顯式上鎖+檢查更低:

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;

void init_resource() { resource_ptr.reset(new some_resource); }
void foo() {
    std::call_once(resource_flag, init_resource);  // 完整地執行恰好一次
    resource_ptr->do_something();
}

也可作為類別成員做延遲初始化,傳遞成員函式指標 + this(與 std::thread/std::bind 的可呼叫物件慣例相同):

class X {
    connection_handle connection;
    std::once_flag connection_init_flag;
    void open_connection();                 // 初始化用成員函式
public:
    void send_data(data_packet const& data) {
        std::call_once(connection_init_flag, &X::open_connection, this);
        connection.send_data(data);
    }
};
不可複製、不可移動

std::mutexstd::once_flag 的實例既不能複製也不能移動——含有它們的類別若要支援複製/移動,必須顯式手寫對應的特殊成員函式。

C++11:static 區域變數的執行緒安全初始化

初始化過程的另一種潛在競爭:函式內 static 區域變數。C++11 之前,多個執行緒可能同時「搶著」初始化它;C++11 起標準保證:初始化恰好發生在一個執行緒中,其他執行緒在初始化完成前不會繼續執行。只需要單一全域實例時,這是 std::call_once 的漂亮替代:

my_class& get_my_class_instance() {
    static my_class instance;   // C++11 起:執行緒安全的初始化
    return instance;
}
方案 適用場景 備註
mutex 全程鎖 不推薦 初始化後仍序列化所有執行緒
DCLP 禁止 資料競爭 → UB
std::call_once 任意延遲初始化(含類別成員) 需搭配 std::once_flag
static 區域變數 全域唯一實例(單例) C++11 起才安全

保護不常更新的資料結構:std::shared_mutex 讀寫鎖

情境:DNS 快取表——絕大多數時間被多執行緒並發讀取,偶爾才更新。用 std::mutex 保護是反應過度:沒有修改發生時也扼殺了並發讀取。需要的是「讀寫鎖」(reader-writer mutex):一個寫者獨占、多個讀者共享

標準版本 提供的型別
C++11 無(可用 Boost)
C++14 std::shared_timed_mutex(支援較多操作,見 4.3 節)
C++17 std::shared_mutex(操作較少、效能較佳)+ shared_timed_mutex

用法對照(頭文件 <shared_mutex>):

角色 鎖類型 語義
讀者(不修改) std::shared_lock<std::shared_mutex>(C++14 RAII) 多執行緒可同時持共享鎖
寫者(修改) std::lock_guard<std::shared_mutex>std::unique_lock<std::shared_mutex> 獨占存取
class dns_cache {
    std::map<std::string, dns_entry> entries;
    mutable std::shared_mutex entry_mutex;
public:
    dns_entry find_entrystring const& domain const {
        std::shared_lock<std::shared_mutex> lk(entry_mutex);   // 讀:共享
        auto it = entries.find(domain);
        return it == entries.end() ? dns_entry() : it->second;
    }
    void update_or_add_entrystring const& domain, dns_entry const& details {
        std::lock_guard<std::shared_mutex> lk(entry_mutex);    // 寫:獨占
        entries[domain] = details;
    }
};
讀寫鎖不是萬靈丹

效能取決於處理器數量讀者/寫者的負載比例——增加的複雜度不保證換來收益,必須在目標系統上實測(見第8章)。

std::recursive_mutex:嵌套鎖

對已鎖住的 std::mutex 在同一執行緒再次上鎖是未定義行為std::recursive_mutex 允許同一執行緒對單一實例多次上鎖,其餘功能與 std::mutex 相同:

通常是設計壞味道

典型「需求」:成員函式 A(已上鎖)呼叫成員函式 B(也要上鎖)→ 換成 recursive_mutex「就能跑了」。這種變通草率且不推薦:A 呼叫 B 時,類別的不變量可能正處於被破壞的中間狀態,B 卻照常執行、照常「持有鎖」,鎖給的安全感是假的。

較好的做法:抽出一個私有成員函式實作共同邏輯——它不上鎖(約定:呼叫前必須已持鎖),A、B 都先上鎖再呼叫它,並仔細想清楚呼叫當下資料的狀態。


Exam/Test Patterns

情境/關鍵字 答案
「雙重檢查鎖定 (DCLP)」 資料競爭 → 未定義行為;改用 std::call_once 或 static 區域變數
「昂貴資源只需初始化一次」 std::once_flag + std::call_once(可傳成員函式 + this)
「單例/全域唯一實例」 C++11 起 static 區域變數初始化執行緒安全
「初始化後每次存取仍要排隊拿鎖」 天真的 mutex 保護造成不必要的序列化
「讀多寫少(如 DNS 快取)」 std::shared_mutex:讀 shared_lock、寫 lock_guard/unique_lock
「C++14 只有哪個讀寫鎖?」 std::shared_timed_mutex(shared_mutex 是 C++17)
「同一執行緒對 std::mutex 重複上鎖」 未定義行為std::recursive_mutex,但通常代表設計該重構
「成員函式互相呼叫都要上鎖」 抽出不上鎖的私有成員函式,而非改用 recursive_mutex
once_flag/mutex 可以複製或移動嗎?」 都不行;含有它們的類別需手寫特殊成員函式