C++ 執行緒同步的四種方式

發表於2016-12-22

執行緒之間通訊的兩個基本問題是互斥和同步。

(1)執行緒同步是指執行緒之間所具有的一種制約關係,一個執行緒的執行依賴另一個執行緒的訊息,當它沒有得到另一個執行緒的訊息時應等待,直到訊息到達時才被喚醒。

(2)執行緒互斥是指對於共享的作業系統資源(指的是廣義的”資源”,而不是Windows的.res檔案,譬如全域性變數就是一種共享資源),在各執行緒訪問時的排它性。當有若干個執行緒都要使用某一共享資源時,任何時刻最多隻允許一個執行緒去使用,其它要使用該資源的執行緒必須等待,直到佔用資源者釋放該資源。

執行緒互斥是一種特殊的執行緒同步。實際上,互斥和同步對應著執行緒間通訊發生的兩種情況:

(1)當有多個執行緒訪問共享資源而不使資源被破壞時;
(2)當一個執行緒需要將某個任務已經完成的情況通知另外一個或多個執行緒時。

從大的方面講,執行緒的同步可分使用者模式的執行緒同步和核心物件的執行緒同步兩大類。使用者模式中執行緒的同步方法主要有原子訪問和臨界區等方法。其特點是同步速度特別快,適合於對執行緒執行速度有嚴格要求的場合。

核心物件的執行緒同步則主要由事件等待定時器訊號量以及訊號燈等核心物件構成。由於這種同步機制使用了核心物件,使用時必須將執行緒從使用者模式切換到核心模式,而這種轉換一般要耗費近千個CPU週期,因此同步速度較慢,但在適用性上卻要遠優於使用者模式的執行緒同步方式。

在WIN32中,同步機制主要有以下幾種:

(1)事件(Event);
(2)訊號量(semaphore);
(3)互斥量(mutex);
(4)臨界區(Critical section)。

臨界區

臨界區(Critical Section)是一段獨佔對某些共享資源訪問的程式碼,在任意時刻只允許一個執行緒對共享資源進行訪問。如果有多個執行緒試圖同時訪問臨界區,那麼在有一個執行緒進入後其他所有試圖訪問此臨界區的執行緒將被掛起,並一直持續到進入臨界區的執行緒離開。臨界區在被釋放後,其他執行緒可以繼續搶佔,並以此達到用原子方式操作共享資源的目的。

臨界區在使用時以CRITICAL_SECTION結構物件保護共享資源,並分別用EnterCriticalSection()和LeaveCriticalSection()函式去標識和釋放一個臨界區。所用到的CRITICAL_SECTION結構物件必須經過InitializeCriticalSection()的初始化後才能使用,而且必須確保所有執行緒中的任何試圖訪問此共享資源的程式碼都處在此臨界區的保護之下。否則臨界區將不會起到應有的作用,共享資源依然有被破壞的可能。

全域性變數

因為程式中的所有執行緒均可以訪問所有的全域性變數,因而全域性變數成為Win32多執行緒通訊的最簡單方式。例如:

請看下列程式:

上述程式中使用全域性變數和while迴圈查詢進行執行緒間同步,實際上,這是一種應該避免的方法,因為:

(1)當主執行緒必須使自己與ThreadFunc函式的完成執行實現同步時,它並沒有使自己進入睡眠狀態。由於主執行緒沒有進入睡眠狀態,因此作業系統繼續為它排程C P U時間,這就要佔用其他執行緒的寶貴時間週期;
(2)當主執行緒的優先順序高於執行ThreadFunc函式的執行緒時,就會發生globalFlag永遠不能被賦值為true的情況。因為在這種情況下,系統決不會將任何時間片分配給ThreadFunc執行緒。

事件

事件(Event)是WIN32提供的最靈活的執行緒間同步方式,事件可以處於激發狀態(signaled or true)或未激發狀態(unsignal or false)。根據狀態變遷方式的不同,事件可分為兩類:

(1)手動設定:這種物件只可能用程式手動設定,在需要該事件或者事件發生時,採用SetEvent及ResetEvent來進行設定。
(2)自動恢復:一旦事件發生並被處理後,自動恢復到沒有事件狀態,不需要再次設定。

使用”事件”機制應注意以下事項:
(1)如果跨程式訪問事件,必須對事件命名,在對事件命名的時候,要注意不要與系統名稱空間中的其它全域性命名物件衝突;
(2)事件是否要自動恢復;
(3)事件的初始狀態設定。

由於event物件屬於核心物件,故程式B可以呼叫OpenEvent函式通過物件的名字獲得程式A中event物件的控制程式碼,然後將這個控制程式碼用於ResetEvent、SetEvent和WaitForMultipleObjects等函式中。此法可以實現一個程式的執行緒控制另一程式中執行緒的執行,例如:

訊號量

訊號量是維護0到指定最大值之間的同步物件。訊號量狀態在其計數大於0時是有訊號的,而其計數是0時是無訊號的。訊號量物件在控制上可以支援有限數量共享資源的訪問。

訊號量的特點和用途可用下列幾句話定義:

(1)如果當前資源的數量大於0,則訊號量有效;
(2)如果當前資源數量是0,則訊號量無效;
(3)系統決不允許當前資源的數量為負值;
(4)當前資源數量決不能大於最大資源數量。

建立訊號量

釋放訊號量

通過呼叫ReleaseSemaphore函式,執行緒就能夠對信標的當前資源數量進行遞增,該函式原型為:

開啟訊號量 

和其他核心物件一樣,訊號量也可以通過名字跨程式訪問,開啟訊號量的API為:

互鎖訪問

當必須以原子操作方式來修改單個值時,互鎖訪問函式是相當有用的。所謂原子訪問,是指執行緒在訪問資源時能夠確保所有其他執行緒都不在同一時間內訪問相同的資源。

請看下列程式碼:

執行ThreadFunc1和ThreadFunc2執行緒,結果是不可預料的,因為globalVar++並不對應著一條機器指令,我們看看globalVar++的反彙編程式碼:

在”mov eax,[globalVar (0042d3f0)]” 指令與”add eax,1″ 指令以及”add eax,1″ 指令與”mov [globalVar (0042d3f0)],eax”指令之間都可能發生執行緒切換,使得程式的執行後globalVar的結果不能確定。我們可以使用InterlockedExchangeAdd函式解決這個問題:

InterlockedExchangeAdd保證對變數globalVar的訪問具有”原子性”。互鎖訪問的控制速度非常快,呼叫一個互鎖函式的CPU週期通常小於50,不需要進行使用者方式與核心方式的切換(該切換通常需要執行1000個CPU週期)。

互鎖訪問函式的缺點在於其只能對單一變數進行原子訪問,如果要訪問的資源比較複雜,仍要使用臨界區或互斥。

可等待定時器

可等待定時器是在某個時間或按規定的間隔時間發出自己的訊號通知的核心物件。它們通常用來在某個時間執行某個操作。

建立可等待定時器

設定可等待定時器

可等待定時器物件在非啟用狀態下被建立,程式設計師應呼叫 SetWaitableTimer函式來界定定時器在何時被啟用:

取消可等待定時器

開啟可等待定時器

作為一種核心物件,WaitableTimer也可以被其他程式以名字開啟:

例項

下面給出的一個程式可能發生死鎖現象:

執行這個程式,在中途一旦發生這樣的輸出:

執行緒1佔用臨界區1  執行緒2佔用臨界區2

執行緒2佔用臨界區2  執行緒1佔用臨界區1

執行緒1佔用臨界區2  執行緒2佔用臨界區1

執行緒2佔用臨界區1  執行緒1佔用臨界區2

程式就”死”掉了,再也執行不下去。因為這樣的輸出,意味著兩個執行緒相互等待對方釋放臨界區,也即出現了死鎖。

如果我們將執行緒2的控制函式改為:

再次執行程式,死鎖被消除,程式不再擋掉。這是因為我們改變了執行緒2中獲得臨界區1、2的順序,消除了執行緒1、2相互等待資源的可能性。
由此我們得出結論,在使用執行緒間的同步機制時,要特別留心死鎖的發生。

相關文章