c-- 多線程編程中鎖的基本類型和用法

線程之間的鎖有:互斥鎖、條件鎖、自旋鎖、讀寫鎖、遞歸鎖。一般而言,鎖的功能與性能成反比。不過我們一般不使用遞歸鎖(C++ 標準庫提供了 std::recursive_mutex),所以這裏就不推薦了。

互斥鎖(Mutex)


互斥鎖用於控制多個線程對他們之間共享資源互斥訪問的一個信號量。也就是說是爲了避免多個線程在某一時刻同時操作一個共享資源。例如線程池中的有多個空閒線程和一個任務隊列。任何是一個線程都要使用互斥鎖互斥訪問任務隊列,以避免多個線程同時訪問任務隊列以發生錯亂。

在某一時刻,只有一個線程可以獲取互斥鎖,在釋放互斥鎖之前其他線程都不能獲取該互斥鎖。如果其他線程想要獲取這個互斥鎖,那麼這個線程只能以阻塞方式進行等待。

頭文件:

類型:std::mutex

用法:在 C++ 中,通過構造 std::mutex 的實例創建互斥元,調用成員函數 lock()來鎖定它,調用 unlock()來解鎖,不過一般不推薦這種做法,標準 C++ 庫提供了 std::lock_guard 和 unique_lock 類模板,都是 RAII 風格,它們是在定義時獲得鎖,在析構時釋放鎖。它們的主要區別在於 unique_lock 鎖機制更加靈活,可以再需要的時候進行 lock 或者 unlock 調用,不非得是析構或者構造時。std::mutex 和 std::lock _ guard。都聲明在 < mutex > 頭文件中。

//用互斥元保護列表
#include <list>
#include <mutex>
 
std::list<int> some_list;
std::mutex some_mutex;
 
void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    some_list.push_back(new_value);
}

以下情況會出現死鎖:

mutex m0,m1;
int i = 0;
void fun0()
{
	while (i < 100)
	{
		lock_guard<mutex> g0(m0);  //線程0加鎖0
		lock_guard<mutex> g1(m1);  //線程0加鎖1
		cout << "thread 0 running..." << endl;
	}
	return;
}
void fun1()
{
	while (i < 100)
	{
		lock_guard<mutex> g1(m1);  //線程1加鎖1
		lock_guard<mutex> g0(m0);  //線程1加鎖0
		cout << "thread 1 running...   "<< i << endl;
	}
	return;
}
int main()
{
	thread p0(fun0);
	thread p1(fun1);
	p0.join();
	p1.join();
    return 0;
}

死鎖:死鎖是指兩個或兩個以上的進程(線程)在運行過程中因爭奪資源而造成的一種僵局,若無外力作用,這些進程(線程)都將無法向前推進。

解決死鎖的方法:

1、順序加鎖

mutex m0,m1;
int i = 0;
void fun0()
{
	while (i < 100)
	{
		lock_guard<mutex> g0(m0);  //線程0加鎖0
		lock_guard<mutex> g1(m1);  //線程0加鎖1
		cout << "thread 0 running..." << endl;
	}
	return;
}
void fun1()
{
	while (i < 100)
	{
                lock_guard<mutex> g0(m0);  //線程1加鎖0
		lock_guard<mutex> g1(m1);  //線程1加鎖1
		cout << "thread 1 running...   "<< i << endl;
	}
	return;
}
int main()
{
	thread p0(fun0);
	thread p1(fun1);
	p0.join();
	p1.join();
    return 0;
}

2、同時上鎖(需要用到 lock 函數)++

mutex m0,m1;
int i = 0;
void fun0()
{
	while (i < 100)
	{
                lock(m0,m1);
		lock_guard<mutex> g0(m0, adopt_lock);
		lock_guard<mutex> g1(m1, adopt_lock);
		cout << "thread 0 running..." << endl;
	}
	return;
}
void fun1()
{
	while (i < 100)
	{
                lock(m0,m1);
		lock_guard<mutex> g0(m0, adopt_lock);
		lock_guard<mutex> g1(m1, adopt_lock);
		cout << "thread 1 running...   "<< i << endl;
	}
	return;
}
int main()
{
	thread p0(fun0);
	thread p1(fun1);
	p0.join();
	p1.join();
    return 0;
}

注意到這裏的 lock_guard 中多了第二個參數 adopt_lock,這個參數表示在調用 lock_guard 時,已經加鎖了,防止 lock_guard 在對象生成時構造函數再次 lock()。

條件鎖


當需要死循環判斷某個條件成立與否時【true or false】,我們往往需要開一個線程死循環來判斷,這樣非常消耗 CPU。使用條件變量,可以讓當前線程 wait,釋放 CPU,如果條件改變時,我們再 notify 退出線程,再次進行判斷。

條件鎖就是所謂的條件變量,某一個線程因爲某個條件未滿足時可以使用條件變量使該程序處於阻塞狀態。一旦條件滿足以 “信號量” 的方式喚醒一個因爲該條件而被阻塞的線程 (常和互斥鎖配合使用),喚醒後,需要檢查變量,避免虛假喚醒。最爲常見就是在線程池中,起初沒有任務時任務隊列爲空,此時線程池中的線程因爲“任務隊列爲空” 這個條件處於阻塞狀態。一旦有任務進來,就會以信號量的方式喚醒一個線程來處理這個任務。

頭文件:<condition_variable>

類型:std::condition_variable(只和 std::mutex 一起工作) 和 std::condition_variable_any(符合類似互斥元的最低標準的任何東西一起工作)。

C++ 標準庫在 <condition_variable> 中提供了條件變量,藉由它,一個線程可以喚醒一個或多個其他等待中的線程。

想要修改共享變量(即 “條件”)的線程必須:

  1. 獲得一個 std::mutex

  2. 當持有鎖的時候,執行修改動作

  3. 對 std::condition_variable 執行 notify_one 或 notify_all(當做 notify 動作時,不必持有鎖)

即使共享變量是原子性的,它也必須在 mutex 的保護下被修改,這是爲了能夠將改動正確發佈到正在等待的線程。

任意要等待 std::condition_variable 的線程必須:

  1. 獲取 std::unique_lockstd::mutex,這個 mutex 正是用來保護共享變量(即 “條件”)的

  2. 執行 wait, wait_for 或者 wait_until. 這些等待動作原子性地釋放 mutex,並使得線程的執行暫停

  3. 當獲得條件變量的通知,或者超時,或者一個虛假的喚醒,那麼線程就會被喚醒,並且獲得 mutex. 然後線程應該檢查條件是否成立,如果是虛假喚醒,就繼續等待。

【注:所謂虛假喚醒,就是因爲某種未知的罕見的原因,線程被從等待狀態喚醒了,但其實共享變量(即條件)並未變爲 true。因此此時應繼續等待】

std::deque<int> q;
std::mutex mu;
std::condition_variable cond;
 
void function_1() //生產者
{
    int count = 10;
    while (count > 0) 
    {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  // Notify one waiting thread, if there is one.
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}
 
void function_2() //消費者
{
    int data = 0;
    while (data != 1) 
    {
        std::unique_lock<std::mutex> locker(mu);
        while (q.empty())
            cond.wait(locker); // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}
int main() 
{
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

上面的代碼有三個注意事項:

  1. 在 function_2 中,在判斷隊列是否爲空的時候,使用的是 while(q.empty()),而不是 if(q.empty()),這是因爲 wait() 從阻塞到返回,不一定就是由於 notify_one() 函數造成的,還有可能由於系統的不確定原因喚醒(可能和條件變量的實現機制有關),這個的時機和頻率都是不確定的,被稱作僞喚醒。如果在錯誤的時候被喚醒了,執行後面的語句就會錯誤,所以需要再次判斷隊列是否爲空,如果還是爲空,就繼續 wait() 阻塞;

  2. 在管理互斥鎖的時候,使用的是 std::unique_lock 而不是 std::lock_guard, 而且事實上也不能使用 std::lock_guard。這需要先解釋下 wait() 函數所做的事情,可以看到,在 wait() 函數之前,使用互斥鎖保護了,如果 wait 的時候什麼都沒做,豈不是一直持有互斥鎖?那生產者也會一直卡住,不能夠將數據放入隊列中了。所以,wait() 函數會先調用互斥鎖的 unlock() 函數,然後再將自己睡眠,在被喚醒後,又會繼續持有鎖,保護後面的隊列操作。lock_guard 沒有 lock 和 unlock 接口,而 unique_lock 提供了,這就是必須使用 unique_lock 的原因;

  3. 使用細粒度鎖,儘量減小鎖的範圍,在 notify_one() 的時候,不需要處於互斥鎖的保護範圍內,所以在喚醒條件變量之前可以將鎖 unlock()。

自旋鎖


假設我們有一個兩個處理器 core1 和 core2 計算機,現在在這臺計算機上運行的程序中有兩個線程:T1 和 T2 分別在處理器 core1 和 core2 上運行,兩個線程之間共享着一個資源。

首先我們說明互斥鎖的工作原理,互斥鎖是是一種 sleep-waiting 的鎖。假設線程 T1 獲取互斥鎖並且正在 core1 上運行時,此時線程 T2 也想要獲取互斥鎖(pthread_mutex_lock),但是由於 T1 正在使用互斥鎖使得 T2 被阻塞。當 T2 處於阻塞狀態時,T2 被放入到等待隊列中去,處理器 core2 會去處理其他任務而不必一直等待(忙等)。也就是說處理器不會因爲線程阻塞而空閒着,它去處理其他事務去了。

而自旋鎖就不同了,自旋鎖是一種 busy-waiting 的鎖。也就是說,如果 T1 正在使用自旋鎖,而 T2 也去申請這個自旋鎖,此時 T2 肯定得不到這個自旋鎖。與互斥鎖相反的是,此時運行 T2 的處理器 core2 會一直不斷地循環檢查鎖是否可用(自旋鎖請求),直到獲取到這個自旋鎖爲止。

從 “自旋鎖” 的名字也可以看出來,如果一個線程想要獲取一個被使用的自旋鎖,那麼它會一致佔用 CPU 請求這個自旋鎖使得 CPU 不能去做其他的事情,直到獲取這個鎖爲止,這就是 “自旋” 的含義。

當發生阻塞時,互斥鎖可以讓 CPU 去處理其他的任務;而自旋鎖讓 CPU 一直不斷循環請求獲取這個鎖。通過兩個含義的對比可以我們知道 “自旋鎖” 是比較耗費 CPU 的。

// 用戶空間用 atomic_flag 實現自旋互斥
#include <thread>
#include <vector>
#include <iostream>
#include <atomic>
 
std::atomic_flag lock = ATOMIC_FLAG_INIT;
 
void f(int n)
{
    for (int cnt = 0; cnt < 100; ++cnt) {
        while (lock.test_and_set(std::memory_order_acquire))  // 獲得鎖
             ; // 自旋
        std::cout << "Output from thread " << n << '\n';
        lock.clear(std::memory_order_release);               // 釋放鎖
    }
}
 
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f, n);
    }
    for (auto& t : v) {
        t.join();
    }
}

說明:atomic 是 C++ 標準程序庫中的一個頭文件,定義了 C++11 標準中的一些表示線程、併發控制時原子操作的類與方法等。此頭文件主要聲明瞭兩大類原子對象:std::atomic 和 std::atomic_flag。

1、atomic_flag 類:是一種簡單的原子布爾類型,只支持兩種操作:test_and_set(flag=true) 和 clear(flag=false)。

2、std::atomic 類模板:std::atomic 既不可複製亦不可移動。atomic 對 int、char、bool 等數據結構進行了原子性封裝,在多線程環境中,對 std::atomic 對象的訪問不會造成競爭 - 冒險。利用 std::atomic 可實現數據結構的無鎖設計。

所謂的原子操作,取的就是 “原子是最小的、不可分割的最小個體” 的意義,它表示在多個線程訪問同一個全局資源的時候,能夠確保所有其他的線程都不在同一時間內訪問相同的資源。也就是他確保了在同一時刻只有唯一的線程對這個資源進行訪問。這有點類似互斥對象對共享資源的訪問的保護,但是原子操作更加接近底層,因而效率更高。使用原子操作能大大的提高程序的運行效率。

#include <iostream>
#include <ctime>
#include <vector>
#include <thread>
#include <atomic>
 
 
std::atomic<size_t> count(0);
 
void threadFun()
{
	for (int i = 0; i < 10000; i++)
		count++;
}
 
int main(void)
{
	clock_t start_time = clock();
 
	// 啓動多個線程
	std::vector<std::thread> threads;
	for (int i = 0; i < 10; i++)
		threads.push_back(std::thread(threadFun));
	for (auto&thad : threads)
		thad.join();
 
	// 檢測count是否正確 10000*10 = 100000
	std::cout << "count number:" << count << std::endl;
 
	clock_t end_time = clock();
	std::cout << "耗時:" << end_time - start_time << "ms" << std::endl;
 
	return 0;
}

讀寫鎖


先看看互斥鎖,它只有兩個狀態,要麼是加鎖狀態,要麼是不加鎖狀態。假如現在一個線程 a 只是想讀一個共享變量 i,因爲不確定是否會有線程去寫它,所以我們還是要對它進行加鎖。但是這時又有一個線程 b 試圖去讀共享變量 i,發現被鎖定了,那麼 b 不得不等到 a 釋放了鎖後才能獲得鎖並讀取 i 的值,但是兩個讀取操作即使是同時發生的,也並不會像寫操作那樣造成競爭,因爲它們不修改變量的值。所以我們期望在多個線程試圖讀取共享變量的時候,它們可以立刻獲取因爲讀而加的鎖,而不是需要等待前一個線程釋放。

讀寫鎖可以解決上面的問題。它提供了比互斥鎖更好的並行性。因爲以讀模式加鎖後,當有多個線程試圖再以讀模式加鎖時,並不會造成這些線程阻塞在等待鎖的釋放上。

讀寫鎖是多線程同步的另外一個機制。在一些程序中存在讀操作和寫操作問題,對某些資源的訪問會存在兩種可能情況,一種情況是訪問必須是排他的,就是獨佔的意思,這種操作稱作寫操作,另外一種情況是訪問方式是可以共享的,就是可以有多個線程同時去訪問某個資源,這種操作稱爲讀操作。這個問題模型是從對文件的讀寫操作中引申出來的。把對資源的訪問細分爲讀和寫兩種操作模式,這樣可以大大增加併發效率。讀寫鎖比互斥鎖適用性更高,並行性也更高。

需要注意的是,這裏只是說並行效率比互斥高,並不是速度一定比互斥鎖快,讀寫鎖更復雜,系統開銷更大。併發性好對於用戶體驗非常重要,假設互斥鎖需要 0.5 秒,使用讀寫鎖需要 0.8 秒,在類似學生管理系統的軟件中,可能 90% 的操作都是查詢操作。如果突然有 20 個查詢請求,使用的是互斥鎖,則最後的查詢請求被滿足需要 10 秒,估計沒人接收。使用讀寫鎖時,因爲讀鎖能多次獲得,所以 20 個請求中,每個請求都能在 1 秒左右被滿足,用戶體驗好的多。

讀寫鎖特點

1 如果一個線程用讀鎖鎖定了臨界區,那麼其他線程也可以用讀鎖來進入臨界區,這樣可以有多個線程並行操作。這個時候如果再用寫鎖加鎖就會發生阻塞。寫鎖請求阻塞後,後面繼續有讀鎖來請求時,這些後來的讀鎖都將會被阻塞。這樣避免讀鎖長期佔有資源,防止寫鎖飢餓。

2 如果一個線程用寫鎖鎖住了臨界區,那麼其他線程無論是讀鎖還是寫鎖都會發生阻塞。

頭文件:boost/thread/shared_mutex.cpp

類型:boost::shared_lock

用法:你可以使用 boost::shared_ mutex 的實例來實現同步,而不是使用 std::mutex 的實例。對於更新操作,std::lock_guard<boost::shared _mutex> 和 std::unique _lock< boost::shared _mutex > 可用於鎖定,以取代相應的 std::mutex 特化。這確保了獨佔訪問,就像 std::mutex 那樣。那些不需要更新數據結構的線程能夠轉而使用 boost::shared _lock< boost::shared _mutex > 來獲得共享訪問。這與 std::unique _lock 用起來正是相同的,除了多個線程在同一時間,同一 boost::shared _mutex 上可能會具有共享鎖。唯一的限制是,如果任意一個線程擁有一個共享鎖,試圖獲取獨佔鎖的線程會被阻塞,知道其他線程全都撤回它們的鎖。同樣的,如果一個線程具有獨佔鎖,其他線程都不能獲取共享鎖或獨佔鎖,直到第一個線程撤回它的鎖。

簡單的說:

shared_lock 是 read lock。被鎖後仍允許其他線程執行同樣被 shared_lock 的代碼。這是一般做讀操作時的需要。

unique_lock 是 write lock。被鎖後不允許其他線程執行被 shared_lock 或 unique_lock 的代碼。在寫操作時,一般用這個,可以同時限制 unique_lock 的寫和 share_lock 的讀。

遞歸鎖


std::recursive_mutex 與 std::mutex 一樣,也是一種可以被上鎖的對象,但是和 std::mutex 不同的是,std::recursive_mutex 允許同一個線程對互斥量多次上鎖(即遞歸上鎖),來獲得對互斥量對象的多層所有權,std::recursive_mutex 釋放互斥量時需要調用與該鎖層次深度相同次數的 unlock(),可理解爲 lock() 次數和 unlock() 次數相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。

例如函數 a 需要獲取鎖 mutex,函數 b 也需要獲取鎖 mutex,同時函數 a 中還會調用函數 b。如果使用 std::mutex 必然會造成死鎖。但是使用 std::recursive_mutex 就可以解決這個問題。

  1. C++ 中使用的鎖:mutex

鎖,是生活中應用十分廣泛的一種工具。鎖的本質屬性是爲事物提供 “訪問保護”,例如:大門上的鎖,是爲了保護房子免於不速之客的到訪;自行車的鎖,是爲了保護自行車只有 owner 纔可以使用;保險櫃上的鎖,是爲了保護裏面的合同和金錢等重要東西……

在 c++ 等高級編程語言中,鎖也是用來提供 “訪問保護” 的,不過被保護的東西不再是房子、自行車、金錢,而是內存中的各種變量。此外,計算機領域對於 “鎖” 有個響亮的名字——mutex(互斥量),學過操作系統的同學對這個名字肯定很熟悉。

Mutex,互斥量,就是互斥訪問的量。這種東東只在多線程編程中起作用,在單線程程序中是沒有什麼用處的。從 c++11 開始,c++ 提供了 std::mutex 類型,對於多線程的加鎖操作提供了很好的支持。下面看一個簡單的例子,對於 mutex 形成一個直觀的認識。

Demo1——無鎖的情況

假定有一個全局變量 counter,啓動兩個線程,每個都對該變量自增 10000 次,最後輸出該變量的值。在第一個 demo 中,我們不加鎖,代碼文件保存爲:mutex_demo1_no_mutex.cpp

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
 
int counter = 0;
void increase(int time) {
    for (int i = 0; i < time; i++) {
        // 當前線程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
    }
}
 
int main(int argc, char** argv) {
    std::thread t1(increase, 10000);
    std::thread t2(increase, 10000);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

爲了顯示多線程競爭導致結果不正確的現象,在每次自增操作的時候都讓當前線程休眠 1 毫秒

對應 CMakeLists.txt

# 聲明要求的 cmake 最低版本
cmake_minimum_required(VERSION 3.0.0)
# 聲明一個 cmake 工程
project(HelloMutex)
# 設置編譯模式
set(CMAKE_BUILD_TYPE "Debug")
# 語法:add_executable( 程序名 源代碼文件 )
add_executable(${PROJECT_NAME} mutex_demo1_no_mutex.cpp)
 
if(WIN32)
	set(PLATFROM_LIBS Ws2_32 mswsock iphlpapi ntdll)
else(WIN32)
	set(PLATFROM_LIBS pthread ${CAMKE_DL_LIBS})
endif(WIN32)
# 將庫文件鏈接到可執行程序上
target_link_libraries(${PROJECT_NAME} ${PLATFROM_LIBS})

如果沒有多線程編程的相關經驗,我們可能想當然的認爲最後的 counter 爲 20000,如果這樣想的話,那就大錯特錯了。下面是兩次實際運行的結果:

[root@2d129aac5cc5 demo]# ./mutex_demo1_no_mutex
counter:19997
[root@2d129aac5cc5 demo]# ./mutex_demo1_no_mutex
counter:19996

出現上述情況的原因是:自增操作 "counter++" 不是原子操作,而是由多條彙編指令完成的。多個線程對同一個變量進行讀寫操作就會出現不可預期的操作。以上面的 demo1 作爲例子:假定 counter 當前值爲 10,線程 1 讀取到了 10,線程 2 也讀取到了 10,分別執行自增操作,線程 1 和線程 2 分別將自增的結果寫回 counter,不管寫入的順序如何,counter 都會是 11,但是線程 1 和線程 2 分別執行了一次自增操作,我們期望的結果是 12!!!!!

輪到 mutex 上場。

Demo2——加鎖的情況

定義一個 std::mutex 對象用於保護 counter 變量。對於任意一個線程,如果想訪問 counter,首先要進行 "加鎖" 操作,如果加鎖成功,則進行 counter 的讀寫,讀寫操作完成後釋放鎖(重要!!!);如果 “加鎖” 不成功,則線程阻塞,直到加鎖成功。

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
 
int counter = 0;
std::mutex mtx; // 保護counter
 
void increase(int time) {
    for (int i = 0; i < time; i++) {
        mtx.lock();
        // 當前線程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
        mtx.unlock();
    }
}
 
int main(int argc, char** argv) {
    std::thread t1(increase, 10000);
    std::thread t2(increase, 10000);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

上述代碼保存文件爲:mutex_demo2_with_mutex.cpp。先來看幾次運行結果:

[root@2d129aac5cc5 demo]# ./mutex_demo2_with_mutex
counter:20000
[root@2d129aac5cc5 demo]# ./mutex_demo2_with_mutex
counter:20000
[root@2d129aac5cc5 demo]# ./mutex_demo2_with_mutex
counter:20000

這次運行結果和我們預想的一致,原因就是 “利用鎖來保護共享變量”,在這裏共享變量就是 counter(多個線程都能對其進行訪問,所以就是共享變量啦)。

簡單總結一些 std::mutex:

  1. lock_guard

雖然 std::mutex 可以對多線程編程中的共享變量提供保護,但是直接使用 std::mutex 的情況並不多。因爲僅使用 std::mutex 有時候會發生死鎖。回到上邊的例子,考慮這樣一個情況:假設線程 1 上鎖成功,線程 2 上鎖等待。但是線程 1 上鎖成功後,拋出異常並退出,沒有來得及釋放鎖,導致線程 2“永久的等待下去”(線程 2:我的心在等待永遠在等待……),此時就發生了死鎖。給一個發生死鎖的 :

Demo3——死鎖的情況(僅僅爲了演示,不要這麼寫代碼哦)

爲了捕捉拋出的異常,我們重新組織一下代碼,代碼保存爲: mutex_demo3_dead_lock.cpp。

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
 
int counter = 0;
std::mutex mtx; // 保護counter
 
void increase_proxy(int time, int id) {
    for (int i = 0; i < time; i++) {
        mtx.lock();
        // 線程1上鎖成功後,拋出異常:未釋放鎖
        if (id == 1) {
            throw std::runtime_error("throw excption....");
        }
        // 當前線程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
        mtx.unlock();
    }
}
 
void increase(int time, int id) {
    try {
        increase_proxy(time, id);
    }
    catch (const std::exception& e){
        std::cout << "id:" << id << ", " << e.what() << std::endl;
    }
}
 
int main(int argc, char** argv) {
    std::thread t1(increase, 10000, 1);
    std::thread t2(increase, 10000, 2);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

執行後,結果如下圖所示:

[root@2d129aac5cc5 demo]# ./mutex_demo3_dead_lock
id:1, throw excption....

程序並沒有退出,而是永遠的 “卡” 在那裏了,也就是發生了死鎖。

那麼這種情況該怎麼避免呢?這個時候就需要 std::lock_guard 登場了。std::lock_guard 只有構造函數和析構函數。簡單的來說:當調用構造函數時,會自動調用傳入的對象的 lock() 函數,而當調用析構函數時,自動調用 unlock() 函數(這就是所謂的 RAII,讀者可自行搜索)。我們修改一下 demo3。

Demo4——避免死鎖,lock_guard

demo4 保存爲:mutex_demo4_lock_guard.cpp

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>
 
int counter = 0;
std::mutex mtx; // 保護counter
 
void increase_proxy(int time, int id) {
    for (int i = 0; i < time; i++) {
        // std::lock_guard對象構造時,自動調用mtx.lock()進行上鎖
        // std::lock_guard對象析構時,自動調用mtx.unlock()釋放鎖
        std::lock_guard<std::mutex> lk(mtx);
        // 線程1上鎖成功後,拋出異常:未釋放鎖
        if (id == 1) {
            throw std::runtime_error("throw excption....");
        }
        // 當前線程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
    }
}
 
void increase(int time, int id) {
    try {
        increase_proxy(time, id);
    }
    catch (const std::exception& e){
        std::cout << "id:" << id << ", " << e.what() << std::endl;
    }
}
 
int main(int argc, char** argv) {
    std::thread t1(increase, 10000, 1);
    std::thread t2(increase, 10000, 2);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

執行上述代碼,結果爲:

[root@2d129aac5cc5 demo]# ./mutex_demo4_lock_guard
id:1, throw excption....
counter:10000

結果符合預期。所以,推薦使用 std::mutex 和 std::lock_guard 搭配使用,避免死鎖的發生。

  1. std::lock_guard 的第二個構造函數

實際上,std::lock_guard 有兩個構造函數,具體的(參考:cppreference):

explicit lock_guard( mutex_type& m );                   (1)      (since C++11)
lock_guard( mutex_type& m, std::adopt_lock_t t ); 	(2)      (since C++11)
lock_guard( const lock_guard& ) = delete;               (3)      (since C++11)

在 demo4 中我們使用了第 1 個構造函數,第 3 個爲拷貝構造函數,定義爲刪除函數。這裏我們來重點說一下第 2 個構造函數。

第 2 個構造函數有兩個參數,其中第二個參數類型爲:std::adopt_lock_t。這個構造函數假定:當前線程已經上鎖成功,所以不再調用 lock() 函數,這裏不再給出具體的例子。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/d1nb_t3GJOAY3TQ5ZQQLRw