保護共享資料的替代方案 (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);
}
};
- 第一個呼叫
send_data()或receive_data()的執行緒完成初始化,其他執行緒等它完成
std::mutex 與 std::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 相同:
lock()三次就必須unlock()三次,其他執行緒才拿得到鎖std::lock_guard<std::recursive_mutex>/std::unique_lock<std::recursive_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 可以複製或移動嗎?」 |
都不行;含有它們的類別需手寫特殊成員函式 |
Related Notes
- 03-Sharing-Data/01-Race-Conditions-and-Mutexes
- 03-Sharing-Data/02-Deadlock-and-Lock-Management
- 03-Sharing-Data/Practice-Sharing-Data
- 04-Synchronizing-Operations/03-Waiting-with-Time-Limits —
shared_timed_mutex的計時操作(4.3 節) - 05-Memory-Model-and-Atomics/01-Memory-Model-Basics — DCLP 資料競爭的記憶體模型解釋
- 08-Designing-Concurrent-Code/02-Factors-Affecting-Performance — 讀寫鎖效能的影響因素