C# 學習筆記:多執行緒 (2) - 分道揚鑣

摘要:C# 非同步程式設計的學習筆記之二,包括:建立與啟動執行緒、等待與暫停執行緒、共享變數、鎖定等議題。

上集,這次開始寫點程式碼來建立執行緒。

溫馨提醒:本文新版本的內容已經整理至電子書《.NET 本事-非同步程式設計》。點我查看出版訊息

建立執行緒來進行非同步運算

本節將介紹如何利用 System.Threading.Thread 類別來建立執行緒,以進行非同步運算。不過,這裡只是讓你知道有這個方法可用,並非建議一蓋採用。事實上,Windows 市集應用程式無法使用本節介紹的程式寫法,因為它根本沒有 Thread 類別可用。另一種作法,是透過執行緒集區(thread pool)來處理背景運算的工作,這個部分(應該)會在將來進一步說明。
執行緒集區與執行緒耗盡

.NET CLR 實作了集區(pool)的概念,讓應用程式可以將完成任務的執行緒丟進集區裡面待命,等到有其他工作需要非同步執行,便可直接從集區取出執行緒,並將工作派給它執行。如此一來,不但省去了頻繁建立和釋放執行緒的時間成本,也因為執行緒集區是由系統來管理,系統更能針對整體執行環境的狀況來調整相關參數(例如每條執行緒要分配多少執行時間),而開發人員也能夠更專注於實現應用程式的邏輯,而不是埋首與底層細節奮戰。

CLR 管理的執行緒集區有兩種: 工作執行緒集區(worker thread pool)和輸入/輸出執行緒集區(I/O thread pool)。兩種集區裡面的執行緒是同樣東西;之所以分成兩個集區,主要是希望應用程式依實際用途來選擇適當的集區,以免經常發生執行緒耗盡(thread starvation)的情形。

那麼,執行緒耗盡又是什麼意思呢? 

正在執行任務的執行緒,就像忙線中的客服人員,必須等到掛完電話,才有辦法繼續接聽下一通電話。然而,當大量工作需求同時產生(許多用戶同時打電話給同一家公司的客服專線),執行緒集區裡面的可用執行緒的數量就會迅速減少,甚至出現完全沒有執行緒可用的情況(所有客服人員現在全部忙線中,若要等待,請按米字鍵…)。這種狀況叫做「執行緒耗盡」,它會嚴重影響應用程式的效能與延展性。
相較於執行緒集區這種從一個共用的池子裡面取用執行緒的作法,「自行建立執行緒」則意味著建立新的執行緒來專門負責處理特定工作,所以有時候我們也說這種作法是「建立專屬的執行緒(dedicated thread)」。

當你碰到以下幾種特殊場合,才應該自行建立專屬的執行緒來處理特定的運算工作:
  • 你希望某些執行緒擁有特殊優先權。在預設情況下,執行緒集區裡面的執行緒都是「正常」優先權。如果想要讓某執行緒擁有特權,可以個別建立執行緒並修改其優先權。但一般不建議這麼做就是了。
  • 你希望某些執行緒以前景執行緒的方式運作,以避免工作還沒完成,應用程式就被使用者或其他程序關閉。執行緒集區裡面的執行緒永遠都是背景執行緒,它們有可能還沒完成任務就被 CLR 結束掉。
  • 執行緒所負責的工作需要大量運算,而且需要花很長的時間才能執行完畢。碰到這種情況,自行建立執行緒可能會比使用執行緒集區來的有效率,因為這樣可以省去執行緒集區的一些處理邏輯,例如何時該建立額外的執行緒。
  • 執行緒開始工作後,你可能需要在某些情況下提前終止執行緒(透過呼叫 Thread 類別的 Abort 方法)。
接著就來看一些範例程式。

建立與啟動執行緒

底下是個測試多執行緒的簡單範例,示範如何建立一個執行緒來執行某件背景工作。有個名詞得先說一下:負責執行背景工作的執行緒又稱為「工作執行緒」(worker thread)。之所以不說「背景執行緒」,是為了避免跟執行緒集區有關的前景、背景執行緒概念混淆。


程式說明:
  • 使用 System.Threading.Thread 類別來建立執行緒物件,同時將一個委派方法 MyBackgroundTask 傳入建構函式。這個委派方法將於該執行緒開始於背景運行時被自動呼叫。
  • 呼叫執行緒物件的 Start 方法,令執行緒開始運行,亦即在這個工作執行緒中呼叫 MyBackgroundTask 方法。
  • Main 函式開始一個迴圈,持續輸出 “.”。這只是為了識別哪些文字是由主執行緒輸出,哪些是由工作執行緒輸出。
  • MyBackgroundTask 函式也有一個迴圈,持續輸出目前執行緒的編號。

下圖為此範例程式的執行結果:


從輸出結果可以看得出來,主執行緒跑了一段時間,切換至我們另外建立的工作執行緒。工作執行緒也同樣跑了一段時間之後,又切回主執行緒,如此反覆切換,直到兩個執行緒的迴圈結束為止。

建立 Thread 物件時,傳入建構函式的委派有兩種版本。一種是 ThreadStart,另一種是 ParameterizedThreadStart。以下是這兩種委派型別的宣告:

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart(Object obj);

前述範例使用的是第一種,也就是不需要傳入參數的 ThreadStart 委派型別。如果在啟動工作執行緒時需要額外傳入一些資料,就可以使用第二種委派型別: ParameterizedThreadStart。參考以下範例:

程式說明:

  • 首先建立三個執行緒物件,而且這三個執行緒都會執行同一項任務:MyBackgroundTask。
  • MyBackgroundTask 方法需要傳入一個 object 型別的參數,而此參數的值是在啟動執行緒時傳入。在啟動三個執行緒物件時,我分別傳入了 “X”、”Y”、”Z”,以便從輸出結果中觀察各執行緒輪流切換的情形。

執行結果:


等待與暫停執行緒

Thread 類別有個 IsAlive 屬性,代表執行緒是否正在運行。一旦呼叫執行緒物件的 Start 方法令它開始執行,其 IsAlive 屬性值就會等於 true,直到該執行緒的委派方法執行完畢,那條執行緒便隨之結束。因此,如果想要等待某執行緒的工作執行完畢才繼續處理其他工作,用一個迴圈來持續判斷執行緒物件的 IsAlive 屬性就能辦到。

還有一個更簡單的作法可以等待執行緒結束:呼叫 Thread 物件的 Join 方法。以下程式片段修改自上一個範例:

這次我在 Main 函式中加入了三行程式碼,分別呼叫三個執行緒物件的 Join 方法。這會令主執行緒依序等待 t1、t2、t3 執行完畢之後才繼續跑底下的迴圈。執行結果如下圖:


此外,有時候我們會需要讓某執行緒稍事休息,此時可呼叫 Thread 類別的靜態方法 Sleep。此方法會令目前所在的執行緒休息一段指定的時間,時間單位是毫秒(millisecond)。範例:略。

共享變數

執行緒之間可以共享變數,有時候也的確需要這麼做。多條執行緒之間共享同一個變數時,如果都只是讀取變數值,並不會有問題。但如果執行緒會去修改共享變數的值,那就得運用一些技巧來避免數值錯亂的情形。看看底下這個範例:


程式說明:
  • Main 函式會建立 SharedStateDemo 物件並呼叫其 Run 方法。此範例的重點都在 SharedStateDemo 類別裡面,示範的情境為購物車。
  • SharedStateDemo 類別有一個整數欄位:itemCount,代表已加入購物車的商品數量。此變數將作為執行緒之間共享的變數。
  • SharedStateDemo 類別的 Run 方法會建立兩條執行緒,它們的工作都是呼叫 AddCart 方法,代表「加入購物車」的動作。
  • AddCart 方法需要傳入一個參數,用來模擬每一次加入購物車的動作需要花多少時間。從 Run 方法的程式碼可以看得出來,我刻意讓第一條執行緒花比較多時間(延遲 300 毫秒)。
執行結果:


如果 t1 和 t2 這兩條執行緒是依照它們啟動的順序先後完成任務,執行結果的第一列顯示地購物車商品數量應為 1,第二列的數量才是 2。可是現在卻全都是 2,這是因為 t1 先啟動,進入 AddCart 函式之後,把 itemCount 加一,然後進入一段模擬長時間工作的延遲(300ms)。此時 t2 也已經啟動了,也把 itemCount 加一了(其值為 2),然後也進入一段延遲(100ms)。但由於 t2 的延遲時間較短,比 t1 更快執行完畢(後發而先至),因此執行結果畫面中的第一列文字其實是由執行緒 t2 輸出的。接下來,t1 也跑完了,但此時的 itemCount 已經被 t2 改成了 2,所以輸出的結果自然就一樣了。

有時候,這種多條執行緒共同修改一個變數的情況可能會導致嚴重問題。比如說,當應用程式正在計算某員工的薪資,才處理到一半,還沒算完呢,又有其他執行緒修改了共享的薪資計算參數,可能原本的計算結果應該是 63,000,結果卻成了 59,000。接著就來看看如何解決這個問題,讓此範例的執行結果顯示的商品數量變成先 1 後 2,而不是兩次都輸出 2。
其實在我的機器上,即使呼叫 t1.Start() 時傳入 1(僅延遲 1 毫秒),輸出結果仍舊相同,並不因為 t1 的模擬延遲時間縮短成 1 毫秒而產生先 1 後 2 的結果。我想這是因為我的機器有多核心 CPU,於是 t1 才剛啟動,將 itemCount 遞增為 1,此時 t2 其實也已經(由另一個 CPU 核心)啟動了,itemCount 便遞增為 2。

不過,如果改成 t1.Start(0),亦即令 t1 模擬延遲的時間為 0 毫秒,結果就會變成先 1 後 2 了。這是因為 Thread.Sleep(0) 完全沒有延遲的作用,故來得及在其他執行緒進入該程式區塊之前完成工作。
鎖定

剛才展示的多執行緒修改同一變數所衍生之變數值錯亂的問題,有點像是很多人同時伸手搶一塊餅──很容易把餅給抓爛了。解決方法很簡單:排隊。也就是說,原本以非同步執行的各條執行緒,碰到了要修改共享變數的時候,都要乖乖排隊,一個做完了才換下一個。這等於是暫時切換成同步執行的方式,如同在八線道的公路某處設下關卡,將道路限縮成單線道,只許一輛汽車通行;等車輛駛出關卡,前方又是一片開闊,任憑奔馳。

我們可以利用獨佔鎖定(exclusive lock)的技巧來建立這道關卡,迫使各執行緒存取共享變數時乖乖排隊,亦即令它們同步化(synchronization)。這裡要示範的是以 C# 的 lock 敘述來建立獨佔鎖定的程式區塊。我們只要稍微修改上一個範例的 SharedStateDemo 類別,輸出結果就會不同。底下是修改後的程式碼:

程式說明:
  • 類別中多了一個型別為 Object 的私有成員:locker。此物件是用來作為獨佔鎖定之用,可以是任何參考型別。
  • AddCart 函式中增加了 lock 敘述。當兩條執行緒同時爭搶同一個鎖定物件時,其中一條執行緒會被擋住,等到被鎖定的物件被先前搶到的執行緒釋放了,才能夠取得鎖定。如此便能夠確保以 lock 關鍵字包住的程式區塊在同一時間內只會有一條執行緒進入。
這次除了增加獨佔鎖定的程式敘述,還把執行緒編號也一併秀出來,方便確認。執行結果如下圖所示:


從圖中可以看出,執行緒編號 3 和 4 都已分別啟動了,但是購物車的數量會依兩條執行緒的順序各自遞增一次,並顯示正確的結果。像這種有加上保護機制來避免多執行緒爭搶共享變數而致資料錯亂的程式寫法,我們說它是「執行緒安全的」(thread-safe)。

注意:使用獨佔鎖定的技巧時應注意避免兩條執行緒互相等待對方釋放鎖定而導致鎖死(deadlock)的情形。

前景執行緒 vs. 背景執行緒

依行為來區分,執行緒可分為兩種:前景執行緒和背景執行緒。兩者的主要區別是:當所有的前景執行緒停止時,CLR 會停止所有背景執行緒(不會拋出任何異常),並結束應用程式。若只是停止背景執行緒,則不會造成應用程式結束。因此,我們通常會把那些必須執行完畢的工作交給前景執行緒,而將比較不重要的、或可隨時中斷然後接續進行的工作交給背景執行緒負責處理。

新建立的執行緒,預設皆為前景執行緒,你可以透過 Thread 物件的 IsBackground 屬性來將它改成背景執行緒。參考以下範例:


程式說明:
  • 此範例程式在 Main 函式中建立一條新的執行緒之後,將它設定為背景執行緒,並令它開始執行。
  • 接著 Main 就結束了,這表示前景執行緒結束了。因此就算 MyWork 函式仍在跑無窮迴圈,應用程式仍會立刻結束。若把 t 設定為前景執行緒(預設值),則Main 函式結束之後,應用程式並不會結束,除非手動將它關閉。

下回預告

如果有的話,應該會介紹執行緒的優先等級吧。

參考資料
技術提供:Blogger.
回頂端⬆️