轉載來自:https://subingwen.cn/cpp/mutex/
進行多線程編程,如果多個線程需要對同一塊內存進行操作,比如:同時讀、同時寫、同時讀寫對於后兩種情況來說,如果不做任何的人為干涉就會出現各種各樣的錯誤數據。這是因為線程在運行的時候需要先得到 CPU 時間片,時間片用完之后需要放棄已獲得的 CPU 資源,就這樣線程頻繁地在就緒態和運行態之間切換,更復雜一點還可以在就緒態、運行態、掛起態之間切換,這樣就會導致線程的執行順序並不是有序的,而是隨機的混亂的,就如同下圖中的這個例子一樣,理想很豐滿現實卻很殘酷。

解決多線程數據混亂的方案就是進行線程同步,最常用的就是互斥鎖,在 C++11 中一共提供了四種互斥鎖:
std::mutex:獨占的互斥鎖,不能遞歸使用
std::timed_mutex:帶超時的獨占互斥鎖,不能遞歸使用
std::recursive_mutex:遞歸互斥鎖,不帶超時功能
std::recursive_timed_mutex:帶超時的遞歸互斥鎖
互斥鎖在有些資料中也被稱之為互斥量,二者是一個東西。
如果對線程同步還一無所知,建議先看一下這篇文章:
https://subingwen.cn/linux/thread-sync/
多線程編程導致數據錯亂的原因剖析,以及線程同步基本常識
1. std::mutex
不論是在 C 還是 C++ 中,進行線程同步的處理流程基本上是一致的,C++ 的 mutex 類提供了相關的 API 函數:
1.1 成員函數
lock() 函數用於給臨界區加鎖,並且只能有一個線程獲得鎖的所有權,它有阻塞線程的作用,函數原型如下:
void lock();
獨占互斥鎖對象有兩種狀態:鎖定和未鎖定。如果互斥鎖是打開的,調用 lock() 函數的線程會得到互斥鎖的所有權,並將其上鎖,其它線程再調用該函數的時候由於得不到互斥鎖的所有權,就會被 lock() 函數阻塞。當擁有互斥鎖所有權的線程將互斥鎖解鎖,此時被 lock() 阻塞的線程解除阻塞,搶到互斥鎖所有權的線程加鎖並繼續運行,沒搶到互斥鎖所有權的線程繼續阻塞。
除了使用 lock() 還可以使用 try_lock() 獲取互斥鎖的所有權並對互斥鎖加鎖,函數原型如下:
bool try_lock();
二者的區別在於 try_lock() 不會阻塞線程,lock() 會阻塞線程:
如果互斥鎖是未鎖定狀態,得到了互斥鎖所有權並加鎖成功,函數返回 true
如果互斥鎖是鎖定狀態,無法得到互斥鎖所有權加鎖失敗,函數返回 false
當互斥鎖被鎖定之后可以通過 unlock() 進行解鎖,但是需要注意的是只有擁有互斥鎖所有權的線程也就是對互斥鎖上鎖的線程才能將其解鎖,其它線程是沒有權限做這件事情的。該函數的函數原型如下:
void unlock();
通過介紹以上三個函數,使用互斥鎖進行線程同步的大致思路差不多就能搞清楚了,主要分為以下幾步:
找到多個線程操作的共享資源(全局變量、堆內存、類成員變量等),也可以稱之為臨界資源
找到和共享資源有關的上下文代碼,也就是臨界區(下圖中的黃色代碼部分)
在臨界區的上邊調用互斥鎖類的 lock() 方法
在臨界區的下邊調用互斥鎖的 unlock() 方法
線程同步的目的是讓多線程按照順序依次執行臨界區代碼,這樣做線程對共享資源的訪問就從並行訪問變為了線性訪問,訪問效率降低了,但是保證了數據的正確性。
當線程對互斥鎖對象加鎖,並且執行完臨界區代碼之后,一定要使用這個線程對互斥鎖解鎖,否則最終會造成線程的死鎖。死鎖之后當前應用程序中的所有線程都會被阻塞,並且阻塞無法解除,應用程序也無法繼續運行。
1.2 線程同步
舉個栗子,我們讓兩個線程共同操作同一個全局變量,二者交替數數,將數值存儲到這個全局變量里邊並打印出來。
#include <iostream> #include <chrono> #include <thread> #include <mutex> using namespace std; int g_num = 0; // 為 g_num_mutex 所保護 mutex g_num_mutex; void slow_increment(int id) { for (int i = 0; i < 3; ++i) { g_num_mutex.lock(); ++g_num; cout << id << " => " << g_num << endl; g_num_mutex.unlock(); this_thread::sleep_for(chrono::seconds(1)); } } int main() { thread t1(slow_increment, 0); thread t2(slow_increment, 1); t1.join(); t2.join(); }
0 => 1 1 => 2 0 => 3 1 => 4 1 => 5 0 => 6
在上面的示例程序中,兩個子線程執行的任務的一樣的(其實也可以不一樣,不同的任務中也可以對共享資源進行讀寫操作),在任務函數中把與全局變量相關的代碼加了鎖,兩個線程只能順序訪問這部分代碼(如果不進行線程同步打印出的數據是混亂且無序的)。另外需要強調一點:
在所有線程的任務函數執行完畢之前,互斥鎖對象是不能被析構的,一定要在程序中保證這個對象的可用性。
互斥鎖的個數和共享資源的個數相等,也就是說每一個共享資源都應該對應一個互斥鎖對象。互斥鎖對象的個數和線程的個數沒有關系。
2. std::lock_guard
lock_guard 是 C++11 新增的一個模板類,使用這個類,可以簡化互斥鎖 lock() 和 unlock() 的寫法,同時也更安全。這個模板類的定義和常用的構造函數原型如下:
// 類的定義,定義於頭文件 <mutex> template< class Mutex > class lock_guard; // 常用構造函數 explicit lock_guard( mutex_type& m );
lock_guard 在使用上面提供的這個構造函數構造對象時,會自動鎖定互斥量,而在退出作用域后進行析構時就會自動解鎖,從而保證了互斥量的正確操作,避免忘記 unlock() 操作而導致線程死鎖。lock_guard 使用了 RAII 技術,就是在類構造函數中分配資源,在析構函數中釋放資源,保證資源出了作用域就釋放。
使用 lock_guard 對上面的例子進行修改,代碼如下:
void slow_increment(int id) { for (int i = 0; i < 3; ++i) { // 使用哨兵鎖管理互斥鎖 lock_guard<mutex> lock(g_num_mutex); ++g_num; cout << id << " => " << g_num << endl; this_thread::sleep_for(chrono::seconds(1)); } }
0 => 1 0 => 2 0 => 3 1 => 4 1 => 5 1 => 6
或者
1 => 1
1 => 2
1 => 3
0 => 4
0 => 5
0 => 6
通過修改發現代碼被精簡了,而且不用擔心因為忘記解鎖而造成程序的死鎖,但是這種方式也有弊端,在上面的示例程序中整個for循環的體都被當做了臨界區,多個線程是線性的執行臨界區代碼的,因此臨界區越大程序效率越低,還是需要根據實際情況選擇最優的解決方案。
3. std::recursive_mutex
遞歸互斥鎖 std::recursive_mutex 允許同一線程多次獲得互斥鎖,可以用來解決同一線程需要多次獲取互斥量時死鎖的問題,在下面的例子中使用獨占非遞歸互斥量會發生死鎖:
死鎖
#include <iostream> #include <thread> #include <mutex> using namespace std; struct Calculate { Calculate() : m_i(6) {} void mul(int x) { lock_guard<mutex> locker(m_mutex); m_i *= x; } void div(int x) { lock_guard<mutex> locker(m_mutex); m_i /= x; } void both(int x, int y) { lock_guard<mutex> locker(m_mutex); mul(x); div(y); } int m_i; mutex m_mutex; }; int main() { Calculate cal; cal.both(6, 3); return 0; }
上面的程序中執行了 cal.both(6, 3); 調用之后,程序就會發生死鎖,在 both() 中已經對互斥鎖加鎖了,繼續調用 mult() 函數,已經得到互斥鎖所有權的線程再次獲取這個互斥鎖的所有權就會造成死鎖(在 C++ 中程序會異常退出,使用 C 庫函數會導致這個互斥鎖永遠無法被解鎖,最終阻塞所有的線程)。要解決這個死鎖的問題,一個簡單的辦法就是使用遞歸互斥鎖 std::recursive_mutex,它允許一個線程多次獲得互斥鎖的所有權。修改之后的代碼如下:
#include <iostream> #include <thread> #include <mutex> using namespace std; struct Calculate { Calculate() : m_i(6) {} void mul(int x) { lock_guard<recursive_mutex> locker(m_mutex); m_i *= x; } void div(int x) { lock_guard<recursive_mutex> locker(m_mutex); m_i /= x; } void both(int x, int y) { lock_guard<recursive_mutex> locker(m_mutex); mul(x); div(y); } int m_i; recursive_mutex m_mutex; }; int main() { Calculate cal; cal.both(6, 3); cout << "cal.m_i = " << cal.m_i << endl; return 0; }
雖然遞歸互斥鎖可以解決同一個互斥鎖頻繁獲取互斥鎖資源的問題,但是還是建議少用,主要原因如下:
使用遞歸互斥鎖的場景往往都是可以簡化的,使用遞歸互斥鎖很容易放縱復雜邏輯的產生,從而導致bug的產生
遞歸互斥鎖比非遞歸互斥鎖效率要低一些。
遞歸互斥鎖雖然允許同一個線程多次獲得同一個互斥鎖的所有權,但最大次數並未具體說明,一旦超過一定的次數,就會拋出std::system錯誤。
4. std::timed_mutex
std::timed_mutex 是超時獨占互斥鎖,主要是在獲取互斥鎖資源時增加了超時等待功能,因為不知道獲取鎖資源需要等待多長時間,為了保證不一直等待下去,設置了一個超時時長,超時后線程就可以解除阻塞去做其他事情了。
std::timed_mutex 比 std::_mutex 多了兩個成員函數:try_lock_for() 和 try_lock_until():
void lock(); bool try_lock(); void unlock(); // std::timed_mutex比std::_mutex多出的兩個成員函數 template <class Rep, class Period> bool try_lock_for(const chrono::duration<Rep, Period>& rel_time); template <class Clock, class Duration> bool try_lock_until(const chrono::time_point<Clock, Duration>& abs_time);
try_lock_for 函數是當線程獲取不到互斥鎖資源的時候,讓線程阻塞一定的時間長度
try_lock_until 函數是當線程獲取不到互斥鎖資源的時候,讓線程阻塞到某一個指定的時間點
關於兩個函數的返回值:當得到互斥鎖的所有權之后,函數會馬上解除阻塞,返回 true,如果阻塞的時長用完或者到達指定的時間點之后,函數也會解除阻塞,返回 false
下面的示例程序中為大家演示了 std::timed_mutex 的使用:
#include <iostream> #include <thread> #include <mutex> using namespace std; timed_mutex g_mutex; void work() { chrono::seconds timeout(1); while (true) { // 通過阻塞一定的時長來爭取得到互斥鎖所有權 if (g_mutex.try_lock_for(timeout)) { cout << "當前線程ID: " << this_thread::get_id() << ", 得到互斥鎖所有權..." << endl; // 模擬處理任務用了一定的時長 this_thread::sleep_for(chrono::seconds(10)); // 互斥鎖解鎖 g_mutex.unlock(); break; } else { cout << "當前線程ID: " << this_thread::get_id() << ", 沒有得到互斥鎖所有權..." << endl; // 模擬處理其他任務用了一定的時長 this_thread::sleep_for(chrono::milliseconds(50)); } } } int main() { thread t1(work); thread t2(work); t1.join(); t2.join(); return 0; }
示例代碼輸出的結果:
當前線程ID: 24092, 得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 沒有得到互斥鎖所有權... 當前線程ID: 23748, 得到互斥鎖所有權...
在上面的例子中,通過一個 while 循環不停的去獲取超時互斥鎖的所有權,如果得不到就阻塞 1 秒鍾,1 秒之后如果還是得不到阻塞 50 毫秒,然后再次繼續嘗試,直到獲得互斥鎖的所有權,跳出循環體。
關於遞歸超時互斥鎖 std::recursive_timed_mutex 的使用方式和 std::timed_mutex 是一樣的,只不過它可以允許一個線程多次獲得互斥鎖所有權,而 std::timed_mutex 只允許線程獲取一次互斥鎖所有權。另外,遞歸超時互斥鎖 std::recursive_timed_mutex 也擁有和 std::recursive_mutex 一樣的弊端,不建議頻繁使用。
