Intel Threading Building Blocks 編程指南:互斥

    互斥控制某塊代碼能同時被多少線程執行。在Intel  Threading Building Blocks(intelTBB)中,互斥通過互斥體(mutexes)和鎖(locks)來實現。互斥體是一種對象,在此對象上,一個線程可以獲得一把鎖。在同一時間,只有一個線程能持有某個互斥體的鎖,其他線程必須等待時機。

    最簡單的互斥體是spin_mutex。試圖在spin_mutex上獲得鎖的線程要保持繁忙等待,直到成功。spin_mutex適合一個鎖只被持有數個指令時常的情況。例如,下面的代碼使用一個互斥體FreeListMutex來保護一個共享變量FreeList。它負責審查在同一時間只有一個線程訪問FreeList

Node* FreeList;
typedef spin_mutex FreeListMutexType;
FreeListMutexType FreeListMutex;
Node* AllocateNode()
{
	Node* n;
	{
		FreeListMutexType::scoped_lock lock(FreeListMutex);
		n = FreeList;
		if (n)
			FreeList = n->next;
	}
	if (!n)
		n = new Node();
	return n;
}
void FreeNode(Node* n)
{
	FreeListMutexType::scoped_lock lock(FreeListMutex);
	n->next = FreeList;
	FreeList = n;
}

scoped_lock的構造子(構造函數)會一直等待,直到FreeListMutex上沒有別的鎖。析構子(析構函數)釋放獲得的鎖。AllocateNode中的大括弧也許看起來不太常見。它們的作用是使鎖的生命週期儘可能的短,這樣其他的正在等待的線程就能儘可能快地得到機會。

注意:確保命名鎖對象,否則它會被過快的銷燬。例如,如果例子中的scoped_lock對象以如下方式創建

FreeListMutexType::scoped_lock (FreeListMutex);

這樣scoped_lock會在執行到分號處時銷燬,即在FreeList被訪問前釋放鎖。

編寫AllocatedNode的另一種可選方式如下:

Node* AllocateNode()
{
	Node* n;
	FreeListMutexType::scoped_lock lock;
	lock.acquire(FreeListMutex);
	n = FreeList;
	if (n)
		FreeList = n->next;
	lock.release();
	if (!n)
		n = new Node();
	return n;
}


acquire方法在得到鎖前會一直等待;release方法釋放該鎖。

推薦的做法是儘可能得加上大括弧,以使得那些代碼被鎖保護對於維護者來說更爲清晰。

如果你很熟悉鎖的C接口,也許會疑惑爲什麼在互斥體對象自身上沒有獲取、釋放方法。原因是C接口不是異常安全的,因爲如果被保護的區域拋出一個異常,控制流就會略過釋放操作。藉助面向對象接口,析構scoped_lock對象會致使鎖的釋放,無論是正常退出保護區域,還是因爲異常。即使對於我們使用acquire、release方法實現的AllocateNode的版本也是這樣的——顯式釋放讓鎖得以早點釋放,而後,析構函數判斷鎖已經被釋放,就不去操作鎖了。

Intel TBB中所有的互斥體都有類似的接口,不但能讓他們易於學習,還能適用於泛型編程。例如,所有的互斥體都嵌套一個scoped_lock類型,對於給定類型M,對應的鎖類型是M::scoped_lock。

推薦爲互斥體類型使用typedef,如同前面的例子所示。以這種方式,你可以稍後改變鎖的類型而不用編輯其餘的代碼。在這些例子中,可以使用typedef queuing_mutex FreeListMutexType來代替 typedef spin_mutex FreeListMutexType(及使用queuing_mutex代替spin_mutex),代碼仍然正確。

互斥體要素

互斥體的行家總結了互斥體的各種特性。知道這些是有幫助的,因爲它們影響通用性、性能的權衡。選擇正確會有助於性能提升。互斥體能以下面的要素描述:

  • 可伸縮性   一些互斥體被稱爲可伸縮的。在嚴格意義上,這不是一個準確的名字,因爲互斥體限制在某個時間某個線程的執行。一個可伸縮的互斥體是不會比這個做的更差。如果等待線程消耗了大量的處理器循環和內存帶寬,減少了線程做實際工作的速度,此時互斥體會比串行執行更糟糕。在輕微競爭的情況下,可伸縮互斥體通常要比非可伸縮互斥體要慢,此時非可伸縮互斥體要優於前者。如果有疑惑,就使用可伸縮互斥體。
  • 公平    互斥體可以是公平或者非公平的。公平的互斥體按照線程到達的順序使其通過,防止餓死線程。每個線程依序進行。然而,非公平互斥體會更快,它們允許正在運行的線程先通過,而不是下一個也許因爲某個中斷正在睡眠的在線(in line)線程。
  • 遞歸    互斥體可以是遞歸的,也可以是非遞歸的。可遞歸互斥體允許線程在持有此互斥體鎖的情況下再次獲得鎖。這在一些遞歸算法中很有用,但也增加了鎖實現的開銷。
  • 放棄或者阻塞   這是影響性能的實現細節。在長等待時,Intel TBB的互斥體要麼放棄(yields)要麼阻塞(blocks)。這裏的放棄(yields)的意思是,重複輪詢看能否有進展,如果不能,就暫時放棄處理器的使用權。阻塞意味着直到互斥體完成處理才釋放處理器。如果等待短暫,就使用放棄互斥體;如果等待時間往往比較長,就使用阻塞互斥體。(在windows系統中,yield通過SwitchToThread()實現,其他系統中通過sched_yield() 實現)

下面是互斥體的行爲總結:

  • spin_mutex    非可伸縮,非公平,非遞歸,在用戶空間自旋(光吃不幹)。看起來它似乎在所有場景裏都是最壞的,例外就是,在輕微競爭的情況下,它非常快。如果你設計程序時,競爭行爲在很多spin_mutex對象間傳播,那還是使用別的種類的互斥體爲好。如果互斥體是重度競爭的,你的算法無論如何都不會是可伸縮的。此種情況下,重新設計算法比尋找更有效的鎖合適。
  • queuing_mutex   可伸縮,公平,非遞歸,在用戶控件自旋。當可伸縮與公平很重要時使用。
  • spin_rw_mutexqueuing_rw_mutex     與spin_mutex、queuing_mutex類似,但是增加了讀取鎖支持。
  • mutexrecursive_mutex    這兩個互斥體是對系統原生互斥的包裝。在windows系統中,是在CRITICAL_SECTION(關鍵代碼段)上封裝的。在Linux以及Mac OS 操作系統中,通過pthread的互斥體實現。封裝的好處是加入了異常安全接口,並相比Intel TBB的其他互斥體提供了接口的一致性,這樣當出於性能方面考慮時能方便地將其替換爲別的互斥體。
  • null_mutex和null_rw_mutex   這兩個互斥體什麼都不做。它們可被用作模版參數。例如,假定你要定義一個容器模板並且知道它的一些實例會被多個線程共享,需要內部鎖定,但是其餘的會被某個線程私有,不需要鎖定。你可以定義一個將互斥體類型作爲參數的模板。在需要鎖定時,這個參數可以是真實互斥體類型中的一種,在不需要鎖定時,將null_mutex作爲參數傳入。

互斥體的行爲與特點:

讀寫鎖

    互斥在當多個線程寫操作某個共享變量時是必要的。但允許多個讀操作者進入保護區域就沒什麼大不了了。互斥體的讀寫變種,在類名稱中以_rw_標記,通過區分讀取鎖與寫入鎖,允許多個讀操作者。一個給定的互斥體,可以有多個讀取鎖。

    scoped_lock的構造函數通過一個額外的布爾型參數來區分讀取鎖請求與寫入鎖請求。如果這個參數爲false,表示請求讀取鎖。true表示請求寫入鎖。默認值爲true,這樣,當省略此參數時,spin_rw_mutex或者queuing_rw_mutex的行爲就跟沒有“_rw_”的版本一樣。

升級/降級

通過方法upgrade_to_writer可以將一個讀取鎖升級爲寫入鎖:

std::vector<string> MyVector;
typedef spin_rw_mutex MyVectorMutexType;
MyVectorMutexType MyVectorMutex;
void AddKeyIfMissing(const string& key)
{
	// Obtain a reader lock on MyVectorMutex 
	MyVectorMutexType::scoped_lock
		lock(MyVectorMutex,/*is_writer=*/false);
	size_t n = MyVector.size();
	for (size_t i = 0; i<n; ++i)
		if (MyVector[i] == key) return;
	if (!MyVectorMutex.upgrade_to_writer())
		// Check if key was added while lock was temporarily released 
		for (int i = n; i<MyVector.size(); ++i)
			if (MyVector[i] == key) return;
	vector.push_back(key);
}

注意,vector在某些時候必須重新搜索。這是因爲upgrade_to_writer在它升級前可能不得不臨時釋放鎖。否則,接下來可能會發生死鎖(下面會講到)。upgrade_to_writer方法返回值爲bool類型,在沒有釋放鎖就成功升級的情況下會返回true,如果鎖被臨時釋放了,返回false。因此,如果upgrade_to_writer返回了false,代碼必須重新運行查找操作確保“key”沒有被其他的線程插入。例子假定“keys”總被追加到vector的末端,而且這些鍵值不會被移除。由於這些假定,它不用重新搜索整個vector,而僅搜索那些最初搜索過的之外的元素。需要記住的關鍵點是,如果upgrade_to_writer返回了false,任何假定持有讀取鎖的假定都可能無效,必須重新檢查。

    於此相應,有個相對的方法downgrade_to_reader,但是在實際應用中,基本找不到使用它的理由。

鎖異常

鎖會導致性能與正確性問題。對於使用鎖的新手,有些問題要避免:

死鎖

當多個線程企圖獲得多個鎖,而且它們會相互持有對方需要的鎖時,死鎖就會發生。更爲準確地定義,當發生以下情況時死鎖會發生:

  • 存在線程迴路
  • 每個線程至少持有互斥體上的一個鎖,而且在等待迴路中下一個線程已經持有鎖的互斥體
  • 任何線程都不願意放棄它的鎖

(這就像路口的堵車,那些SB明知道走不動非要頂上去堵着別人)避免死鎖的兩種慣用方法是:

  • 避免需要同一時間持有兩把鎖的情況。將大塊的程序拆分爲小塊,每塊都可以在持有一把鎖的情況下完工。
  • 總是以同樣的順序取鎖。例如,如果你有“外部容器”與“內部容器”互斥體,需要從中獲取鎖,你可以總是先從“外部密室”獲取。另外一個例子是在鎖具有命名的情況下“以字母順序獲取鎖”。或者,如果鎖沒有命名,就以互斥體的數字地址作爲順序獲取鎖。
  • 使用原子操作替換鎖(隨後會說到原子操作)

鎖護送

    另外一個與鎖相關的常見問題是鎖護送。當操作系統打斷一個持有鎖的線程時,這種情況就會發生。所有其他的需要這把鎖的線程都必須等待被中斷的線程恢復並釋放鎖。公平互斥體會導致更糟糕的狀況,因爲,如果一個正在等待的線程被中斷,所有它後面的線程都必須等待它恢復(就不單是需要它持有鎖的那些線程的問題了)。

  •     要最小化這種情況發生,應該儘量縮短持有鎖的時間。在請求鎖之前,進行任何可被預先計算的工作。
  •    要避免這種情況,儘可能使用原子操作代替鎖。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章