C++ 的多執行序程式開發 Thread:多執行序之間的溝通(二)

| | 0 Comments| 13:01
Categories:

在上一篇《多執行序之間的溝通(一)》裡,Heresy 已經大概介紹過,最簡單的 mutexlock_guard 的機制了。不過,在該篇文章裡面提到的,只是最簡單的應用;在這邊,Heresy 會繼續在講一些這方面的進階功能。

其他種類的 mutex

首先,前面也有提到,除了基本的 std::mutex 外,實際上 STL Thread 裡面,也還有提供其他型別的 mutex 類別,包括了 timed_mutexrecursive_mutexrecursive_timed_mutex;實際上,這些延伸的 mutex 類別,就是如同字面上的意思,擁有支援「定時」、「遞迴」特性的 mutex。

像是以 timed_mutex 來說,他除了基本款 mutexlock()try_lock() 外,還額外提供了 try_lock_for()try_lock_until() 這兩個函式,可以設定在指定的時間內、試著去進行 mutex 的鎖定的動作。(參考

recursive_mutex 的話,則是可以讓 mutex 認得鎖住自己的執行序,並且讓 mutex 在已經被鎖定的情況下,還是可以讓同一個執行序再去鎖定他、而不會被擋下來。下面就是一個簡單的例子:

class ClassA
{
public:
void func1()
{
lock_guard<recursive_mutex> lock(mMutex);
}

void func2()
{
lock_guard<recursive_mutex> lock(mMutex);
func1();
}

private:
recursive_mutex mMutex;
};

ClassA 裡,有 func1()func2() 兩個函式,兩者都會去建立一個 lock_guard 的物件、來鎖定 mMutex 這個 mutex。不過,由於 func2() 裡、會去呼叫 func1(),所以如果去呼叫 func2() 的話,實際上可以發現,程式會在執行 func2() 的時候,透過 lock_guard 去鎖定 mMutex,而當在 func2() 內去呼叫 func1() 的時候,同樣的要求鎖定動作,又會再進行一次!

這時候如果是使用標準的 mutex 的話,在第二次試圖去鎖定 mMutex 的時候(func1()),會因為 mMutex 已經在 func2() 裡被鎖定了,而就因此停在這邊,等待 mMutex 被解除鎖定再繼續;但是由於 mMutex 的鎖定狀態必須要等到 func2() 整個執行完成後才會解除,所以這邊就會變成永遠等不完的狀況,讓程式無法繼續執行。

但是因為這邊用的是 recursive_mutex,所以在第二次、也就是在 func1() 裡試著去鎖定 mMutex 的時候,系統會判斷出目前是由同一個執行序所鎖定的,所以就讓它繼續執行下去、不會出問題。

而最後的 recursive_timed_mutex,顧名思義,就是同時具有 timed_mutexrecursive_mutex 兩種特殊性質的 mutex 類別了~


更彈性的 unique_lock

除了 mutex 有四種不同的類型外,其實在 STL Thread 裡,除了 lock_guard 可以用來做 mutex 的自動管理外,還有另一個 unique_lock參考),也是用來做類似的工作的,而且 unique_lock 在使用上的彈性,會比 lock_guard 來的大。

lock_guard 相比,unique_lock 主要的特色在於:

  • unique_lock 不一定要擁有 mutex,所以可以透過 default constructor 建立出一個空的 unique_lock

  • unique_lock 雖然一樣不可複製(non-copyable),但是它是可以轉移的(movable)。所以,unique_lock 不但可以被函式回傳,也可以放到 STL 的 container 裡。

這兩點,都是 lock_guard 所缺乏的能力。另外,unique_lock 也有提供 lock()unlock() 等函式,可以用來手動鎖定、解鎖 mutex,也算是功能比較完整的地方。


同時鎖定多個 mutex

雖然一般狀況下,大多是一次去鎖定一個 mutex 來使用的。但是在實際使用的時候,有的時候還是會需要同時去鎖定多個 mutex,來避免資料不同步的問題。例如,在 cppreference 就有提出一個銀行帳號例子(網頁),在這種狀況,就必須要同時鎖定「轉出者」和「轉入者」兩者個 mutex,否則會有問題。

不過由於他的範例程式在 VC11 無法正確地編譯(gcc 4.6.3 好像也不行…),所以 Heresy 自己做了一些修改:

#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>
#include <string>

using namespace std;

struct bank_account
{
explicit bank_account(string name, int money)
{
sName =
name;
iMoney =
money;
}

string sName;
int iMoney;
mutex mMutex;
};

void transfer( bank_account &from, bank_account &to, int amount )
{
// don't actually take the locks yet
unique_lock<mutex> lock1( from.mMutex, defer_lock );
unique_lock<mutex> lock2( to.mMutex, defer_lock );

// lock both unique_locks without deadlock
lock( lock1, lock2 );

from.iMoney -= amount;
to.iMoney = amount;

// output log
cout <<
"Transfer " << amount << " from "
<<
from.sName << " to " << to.sName << endl;
}

int main()
{
bank_account Account1( "User1", 100 );
bank_account Account2( "User2", 50 );

thread t1( [&](){ transfer( Account1, Account2, 10 ); } );
thread t2( [&](){ transfer( Account2, Account1, 5 ); } );

t1.join();
t2.join();
}

由於在進行轉帳(transfer())的時候,必須要先鎖定兩個帳號,然後再做數值的修改,所以這邊的範例,是在 transfer() 裡,先透過 unique_lock 來管理兩個帳號的 mutex;不過要注意的是,比較不一樣的地方,是在建立 unique_lock 物件的時候,他還加上了第二個參數 defer_lock

這個參數的目的,是告訴 unique_lock 雖然要讓他去管理指定的 mutex 物件,但是不要立刻去鎖定他、而是維持沒有鎖定的狀態。而接下來,則是再透過 lock() 這個函式,來同時鎖定 lock1lock2 這兩個 unique_lock 物件。

為什麼需要這樣做,而不直接寫成:

lock_guard<mutex> lock1( from.mMutex );
lock_guard<mutex> lock2( to.mMutex );

呢?因為如果用上面的寫法,直接依序各自鎖定兩個 mutex 的話,有可能會在多執行序的情況下,產生 dead lock 的狀況。

舉例來說,如果同時要求進行「由 A 轉帳給 B」(thread 1)和「由 B 轉帳給 A」(thread 2)的動作的話,有可能會產生一個狀況,就是在 thread 1 裡面已經鎖定了 A 的 mutex,但是在試圖鎖定 B 的 mutex 的時候,thread 2 已經鎖定了 B 的 mutex;而同樣地,這時候 thread 2 也需要去鎖定 A 的 mutex,但是他卻已經被 thread 1 鎖定了。

如此一來,就會變成 thread 1 鎖著 A 的 mutex 在等 thread 2 把 B 的 mutex 解鎖,而 thread 2 鎖著 B 的 mutex 在等 thread 1 把 A 的 mutex 解鎖的狀況…這樣的情況,基本上是無解的。

而透過像上面這樣的方法,使用 lock() 這個函式,就可以一口氣把多個 mutex 物件進行鎖定,並免這樣的狀況發生。而實際上,在這種狀況下,使用 unique_lock 也僅只是用來自動管理 mutex 的一種方法,所以其實也還是有其他寫法的~

例如,如果是要使用 lock_guard 的話,就可以寫成(參考):

lock( from.mMutex, to.mMutex );
lock_guard<mutex> lock1( from.mMutex, adopt_lock );
lock_guard<mutex> lock2( to.mMutex, adopt_lock );

這邊的做法,是先透過 lock() 去對兩個帳號的 mutex 物件直接做鎖定的動作,然後再建立 lock_guard 的物件、來做自動釋放的管理。這邊要注意的是,在建立 lock_guard 物件的時候,需要指定第二個參數 adopt_lock,告訴 lock_guard 目前的執行序已經鎖定了這個 mutex,所以不需要再去要求鎖定一次,只要之後自動解除鎖定就可以了。

而如果不想使用 lock_guardunique_lock 來做自動解鎖的話,也可以自己之後手動各自呼叫 mutex::unlock() 來做解除鎖定的動作,也就是:

lock( from.mMutex, to.mMutex );
//......
from.mMutex.unlock();
to.mMutex.unlock();

這篇就先寫到這了。實際上,在 C 11 STL 裡面,還有不少和 thread 相關的東西,像是 call once、atomic、condition variables、futures 等等(參考);不過因為 Heresy 自己也還沒研究完,所以之後有機會研究完之後,再來分享吧~

1 thought on “C++ 的多執行序程式開發 Thread:多執行序之間的溝通(二)”

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *