C++11 中的執行緒、鎖和條件變數

JingerJoe發表於2013-07-30

【感謝@_La_Isla_Bonita 的熱心翻譯。如果其他朋友也有不錯的原創或譯文,可以嘗試推薦給伯樂線上。】

執行緒

類std::thread代表一個可執行執行緒,使用時必須包含標頭檔案<thread>。std::thread可以和普通函式,匿名函式和仿函式(一個實現了operator()函式的類)一同使用。另外,它允許向執行緒函式傳遞任意數量的引數。

上例中,t 是一個執行緒物件,函式func()執行於該執行緒中。對join()函式的呼叫將使呼叫執行緒(本例是指主執行緒)一直處於阻塞狀態,直到正在執行的執行緒t執行結束。如果執行緒函式返回某個值,該值也將被忽略。不過,該函式可以接收任意數量的引數。

儘管可以向執行緒函式傳遞任意數量的引數,但是所有的引數應當按值傳遞。如果需要將引數按引用傳遞,那要向下例所示那樣,必須將引數用std::ref 或者std::cref進行封裝。

Detach: 允許執行該方法的執行緒脫離其執行緒物件而繼續獨立執行。脫離後的執行緒不再是可結合執行緒(你不能等待它們執行結束)。

有一點非常重要,如果執行緒函式丟擲異常,使用常規的try-catch語句是捕獲不到該異常的。換句話說,以下的做法是不可行的:

要線上程間傳遞異常,你需要線上程函式中捕獲他們,將其儲存在合適的地方,比便於另外的執行緒可以隨後獲取到這些異常。

想要知道更多的關於捕獲和傳遞異常的知識,可以閱讀這兩本書在主執行緒中處理輔助執行緒丟擲的C++異常怎樣線上程間傳遞異常

在深入學習之前,有一點需要注意 &lt;thread&gt;標頭檔案在名稱空間std::this_thread中提供了一些幫助函式:

  • get_id: 返回當前執行緒的id.
  • yield:在處於等待狀態時,可以讓排程器先執行其他可用的執行緒。
  • sleep_for:阻塞當前執行緒,時間不少於其引數指定的時間。
  • sleep_util:在引數指定的時間到達之前,使當前執行緒一直處於阻塞狀態。

在上面的例子中,我需要對vector g_exceptions進行同步訪問,以確保在同一時間只能有一個執行緒向其中新增新元素。為此,我使用了互斥量,並對該互斥進行加鎖。互斥量是一個核心同步原語,C++ 11的<mutex>標頭檔案裡包含了四種不同的互斥量。

  • Mutex: 提供了核心函式 lock() 和 unlock(),以及非阻塞方法的try_lock()方法,一旦互斥量不可用,該方法會立即返回。
  • Recursive_mutex:允許在同一個執行緒中對一個互斥量的多次請求。
  • Timed_mutex:同上面的mutex類似,但它還有另外兩個方法 try_lock_for() 和 try_lock_until(),分別用於在某個時間段裡或者某個時刻到達之間獲取該互斥量。
  • Recursive_timed_mutex: 結合了timed_mutex 和recuseive_mutex的使用。

下面是一個使用了std::mutex的例子(注意前面提到過的幫助函式get_id()和sleep_for()的用法)。

輸出結果如下所示:

lock()和unlock()這兩個方法應該一目瞭然,第一個方法用來對互斥量加鎖,如果互斥量不可用,便處於阻塞狀態。後者則用來對互斥量解鎖。

下面這個例子展示了一個簡單的執行緒安全容器(內部使用std::vector).這個容器帶有新增單個元素的add()方法和新增多個元素的addrange()方法,addrange()方法內部僅僅呼叫了add()方法。

注意:就像下面的評論裡所指出的一樣,由於某些原因,包括使用了va_args,這不是一個標準的執行緒安全容器。而且,dump()方法也不是容器的方法,從真正的實現上來說,它只是一個幫助(獨立的)函式。這個例子僅僅用來告訴大家一些有關互斥量的概念,而不是實現一個完全成熟的,無任何錯誤的執行緒安全容器。

執行該程式時,會進入死鎖狀態。原因是該容器試圖多次去獲取同一個互斥量,卻一直沒有釋放它,這樣是不可行的。

在這裡,使用std::recursive_mutex就可以很好地解決這個問題,它允許同一個執行緒多次獲取同一個互斥量,可獲取的互斥量的最大次數並沒有具體說明。但是一旦超過最大次數,再對lock進行呼叫就會丟擲std::system_error錯誤異常。

要想修改上述程式碼中的問題(除了修改addrange()方法的實現,使它不去呼叫lock()和unlock()),還可以將互斥量std::mutex改為std::recursive_mutex

修改後,就會得到下面的輸出結果。

聰明的讀者會注意到每次呼叫func()都會產生相同的數字序列。這是因為種子數是執行緒本地化的,僅僅在主執行緒中呼叫了srand()對種子進行了初始化,在其他工作執行緒中並沒用進行初始化,所以每次都得到相同的數字序列。

顯式的加鎖和解鎖會導致一些問題,比如忘記解鎖或者請求加鎖的順序不正確,進而產生死鎖。該標準提供了一些類和函式幫助解決此類問題。這些封裝類保證了在RAII風格上互斥量使用的一致性,可以在給定的程式碼範圍內自動加鎖和解鎖。封裝類包括:
Lock_guard:在構造物件時,它試圖去獲取互斥量的所有權(通過呼叫lock()),在析構物件時,自動釋放互斥量(通過呼叫unlock()).這是一個不可複製的類。

Unique_lock:這個一通用的互斥量封裝類,不同於lock_guard,它還支援延遲加鎖,時間加鎖和遞迴加鎖以及鎖所有權的轉移和條件變數的使用。這也是一個不可複製的類,但它是可移動類。

有了這些封裝類,我們可以像下面這樣改寫容器類:

有人也許會問,既然dump()方法並沒有對容器的狀態做任何修改,是不是應該定義為const方法呢?但是你如果將它定義為const,編譯器會報出下面的錯誤:

‘std::lock_guard<_Mutex>::lock_guard(_Mutex &)’ : cannot convert parameter 1 from ‘const std::recursive_mutex’ to ‘std::recursive_mutex &’

一個互斥量(不管使用的哪一種實現)必須要獲取和釋放,這就意味著要呼叫非const的lock()和unlock()方法。所以從邏輯上來講,lock_guard的引數不能使const(因為如果該方法為const,互斥量也必需是const).解決這個問題的辦法就是將互斥量定義為可變的mutable,Mutable允許在常函式中修改狀態。

不過,這種方法只能用於隱藏或者元狀態(就像對計算結果或查詢的資料進行快取,以便下次呼叫時可以直接使用,不需要進行多次計算和查詢。再或者,對在一個物件的實際狀態起輔助作用的互斥量進行位的修改)。

這些封裝類的建構函式可以過載,接受一個引數用來指明加鎖策略。可用的策略如下:

  • defer_lock of type defer_lock_t:不獲取互斥量的擁有權
  • try_to_lock of type try_to_lock_t:在不阻塞的情況下試圖獲取互斥量的擁有權
  • adopte_lock of type adopt_lock_t:假設呼叫執行緒已經擁有互斥量的所有權

這些策略的宣告如下:

除了這些互斥量的封裝類,該標準還提供了兩個方法,用於對一個或多個互斥量進行加鎖。

  • lock:使用一種可以避免死鎖的演算法對互斥量加鎖(通過呼叫lock(),try_lock()和unlock()).
  • try_lock():按照互斥量被指定的順序,試著通過呼叫try_lock()來對多個互斥量加鎖。

 

這是一個發生死鎖的例子:有一個用來儲存元素的容器和一個函式exchange(),該函式用來交換兩個容器中的元素。要成為執行緒安全函式,該函式通過獲取每個容器的互斥量,來對兩個容器的訪問進行同步操作。

假設這個函式是由兩個不同的執行緒進行呼叫的,第一個執行緒中,一個元素從容器1中移除,新增到容器2中。第二個執行緒中,該元素又從容器2移除新增到容器1中。這種做法會導致發生死鎖(如果在獲取第一個鎖後,執行緒上下文剛好從一個執行緒切換到另一個執行緒,導致發生死鎖)。

要解決這個問題,可以使用std::lock來確保以避免發生死鎖的方式來獲取鎖。

條件變數C++11 還提供了另外一種同步原語,就是條件變數,它能使一個或多個執行緒進入阻塞狀態,直到接到另一個執行緒的通知,或者發生超時或虛假喚醒時,才退出阻塞.在標頭檔案<condition_variable> 裡對條件變數有兩種實現:

condition_variable:要求任何在等待該條件變數的執行緒必須先獲取std::unique_lock鎖。

Condition_variable_any:是一種更加通用的實現,可以用於任意滿足鎖的基本條件的型別(該實現只要提供了lock()和unlock()方法即可)。因為使用它花費的代價比較高(從效能和作業系統資源的角度來講),所以只有在提供了必不可少的額外的靈活性的條件下才提倡使用它。

下面來講講條件變數的工作原理: 至少有一個執行緒在等待某個條件變為true。等待的執行緒必須先獲取unique_lock 鎖。該鎖被傳遞給wait()方法,wait()方法會釋放互斥量,並將執行緒掛起,直到條件變數接收到訊號。收到訊號後,執行緒會被喚醒,同時該鎖也會被重新獲取。

至少有一個執行緒傳送訊號使某個條件變為true。可以使用notify_one()來傳送訊號,同時喚醒一個正在等待該條件收到訊號的處於阻塞狀態的執行緒,或者用notify_all()來喚醒在等待該條件的所有執行緒。

在多處理器系統中,因為一些複雜情況,要想完全預測到條件被喚醒並不容易,還會出現虛假喚醒的情況。就是說,在沒人給條件變數傳送訊號的情況下,執行緒也可能會被喚醒。所以執行緒被喚醒後,還需要檢測條件是否為true。因為可能會多次發生虛假喚醒,所以需要進行迴圈檢測。

下面程式碼是一個使用條件變數來同步執行緒的例子:幾個工作執行緒執行時可能會產生錯誤並將錯誤程式碼放到佇列裡。記錄執行緒會從佇列裡取出錯誤程式碼並輸出它們來處理這些錯誤。發生錯誤的時候,工作執行緒會給記錄執行緒發訊號。記錄執行緒一直在等待條件變數接收訊號。為了避免發生虛假喚醒,該等待過程在迴圈檢測條件的布林值。

執行上述程式碼,輸出結果如下(注意每次執行,輸出結果都不一樣;因為每個工作執行緒執行時都有一個隨機的休眠時間)。

上面看到的wait()方法有兩個過載:

  • 第一個過載帶有鎖unique_lock;這個過載方法可以釋放鎖,阻塞執行緒,並把執行緒新增到正在等待這一條件變數的執行緒佇列裡面。當該條件變數收到訊號或者發生虛假喚醒時,執行緒就會被喚醒。它們其中任何一個發生時,鎖都會被重新獲取,函式返回。
  • 第二個過載除了帶有鎖unique_lock外,還帶有迴圈判定直到返回false值;這個過載是用來避免發生虛假喚醒。它基本上等價於下面的語句:

因此在上面的例子中,通過使用過載的wait()方法以及驗證佇列狀態的判斷(空或不空),就可以避免使用布林變數g_notified了。

除了這個過載的wait()方法,還有另外兩個類似的過載方法,也帶有避免虛假喚醒的判定。

  • Wait_for: 在條件變數收到訊號或者指定的超時發生前,執行緒一直處於阻塞狀態;
  • Wait_until:在條件變數收到訊號或者指定的時刻到達之前,執行緒一直處於阻塞狀態。

 

這兩個函式的不帶有判定的過載返回cv_status狀態,用來表明發生超時或者執行緒被喚醒是因為條件變數收到訊號或者發生虛假喚醒。

該標準還提供了一個函式notify_all_at_thread_exit,它實現了一個機制,通知其他執行緒給定執行緒已經執行結束,並銷燬所有的thread_local物件。該函式的引進是因為在使用了thread_local後,採用除join()之外的其他機制來等待執行緒會導致不正確甚至致命的行為發生。

因為thread_local的解構函式會在等待中的執行緒恢復執行和可能執行結束的情況下被呼叫(可參考N3070和N2880得知更多資訊)。

通常情況下,對這個函式的呼叫必須線上程生成之前。下面的例子描述瞭如何使用notify_all_at_thread_exit和condition_variable共同完成對兩個執行緒的同步操作:

如果工作執行緒在主執行緒執行結束之前結束,輸出結果將如下:

如果主執行緒比工作執行緒更早結束,輸出結果將如下:

 

結束語

C++11標準可以讓C++開發者以一種標準的,獨立平臺的方式來編寫多執行緒。這篇文章大概講述了該標準所支援的執行緒和同步機制。標頭檔案<thread>提供了thread類(和一些幫助函式),表明thread類是一個可執行執行緒。標頭檔案<mutex>提供了幾種互斥量的實現和對執行緒進行同步訪問的封裝類。標頭檔案<condition_variable>提供了條件變數的兩種實現,這些實現使一個或多個執行緒一直處於阻塞狀態,直到接收到其他執行緒的通知,或發生超時或者有虛假喚醒發生時才會被喚醒。推薦讀者朋友可以閱讀其他資料來獲取更多的詳細資訊。

相關文章