文章目錄
- 共享數據的問題
- 3.1.1 條件競爭
- 雙鏈表的例子
- 條件競爭示例
- 惡性條件競爭的特點
- 3.1.2 避免惡性條件競爭
- 1. 使用互斥量保護共享數據結構
- 2. 無鎖編程
- 3. 軟件事務內存(STM)
- 總結
- 互斥量與共享數據保護
- 3.2.1 互斥量
- 使用互斥量保護共享數據
- 示例代碼:
- C++17的新特性
- 面向對象設計中的互斥量
- 3.2.2 保護共享數據
- 示例代碼:
- 解決方案:
- 3.2.3 接口間的條件競爭
- 示例代碼:
- 解決方案:
- 總結
- 接口間的條件競爭與解決方案
- 3.2.3 接口間的條件競爭
- 示例:`std::stack` 容器的實現
- 解決方案:重新設計接口
- 示例:線程安全的堆棧類定義
- 3.2.4 死鎖:問題描述及解決方案
- 示例:使用 `std::lock` 和 `std::lock_guard`
- 使用 `std::scoped_lock`(C++17)
- 總結
- 3.2.5 避免死鎖的進階指導
- 死鎖的原因與常見場景
- 避免嵌套鎖
- 避免在持有鎖時調用外部代碼
- 使用固定順序獲取鎖
- 使用層次鎖結構
- 示例:使用層次鎖來避免死鎖
- 超越鎖的延伸擴展
- 使用 `std::unique_lock` 提供靈活性
- 示例:使用 `std::unique_lock` 和 `std::defer_lock`
- 不同域中互斥量的傳遞
- 總結
- 3.2.8 鎖的粒度
- 鎖的粒度簡介
- 類比超市結賬場景
- 細粒度鎖 vs 粗粒度鎖
- 示例:優化鎖的使用
- 控制鎖的持有時間
- 示例:細粒度鎖的應用
- 條件競爭與語義一致性
- 尋找合適的機制
- 總結
- 3.3 保護共享數據的方式
- 3.3.1 保護共享數據的初始化過程
- 單線程延遲初始化
- 多線程延遲初始化
- 雙重檢查鎖模式
- 使用 `std::call_once` 和 `std::once_flag`
- 靜態局部變量的線程安全初始化
- 3.3.2 保護不常更新的數據結構
- 使用 `std::shared_mutex`
- 3.3.3 嵌套鎖
- 使用 `std::recursive_mutex`
- 總結
共享數據的問題
3.1.1 條件競爭
在多線程編程中,共享數據的修改是導致問題的主要原因。如果數據只讀,則不會影響數據的一致性,所有線程都能獲得相同的數據。然而,當一個或多個線程需要修改共享數據時,就會出現許多復雜的問題。這些問題通常涉及**不變量(invariants)**的概念,即描述特定數據結構的某些屬性,例如“變量包含列表中的項數”。更新操作通常會破壞這些不變量,特別是在處理復雜數據結構時。
雙鏈表的例子
以雙鏈表為例,每個節點都有指向前一個節點和后一個節點的指針。為了從列表中刪除一個節點,必須更新其前后節點的指針,這會導致不變量暫時被破壞:
- 找到要刪除的節點N
- 更新前一個節點指向N的指針,讓其指向N的下一個節點
- 更新后一個節點指向N的指針,讓其指向前一個節點
- 刪除節點N
在這過程中,步驟2和步驟3之間,不變量被破壞,因為此時部分指針已經更新,但還未完全完成。如果其他線程在此期間訪問該鏈表,可能會讀取到不一致的狀態,從而導致程序錯誤甚至崩潰。這種問題被稱為條件競爭(race condition)。
條件競爭示例
假設你去一家大電影院買電影票,有多個收銀臺可以同時售票。當另一個收銀臺也在賣你想看的電影票時,你的座位選擇取決于之前已預定的座位。如果有少量座位剩余,可能會出現一場搶票比賽,看誰能搶到最后的票。這就是一個典型的條件競爭例子:你的座位(或電影票)取決于購買的順序。
在并發編程中,條件競爭取決于多個線程的執行順序。大多數情況下,即使改變執行順序,結果仍然是可接受的。然而,當不變量遭到破壞時,條件競爭就可能變成惡性競爭,例如在雙鏈表的例子中,可能導致數據結構永久損壞并使程序崩潰。
C++標準定義了**數據競爭(data race)**這一術語,指的是并發修改獨立對象的情況,這種情況會導致未定義行為。
惡性條件競爭的特點
- 難以查找和復現:由于問題出現的概率較低,且依賴于特定的執行順序,因此很難查找和復現。
- 時間敏感:調試模式下,程序的執行速度變慢,錯誤可能完全消失,因為調試模式會影響程序的執行時間。
- 負載敏感:隨著系統負載增加,執行序列問題復現的概率也會增加。
3.1.2 避免惡性條件競爭
為了避免惡性條件競爭,以下是幾種常見的解決方案:
1. 使用互斥量保護共享數據結構
最簡單的方法是對共享數據結構使用某種保護機制,確保只有修改線程才能看到不變量的中間狀態。C++標準庫提供了多種互斥量(如 std::mutex
),可以用來保護共享數據結構,確保只有一個線程能進行修改,其他線程要么等待修改完成,要么讀取到一致的數據。
2. 無鎖編程
另一種方法是對數據結構和不變量進行設計,使其能夠完成一系列不可分割的變化,保證每個不變量的狀態。這種方法稱為無鎖編程,雖然高效,但實現難度較大,容易出錯。
3. 軟件事務內存(STM)
還有一種處理條件競爭的方式是使用事務的方式處理數據結構的更新,類似于數據庫中的事務管理。所需的數據和讀取操作存儲在事務日志中,然后將之前的操作進行合并并提交。如果數據結構被另一個線程修改,提交操作將失敗并重新嘗試。這種方法稱為軟件事務內存(Software Transactional Memory, STM),是一個熱門的研究領域,但在C++標準中沒有直接支持。
總結
- 共享數據問題:當多個線程共享數據時,特別是當數據需要被修改時,會出現條件競爭問題。
- 不變量:描述數據結構的某些屬性,在修改過程中可能會被破壞。
- 條件競爭:多個線程爭奪對共享資源的訪問權,可能導致程序錯誤或崩潰。
- 避免惡性條件競爭的方法:
- 互斥量:使用互斥量保護共享數據結構,確保只有一個線程能進行修改。
- 無鎖編程:設計數據結構使其能完成一系列不可分割的變化。
- 軟件事務內存(STM):使用事務的方式處理數據結構的更新,確保一致性。
通過上述方法,開發者可以有效避免多線程編程中的條件競爭問題,確保程序的正確性和穩定性。
互斥量與共享數據保護
3.2.1 互斥量
使用互斥量保護共享數據
在多線程環境中,使用互斥量(std::mutex
)可以確保對共享數據的訪問是互斥的,從而避免條件競爭問題。C++標準庫提供了std::lock_guard
,它利用RAII機制自動管理互斥量的鎖定和解鎖。
示例代碼:
#include <list>
#include <mutex>
#include <algorithm>std::list<int> some_list; // 1: 全局變量
std::mutex some_mutex; // 2: 全局互斥量void add_to_list(int new_value)
{std::lock_guard<std::mutex> guard(some_mutex); // 3: 鎖定互斥量some_list.push_back(new_value);
}bool list_contains(int value_to_find)
{std::lock_guard<std::mutex> guard(some_mutex); // 4: 鎖定互斥量return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
- 全局變量與互斥量:
some_list
是一個全局變量,被一個全局互斥量some_mutex
保護。 std::lock_guard
:在add_to_list
和list_contains
函數中,使用std::lock_guard
來自動管理互斥量的鎖定和解鎖,確保在函數執行期間互斥量處于鎖定狀態,防止其他線程訪問共享數據。
C++17的新特性
C++17引入了模板類參數推導,簡化了std::lock_guard
的使用:
std::lock_guard guard(some_mutex); // 模板參數類型由編譯器推導
此外,C++17還引入了std::scoped_lock
,提供了更強大的功能:
std::scoped_lock guard(some_mutex);
為了兼容C++11標準,本文將繼續使用帶有模板參數類型的std::lock_guard
。
面向對象設計中的互斥量
將互斥量與需要保護的數據放在同一個類中,可以使代碼更加清晰,并且方便了解什么時候對互斥量上鎖。例如:
class ProtectedData {
private:std::list<int> data;std::mutex mutex;public:void add_to_list(int new_value) {std::lock_guard<std::mutex> guard(mutex);data.push_back(new_value);}bool contains(int value_to_find) {std::lock_guard<std::mutex> guard(mutex);return std::find(data.begin(), data.end(), value_to_find) != data.end();}
};
這種設計方式不僅封裝了數據,還確保了所有對共享數據的訪問都在互斥量保護下進行。
3.2.2 保護共享數據
使用互斥量保護數據不僅僅是簡單地在每個成員函數中加入一個std::lock_guard
對象。必須注意以下幾點:
-
避免返回指向受保護數據的指針或引用:
- 如果成員函數返回指向受保護數據的指針或引用,外部代碼可以直接訪問這些數據而無需通過互斥量保護,這會破壞數據保護機制。
-
檢查成員函數是否通過指針或引用來調用:
- 尤其是在調用不在你控制下的函數時,確保這些函數不會存儲指向受保護數據的指針或引用。
示例代碼:
class SomeData {int a;std::string b;
public:void do_something();
};class DataWrapper {
private:SomeData data;std::mutex m;public:template<typename Function>void process_data(Function func) {std::lock_guard<std::mutex> l(m);func(data); // 傳遞“保護”數據給用戶函數}
};SomeData* unprotected;void malicious_function(SomeData& protected_data) {unprotected = &protected_data;
}DataWrapper x;void foo() {x.process_data(malicious_function); // 傳遞惡意函數unprotected->do_something(); // 在無保護的情況下訪問保護數據
}
在這個例子中,盡管process_data
函數內部使用了互斥量保護數據,但傳遞給用戶的函數func
可能會繞過保護機制,導致數據被不安全地訪問。
解決方案:
- 不要將受保護數據的指針或引用傳遞到互斥鎖作用域之外。
- 確保所有對受保護數據的訪問都在互斥量保護下進行。
3.2.3 接口間的條件競爭
即使使用了互斥量保護數據,如果接口設計不當,仍然可能存在條件競爭。例如,如果某個接口允許返回指向受保護數據的指針或引用,外部代碼可以在沒有互斥量保護的情況下訪問這些數據,導致數據不一致。
示例代碼:
class ProtectedData {
private:std::list<int> data;std::mutex mutex;public:const std::list<int>& get_data() { // 返回引用,可能導致條件競爭std::lock_guard<std::mutex> guard(mutex);return data;}
};
在這種情況下,雖然get_data
函數內部使用了互斥量保護數據,但返回的引用可以在互斥量保護范圍之外被訪問,從而導致潛在的條件競爭。
解決方案:
- 避免返回指向受保護數據的指針或引用,除非這些指針或引用本身也在互斥量保護下使用。
- 設計接口時確保所有對受保護數據的訪問都在互斥量保護范圍內。
總結
- 互斥量的作用:互斥量用于保護共享數據,確保同一時間只有一個線程能夠訪問和修改數據,從而避免條件競爭。
std::lock_guard
:利用RAII機制自動管理互斥量的鎖定和解鎖,簡化了代碼編寫。- 面向對象設計中的互斥量:將互斥量與需要保護的數據放在同一個類中,使得代碼更加清晰并便于管理。
- 避免返回指針或引用:確保所有對受保護數據的訪問都在互斥量保護下進行,避免返回指向受保護數據的指針或引用。
- 接口設計注意事項:確保接口設計合理,避免通過接口泄露受保護數據的指針或引用,防止條件競爭的發生。
通過正確使用互斥量和精心設計接口,開發者可以有效避免多線程編程中的條件競爭問題,確保程序的正確性和穩定性。
接口間的條件競爭與解決方案
3.2.3 接口間的條件競爭
即使使用了互斥量或其他機制保護共享數據,仍然需要確保數據是否真正受到了保護。例如,在雙鏈表的例子中,為了線程安全地刪除一個節點,不僅需要保護待刪除節點及其前后相鄰的節點,還需要保護整個刪除操作的過程。最簡單的解決方案是使用互斥量來保護整個鏈表或數據結構。
示例:std::stack
容器的實現
考慮一個類似于 std::stack
的棧類:
template<typename T, typename Container = std::deque<T>>
class stack {
public:explicit stack(const Container&);explicit stack(Container&& = Container());template <class Alloc> explicit stack(const Alloc&);template <class Alloc> stack(const Container&, const Alloc&);template <class Alloc> stack(Container&&, const Alloc&);template <class Alloc> stack(stack&&, const Alloc&);bool empty() const;size_t size() const;T& top();T const& top() const;void push(T const&);void push(T&&);void pop();void swap(stack&&);template <class... Args> void emplace(Args&&... args); // C++14的新特性
};
盡管每個成員函數都可能在內部使用互斥量保護數據,但接口設計上的問題仍可能導致條件競爭。例如:
empty()
和size()
:雖然這些函數在返回時可能是正確的,但在返回后其他線程可能會修改棧的內容,導致之前的結果變得不可靠。top()
和pop()
:如果兩個線程分別調用top()
和pop()
,可能會出現競態條件,因為在這兩個操作之間,另一個線程可能會修改棧的狀態。
解決方案:重新設計接口
為了避免上述問題,可以通過重新設計接口來解決條件競爭:
-
選項1:傳入引用獲取彈出值
std::vector<int> result; some_stack.pop(result);
缺點:
- 需要構造一個目標類型的實例,這可能不現實或資源開銷大。
- 不適用于所有類型,特別是那些沒有賦值操作的類型。
-
選項2:無異常拋出的拷貝構造函數或移動構造函數
使用無異常拋出的拷貝構造函數或移動構造函數可以避免某些異常問題,但這限制了可使用的類型范圍。
-
選項3:返回指向彈出值的指針
返回一個指向彈出元素的指針(如
std::shared_ptr
)可以避免內存分配問題,并且不會拋出異常。std::shared_ptr<T> pop() {std::lock_guard<std::mutex> lock(m);if (data.empty()) throw empty_stack();std::shared_ptr<T> res(std::make_shared<T>(data.top()));data.pop();return res; }
-
選項4:結合選項1和選項3
提供多個接口選項,讓用戶選擇最適合的方案。
示例:線程安全的堆棧類定義
以下是一個線程安全的堆棧類定義示例,結合了選項1和選項3:
#include <exception>
#include <memory>
#include <mutex>
#include <stack>struct empty_stack : std::exception {const char* what() const throw() {return "empty stack!";}
};template<typename T>
class threadsafe_stack {
private:std::stack<T> data;mutable std::mutex m;public:threadsafe_stack() : data(std::stack<T>()) {}threadsafe_stack(const threadsafe_stack& other) {std::lock_guard<std::mutex> lock(other.m);data = other.data; // 在構造函數體中的執行拷貝}threadsafe_stack& operator=(const threadsafe_stack&) = delete;void push(T new_value) {std::lock_guard<std::mutex> lock(m);data.push(new_value);}std::shared_ptr<T> pop() {std::lock_guard<std::mutex> lock(m);if (data.empty()) throw empty_stack();std::shared_ptr<T> res(std::make_shared<T>(data.top()));data.pop();return res;}void pop(T& value) {std::lock_guard<std::mutex> lock(m);if (data.empty()) throw empty_stack();value = data.top();data.pop();}bool empty() const {std::lock_guard<std::mutex> lock(m);return data.empty();}
};
3.2.4 死鎖:問題描述及解決方案
死鎖是指兩個或多個線程互相等待對方釋放資源,導致所有線程都無法繼續執行的情況。例如:
- 線程A持有互斥量A并請求互斥量B。
- 線程B持有互斥量B并請求互斥量A。
為了避免死鎖,可以采取以下措施:
- 保持一致的加鎖順序:確保所有線程以相同的順序獲取互斥量。
- 使用
std::lock
或std::scoped_lock
:C++標準庫提供了std::lock
和std::scoped_lock
,可以一次性鎖住多個互斥量,避免死鎖。
示例:使用 std::lock
和 std::lock_guard
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);class X {
private:some_big_object some_detail;std::mutex m;public:X(some_big_object const& sd) : some_detail(sd) {}friend void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;std::lock(lhs.m, rhs.m); // 1 鎖住兩個互斥量std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock); // 2std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock); // 3swap(lhs.some_detail, rhs.some_detail);}
};
使用 std::scoped_lock
(C++17)
C++17引入了 std::scoped_lock
,可以簡化多互斥量鎖定的代碼:
void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;std::scoped_lock guard(lhs.m, rhs.m); // 1 自動推導模板參數swap(lhs.some_detail, rhs.some_detail);
}
總結
- 條件競爭:即使使用互斥量保護共享數據,接口設計不當仍可能導致條件競爭。通過重新設計接口,可以有效避免這些問題。
- 死鎖:避免死鎖的關鍵在于保持一致的加鎖順序,或使用
std::lock
和std::scoped_lock
來一次性鎖住多個互斥量。 - 接口設計建議:
- 避免返回指向受保護數據的指針或引用。
- 盡量減少不必要的接口復雜性,確保所有對共享數據的訪問都在互斥量保護下進行。
- 使用細粒度鎖來提高并發性能,同時避免過度細化導致的死鎖風險。
通過合理的設計和使用標準庫提供的工具,開發者可以有效地避免多線程編程中的條件競爭和死鎖問題,確保程序的正確性和穩定性。
3.2.5 避免死鎖的進階指導
死鎖的原因與常見場景
死鎖通常是由對鎖的不當使用造成的。例如,兩個線程互相調用 join()
可能導致死鎖,因為每個線程都在等待另一個線程結束。類似地,當多個線程持有不同鎖并試圖獲取對方持有的鎖時,也會發生死鎖。
為了避免死鎖,以下是一些進階的指導意見:
避免嵌套鎖
建議1:避免嵌套鎖
最簡單的避免死鎖的方法是確保每個線程只持有一個鎖。如果需要獲取多個鎖,可以使用 std::lock
來一次性鎖定多個互斥量,從而避免死鎖。
std::mutex m1, m2;
std::lock(m1, m2); // 同時鎖定m1和m2,避免死鎖
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
避免在持有鎖時調用外部代碼
建議2:避免在持有鎖時調用外部代碼
外部代碼的行為是不可預測的,可能包含獲取其他鎖的操作,這會導致死鎖。盡量減少在持有鎖的情況下調用外部代碼。
使用固定順序獲取鎖
建議3:使用固定順序獲取鎖
當必須獲取多個鎖時,確保所有線程以相同的順序獲取這些鎖。例如,在鏈表中刪除節點時,確保所有線程按相同順序鎖定節點及其相鄰節點。
void delete_node(Node* node) {std::lock_guard<std::mutex> lock_prev(node->prev->mutex);std::lock_guard<std::mutex> lock_next(node->next->mutex);// 確保固定的鎖順序
}
使用層次鎖結構
建議4:使用層次鎖結構
為每個互斥量分配一個層級值,并確保在任何時刻,只能獲取比當前層級更低的鎖。這樣可以避免循環等待的情況。
class hierarchical_mutex {std::mutex internal_mutex;unsigned long const hierarchy_value;unsigned long previous_hierarchy_value;static thread_local unsigned long this_thread_hierarchy_value;void check_for_hierarchy_violation() {if (this_thread_hierarchy_value <= hierarchy_value) {throw std::logic_error("mutex hierarchy violated");}}void update_hierarchy_value() {previous_hierarchy_value = this_thread_hierarchy_value;this_thread_hierarchy_value = hierarchy_value;}public:explicit hierarchical_mutex(unsigned long value): hierarchy_value(value), previous_hierarchy_value(0) {}void lock() {check_for_hierarchy_violation();internal_mutex.lock();update_hierarchy_value();}void unlock() {if (this_thread_hierarchy_value != hierarchy_value) {throw std::logic_error("mutex hierarchy violated");}this_thread_hierarchy_value = previous_hierarchy_value;internal_mutex.unlock();}bool try_lock() {check_for_hierarchy_violation();if (!internal_mutex.try_lock()) {return false;}update_hierarchy_value();return true;}
};thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
示例:使用層次鎖來避免死鎖
以下是使用層次鎖的一個示例:
hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(5000);
hierarchical_mutex other_mutex(6000);int do_low_level_stuff();int low_level_func() {std::lock_guard<hierarchical_mutex> lk(low_level_mutex);return do_low_level_stuff();
}void high_level_stuff(int some_param);void high_level_func() {std::lock_guard<hierarchical_mutex> lk(high_level_mutex);high_level_stuff(low_level_func());
}void thread_a() {high_level_func();
}void do_other_stuff();void other_stuff() {high_level_func();do_other_stuff();
}void thread_b() {std::lock_guard<hierarchical_mutex> lk(other_mutex);other_stuff();
}
超越鎖的延伸擴展
除了上述方法,還需要注意其他同步構造中的潛在死鎖問題。例如,不要在持有鎖的情況下等待另一個線程的完成,除非你確定該線程的層級低于當前線程。
使用 std::unique_lock
提供靈活性
std::unique_lock
提供了比 std::lock_guard
更多的靈活性。它可以延遲鎖定、手動解鎖以及在不同作用域之間轉移所有權。
示例:使用 std::unique_lock
和 std::defer_lock
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);class X {
private:some_big_object some_detail;std::mutex m;public:X(some_big_object const& sd) : some_detail(sd) {}friend void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);std::lock(lock_a, lock_b); // 同時鎖定兩個互斥量swap(lhs.some_detail, rhs.some_detail);}
};
不同域中互斥量的傳遞
std::unique_lock
支持移動操作,可以在不同的作用域之間傳遞鎖的所有權。例如:
std::unique_lock<std::mutex> get_lock() {extern std::mutex some_mutex;std::unique_lock<std::mutex> lk(some_mutex);prepare_data();return lk; // 返回鎖的所有權
}void process_data() {std::unique_lock<std::mutex> lk(get_lock());do_something(); // 在保護的數據上執行操作
}
總結
- 避免嵌套鎖:每個線程只持有一個鎖,必要時使用
std::lock
一次性鎖定多個互斥量。 - 避免在持有鎖時調用外部代碼:外部代碼可能導致意外的鎖競爭。
- 使用固定順序獲取鎖:確保所有線程以相同的順序獲取鎖。
- 使用層次鎖結構:通過層級值限制鎖的獲取順序,避免死鎖。
- 使用
std::unique_lock
提供靈活性:允許延遲鎖定、手動解鎖及鎖的所有權轉移。
通過遵循這些指導意見,可以有效避免多線程編程中的死鎖問題,提高程序的穩定性和可靠性。
3.2.8 鎖的粒度
鎖的粒度簡介
鎖的粒度指的是通過一個鎖保護的數據量大小。細粒度鎖(fine-grained lock)保護較小的數據量,而粗粒度鎖(coarse-grained lock)則保護較大的數據量。選擇合適的鎖粒度對于提高多線程程序的性能至關重要。
類比超市結賬場景
考慮一個超市結賬的情景:如果一位顧客在結賬時突然發現忘拿了某樣商品,離開去取回該商品會導致其他排隊的顧客等待。同樣地,在多線程環境中,如果某個線程長時間持有鎖,其他需要訪問共享資源的線程將被迫等待,導致整體性能下降。
細粒度鎖 vs 粗粒度鎖
- 細粒度鎖:每個鎖保護的數據量較小,允許多個線程并行訪問不同的數據部分,減少競爭和等待時間。
- 粗粒度鎖:一個鎖保護大量數據,可能導致更多的線程競爭同一把鎖,增加等待時間。
示例:優化鎖的使用
以下是一個示例,展示了如何優化鎖的使用以減少持鎖時間:
void get_and_process_data() {std::unique_lock<std::mutex> my_lock(the_mutex);some_class data_to_process = get_next_data_chunk();my_lock.unlock(); // 1 解鎖互斥量,避免在處理數據時持有鎖result_type result = process(data_to_process);my_lock.lock(); // 2 再次上鎖,準備寫入結果write_result(data_to_process, result);
}
在這個例子中,my_lock.unlock()
在調用 process()
函數之前解鎖互斥量,從而允許其他線程在此期間訪問共享數據。當需要寫入結果時,再次鎖定互斥量。
控制鎖的持有時間
為了最小化鎖的持有時間,可以采取以下策略:
- 只在必要時持有鎖:僅在訪問或修改共享數據時持有鎖,盡量減少持有鎖的時間。
- 分段操作:將復雜的操作分成多個步驟,并在每個步驟之間釋放鎖。
示例:細粒度鎖的應用
假設有一個簡單的數據類型 int
,其拷貝操作非常廉價。在這種情況下,可以通過復制數據來避免長時間持有鎖:
class Y {
private:int some_detail;mutable std::mutex m;int get_detail() const {std::lock_guard<std::mutex> lock_a(m); // 1 保護對some_detail的訪問return some_detail;}public:Y(int sd) : some_detail(sd) {}friend bool operator==(Y const& lhs, Y const& rhs) {if (&lhs == &rhs)return true;int const lhs_value = lhs.get_detail(); // 2 獲取lhs的值int const rhs_value = rhs.get_detail(); // 3 獲取rhs的值return lhs_value == rhs_value; // 4 比較兩個值}
};
在這個例子中,比較操作符首先通過調用 get_detail()
成員函數檢索要比較的值(步驟 2 和 3),并在索引時被鎖保護(步驟 1)。然后比較這兩個值(步驟 4)。這種方法減少了鎖的持有時間,但需要注意的是,由于兩次獲取值之間可能存在數據變化,可能會出現條件競爭的問題。
條件競爭與語義一致性
雖然上述方法減少了鎖的持有時間,但也引入了條件競爭的風險。例如,兩個值可能在讀取后被修改,導致比較的結果不再準確。因此,在設計并發程序時,必須仔細考慮語義一致性問題。
尋找合適的機制
有時,單一的鎖機制無法滿足所有需求。在這種情況下,可以考慮使用更復雜的同步機制,如讀寫鎖(std::shared_mutex
)、無鎖數據結構或其他高級同步技術。
總結
- 鎖的粒度:細粒度鎖保護較小的數據量,適合高并發場景;粗粒度鎖保護較大的數據量,可能導致較多的競爭。
- 控制鎖的持有時間:盡可能縮短持有鎖的時間,只在必要的時候持有鎖。
- 分段操作:將復雜操作分成多個步驟,并在每個步驟之間釋放鎖。
- 條件競爭:注意在減少鎖持有時間的同時,避免引入條件競爭問題。
通過合理選擇鎖的粒度和控制鎖的持有時間,可以顯著提高多線程程序的性能和可靠性。
3.3 保護共享數據的方式
在多線程編程中,互斥量是保護共享數據的一種通用機制,但并非唯一方式。根據具體場景選擇合適的同步機制可以顯著提高程序的性能和可靠性。
3.3.1 保護共享數據的初始化過程
單線程延遲初始化
假設有一個昂貴的資源需要延遲初始化:
std::shared_ptr<some_resource> resource_ptr;void foo() {if (!resource_ptr) {resource_ptr.reset(new some_resource); // 1 初始化資源}resource_ptr->do_something();
}
這段代碼在單線程環境中工作良好,但在多線程環境中,resource_ptr
的初始化部分需要保護以避免競爭條件。
多線程延遲初始化
使用互斥量保護初始化過程:
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;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();
}
雖然這種方法保證了線程安全,但會導致不必要的序列化,降低并發性能。
雙重檢查鎖模式
雙重檢查鎖模式試圖減少鎖的競爭:
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 使用資源
}
然而,這種方法存在潛在的條件競爭問題,可能導致未定義行為。
使用 std::call_once
和 std::once_flag
C++ 標準庫提供了 std::call_once
和 std::once_flag
來處理這種情況:
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();
}
這種方式不僅簡化了代碼,還減少了鎖的競爭,提高了性能。
靜態局部變量的線程安全初始化
C++11 標準確保靜態局部變量的初始化是線程安全的:
class my_class;
my_class& get_my_class_instance() {static my_class instance; // 線程安全的初始化過程return instance;
}
這種初始化方式在多線程調用時也是安全的,無需額外的同步機制。
3.3.2 保護不常更新的數據結構
對于不經常更新的數據結構,如 DNS 緩存,可以使用讀者-作者鎖(reader-writer lock)來優化性能。
使用 std::shared_mutex
C++17 提供了 std::shared_mutex
,允許多個讀線程同時訪問數據,而寫線程獨占訪問。
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>class dns_entry;class dns_cache {std::map<std::string, dns_entry> entries;mutable std::shared_mutex entry_mutex;public:dns_entry find_entry(const std::string& domain) const {std::shared_lock<std::shared_mutex> lk(entry_mutex); // 1 共享鎖auto it = entries.find(domain);return (it == entries.end()) ? dns_entry() : it->second;}void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) {std::lock_guard<std::shared_mutex> lk(entry_mutex); // 2 獨占鎖entries[domain] = dns_details;}
};
在這個例子中,find_entry()
使用 std::shared_lock
允許多個讀線程并發訪問,而 update_or_add_entry()
使用 std::lock_guard
提供獨占訪問。
3.3.3 嵌套鎖
當一個線程需要多次獲取同一個互斥量時,可以使用 std::recursive_mutex
,它允許多次遞歸鎖定而不導致死鎖。
使用 std::recursive_mutex
std::recursive_mutex recursive_mutex;void nested_function() {std::lock_guard<std::recursive_mutex> lk(recursive_mutex);// 執行操作
}void outer_function() {std::lock_guard<std::recursive_mutex> lk(recursive_mutex);nested_function(); // 可以再次鎖定同一個互斥量
}
需要注意的是,嵌套鎖應謹慎使用,通常應通過重構代碼避免嵌套鎖定的需求。
總結
- 鎖的粒度:選擇合適的鎖粒度可以提高并發性能,細粒度鎖適合高并發場景,粗粒度鎖適合較少競爭的場景。
- 延遲初始化:使用
std::call_once
和std::once_flag
可以有效地保護共享數據的初始化過程,避免不必要的鎖競爭。 - 讀者-作者鎖:對于不常更新的數據結構,使用
std::shared_mutex
可以提高讀操作的并發性能。 - 嵌套鎖:在需要遞歸鎖定的情況下,使用
std::recursive_mutex
,但應盡量避免嵌套鎖定的需求。
通過合理選擇和使用同步機制,可以有效保護共享數據并提升多線程程序的性能和可靠性。