這篇文章主要介紹了探究在C++程序并發時保護共享數據的問題,也有利于大家更好地理解C++多線程的一些機制,需要的朋友可以參考下
我們先通過一個簡單的代碼來了解該問題。
同步問題
我們使用一個簡單的結構體 Counter,該結構體包含一個值以及一個方法用來改變這個值:
- struct Counter {
- int value;
- void increment(){
- ++value;
- }
- };
然后啟動多個線程來修改結構體的值:
- int main(){
- Counter counter;
- std::vector<std::thread> threads;
- for(int i = 0; i < 5; ++i){
- threads.push_back(std::thread([&counter](){
- for(int i = 0; i < 100; ++i){
- counter.increment();
- }
- }));
- }
- for(auto& thread : threads){
- thread.join();
- }
- std::cout << counter.value << std::endl;
- return 0;
- }
我們啟動了5個線程來增加計數器的值,每個線程增加了100次,然后在線程結束時打印計數器的值。
但我們運行這個程序的時候,我們是希望它會答應500,但事實不是如此,沒人能確切知道程序將打印什么結果,下面是在我機器上運行后打印的數據,而且每次都不同:
- 442
- 500
- 477
- 400
- 422
- 487
問題的原因在于改變計數器值并不是一個原子操作,需要經過下面三個操作才能完成一次計數器的增加:
首先讀取 value 的值
然后將 value 值加1
將新的值賦值給 value
但你使用單線程來運行這個程序的時候當然沒有任何問題,因此程序是順序執行的,但在多線程環境中就有麻煩了,想象下下面這個執行順序:
Thread 1 : 讀取 value, 得到 0, 加 1, 因此 value = 1
Thread 2 : 讀取 value, 得到 0, 加 1, 因此 value = 1
Thread 1 : 將 1 賦值給 value,然后返回 1
Thread 2 : 將 1 賦值給 value,然后返回 1
這種情況我們稱之為多線程的交錯執行,也就是說多線程可能在同一個時間點執行相同的語句,盡管只有兩個線程,交錯的現象也很明顯。如果你有更多的線程、更多的操作需要執行,那么這個交錯是必然發生的。
有很多方法來解決線程交錯的問題:
信號量 Semaphores
原子引用 Atomic references
Monitors
Condition codes
Compare and swap
在這篇文章中我們將學習如何使用信號量來解決這個問題。信號量也有很多人稱之為互斥量(Mutex),同一個時間只允許一個線程獲取一個互斥對象的鎖,通過 Mutex 的簡單屬性就可以用來解決交錯的問題。
使用 Mutex 讓計數器程序是線程安全的
在 C++11 線程庫中,互斥量包含在 mutex 頭文件中,對應的類是 std::mutex,有兩個重要的方法 mutex:lock() 和 unlock() ,從名字上可得知是用來鎖對象以及釋放鎖對象。一旦某個互斥量被鎖,那么再次調用 lock() 返回堵塞值得該對象被釋放。
為了讓我們剛才的計數器結構體是線程安全的,我們添加一個 set:mutext 成員,并在每個方法中通過 lock()/unlock() 方法來進行保護:
- struct Counter {
- std::mutex mutex;
- int value;
- Counter() : value(0) {}
- void increment(){
- mutex.lock();
- ++value;
- mutex.unlock();
- }
- };
然后我們再次測試這個程序,打印的結果就是 500 了,而且每次都一樣。
異常和鎖
現在讓我們來看另外一種情況,想象我們的的計數器有一個減操作,并在值為0的時候拋出異常:
- struct Counter {
- int value;
- Counter() : value(0) {}
- void increment(){
- ++value;
- }
- void decrement(){
- if(value == 0){
- throw "Value cannot be less than 0";
- }
- --value;
- }
- };
然后我們不需要修改類來訪問這個結構體,我們創建一個封裝器:
- struct ConcurrentCounter {
- std::mutex mutex;
- Counter counter;
- void increment(){
- mutex.lock();
- counter.increment();
- mutex.unlock();
- }
- void decrement(){
- mutex.lock();
- counter.decrement();
- mutex.unlock();
- }
- };
大部分時候該封裝器運行挺好,但是使用 decrement 方法的時候就會有異常發生。這是一個大問題,一旦異常發生后,unlock 方法就沒被調用,導致互斥量一直被占用,然后整個程序就一直處于堵塞狀態(死鎖),為了解決這個問題我們需要用 try/catch 結構來處理異常情況:
- void decrement(){
- mutex.lock();
- try {
- counter.decrement();
- } catch (std::string e){
- mutex.unlock();
- throw e;
- }
- mutex.unlock();
- }
這個代碼并不難,但看起來很丑,如果你一個函數有 10 個退出點,你就必須為每個退出點調用一次 unlock 方法,或許你可能在某個地方忘掉了 unlock ,那么各種悲劇即將發生,悲劇發生將直接導致程序死鎖。
接下來我們看如何解決這個問題。
自動鎖管理
當你需要包含整段的代碼(在我們這里是一個方法,也可能是一個循環體或者其他的控制結構),有這么一種好的解決方法可以避免忘記釋放鎖,那就是 std::lock_guard.
這個類是一個簡單的智能鎖管理器,但創建 std::lock_guard 時,會自動調用互斥量對象的 lock() 方法,當 lock_guard 析構時會自動釋放鎖,請看下面代碼:
- struct ConcurrentSafeCounter {
- std::mutex mutex;
- Counter counter;
- void increment(){
- std::lock_guard<std::mutex> guard(mutex);
- counter.increment();
- }
- void decrement(){
- std::lock_guard<std::mutex> guar(mutex);
- mutex.unlock();
- }
- };
是不是看起來爽多了?
使用 lock_guard ,你不再需要考慮什么時候要釋放鎖,這個工作已經由 std::lock_guard 實例幫你完成。
結論
在這篇文章中我們學習了如何通過信號量/互斥量來保護共享數據。需要記住的是,使用鎖會降低程序性能。在一些高并發的應用環境中有其他更好的解決辦法,不過這不在本文的討論范疇之內。
你可以在 Github 上獲取本文的源碼.
新聞熱點
疑難解答