1. 程式人生 > >對C++鎖的一些思考

對C++鎖的一些思考

C++中的多執行緒程式設計是一個相對複雜,坑比較多,並且出現問題較難排查的一個程式設計領域,也是c++編寫大型專案中避不開的部分。

加鎖的方式有很多,應用場景也各都稍有不同,根據不同維度(或者側重點,比如是原子化操作還是通知,技術屬於樂觀鎖還是悲觀鎖,核心級別還是使用者級別,linux平臺還是win平臺)可以分成很多類別,但其作用都是服務於多執行緒程式的穩定執行,所以一般都統稱為“鎖”。

比如原子型別(使用場景如線上程併發環境下的任務ID生成器class idGenerator,內部的計數器應該使用std::atomic_int類似的型別才能保證執行緒安全),在使用中基本看不到鎖的操作,也一樣納入到鎖的範疇,而且,它還是多種值得研究的鎖分類的一個典型代表,比如樂觀鎖,自旋鎖,使用者態鎖等。

最近有同事討論std::shared_ptr是不是執行緒安全的。我之前一直篤信它是執行緒安全的,所以在程式碼中用得很放心。但為了一探究竟,從程式碼層面理清這個問題,還是有待好好鑽研一下。還好能找到stl的程式碼,在大致看過之後,我還是篤信它是執行緒安全的。當然,我的“篤信”在於它互相之間拷貝和移動中引用計數維護的方面。而至於其中存放的實際資料,則沒有任何的安全策略。那麼是不是說它就不執行緒安全了呢?

這個問題我覺得應該從std::shared_ptr資料結構本質上來說,C++一系列的智慧指標都旨在代替各種使用場景下的原始指標的使用,那麼如果要問std::shared_ptr存放的資料是否執行緒安全,可以先想想int*型別的臨界資源是否執行緒安全。答案顯而易見,並不是。它的執行緒安全需要你自己加鎖維護。

不知道我有沒有表述清楚,本人一向口拙筆拙,其實意思就是關於std::shared_ptr的執行緒安全問題,需要從兩方面考慮,第一個是stl提供的這個工具類,對它的執行緒安全考慮主要就是併發下的引用計數是否準確問題,當然,它是執行緒安全的;而第二個是它作為一個指標指向的內容是否執行緒安全,而這一方面,工具類是沒有為你做任何事情的。

對於之前一篇博文,其中實現的ttl cache程式碼再研究了一下,果然還是發現了多執行緒下的漏洞,在於併發下呼叫_CheckInConstructor()和_CheckInDestructor()時,其中use_count()並不一定是出現滿足條件的值。而這個問題的修改又並不是一個簡單的加解鎖就解決的,因為對其中智慧指標構造(拷貝)導致引用計數變化的程式碼在建構函式的初始化列表裡,而獲取引用計數的程式碼在函式體內部。問題變得有點棘手。

為了看起來簡單,我寫了個簡單的抽象的問題描述demo:

atomic_int g_count = 0;

template<int n>
class N { public: N(int p = 0) { (n % 2) ? (++g_count) : (--g_count); } };

class T
{
public:
	T() :n1(0), n2(0), n3(0), n4(0), n5(0), n6(0), n7(0), n8(0) { assert(g_count == 0); }
private:
	N<1> n1; N<2> n2; N<3> n3; N<4> n4; N<5> n5; N<6> n6; N<7> n7; N<8> n8;
};
在單執行緒中,T型別建構函式中的assert永遠不會觸發,但是在多執行緒的併發環境下,如果某一個執行緒在初始化列表n1~n8構造過程中有另一個執行緒搶到了cpu時間片,恰好不對稱的改變了g_count,則assert便會觸發。實驗結果是極其容易引發斷言,測試程式碼如下:
void test()
{
	int count = 10;
	std::vector<std::thread*> tv;
	tv.resize(count);
	for (int i = 0; i < count; ++i)
	{
		tv[i] = new std::thread([]() 
		{
			for (int j = 0; j < 1000; ++j)
			{
				T t;
				std::this_thread::sleep_for(std::chrono::milliseconds(10));
			}
		});
	}
	for (int k = 0; k < count; ++k)
	{
		if (tv[k])
		{
			tv[k]->join();
			delete tv[k];
		}
	}
}

對於這個問題,在T建構函式的assert前或者後加鎖都是沒有任何作用的,並不能達到對初始化列表和函式體內程式碼同時原子化的目的(其實連單獨對初始化列表的原子化都達不到)。

不過可能我運氣一向比較好,在思忖好一陣,也就在快要放棄,打算推倒重來的時候,靈光乍現,有了!程式碼改成下面這樣:

std::atomic_int g_count = 0;
std::mutex g_mutex;


template<int n>
class N { public: N(int p = 0) { (n % 2) ? (++g_count) : (--g_count); } };

template<typename LOCK> class OnlyLock { public: OnlyLock(LOCK& l) { l.lock(); } };
template<typename LOCK> class OnlyUnLock { public: OnlyUnLock(LOCK& l) { l.unlock(); } };

class T
{
public:
	T() :l(g_mutex), n1(0), n2(0), n3(0), n4(0), n5(0), n6(0), n7(0), n8(0) { assert(g_count == 0); OnlyUnLock<std::mutex> l(g_mutex); }
private:
	OnlyLock<std::mutex> l;
	N<1> n1; N<2> n2; N<3> n3; N<4> n4; N<5> n5; N<6> n6; N<7> n7; N<8> n8;
};

因為c++的類成員初始化順序是和宣告順序一致的,所以OnlyLock變數放在其他所有成員變數之前就好了,最後在建構函式體最末尾OnlyUnLock。

但是還是有個問題,如果T類有基類,而且在基類建構函式中對臨界資源值有修改,同時要在T類建構函式中取值並且保證整個物件構造過程的原子性,上面的技巧則是不能達到要求的,除非去修改基類,把OnlyLock挪到基類第一個成員位置。

再研究了下ttl cache,上述改法應該可以奏效,但是考慮到是否會代來效能問題或者是否有更好的解決問題方案,還需要再仔細琢磨一下,所以暫時也沒有程式碼修改。

對於上述加鎖處理,還應該說明的一點是,這種非RAII的加鎖方式一般應該避免使用,RAII能為程式的健壯性從程式碼層次提供有更有效的提升,比如不會因為忘記unlock而造成死鎖,同時也保證了異常出現並拋到外層時加過的鎖會自動解除。這種RAII的機制在很多同類型的場景中都應用到,比如scope_ptr。