多線程編程需要在編程時倍加註意。對於多數任務,通過將執行請求以線程池線程的方式排隊,可以降低複雜性。本主題將探討更複雜的情形,比如協調多個線程的工作或處理造成阻止的線程。
死結和競爭條件
多線程編程解決了輸送量和響應性問題,但引入此功能會帶來新的問題:死結和競爭條件。
死結
當兩個線程中的每一個線程都在嘗試鎖定另外一個線程鎖定的資源時,就會發生死結。其中任何一個線程都不能繼續執行。
託管線程處理類的許多方法都提供了逾時設定,可幫您檢測到死結。例如,下面的代碼嘗試擷取對當前執行個體的鎖定。如果在 300 毫秒內未能鎖定,Monitor::TryEnter 將返回 false。
競爭條件
競爭條件是當程式的結果取決於兩個或更多個線程中的哪一個先到達某一特定代碼塊時出現的一種 Bug。多次運行程式將產生不同的結果,而且給定的任何一次啟動並執行結果都不可預知。
競爭條件的一個簡單例子是遞增一個欄位。假定某個類有一個私人 static 欄位(在 Visual Basic 中為 Shared),每建立該類的一個執行個體時它都遞增一次,使用的代碼是 objCt++; (C#) 或 objCt += 1 (Visual Basic)。此操作要求將 objCt 中的值載入到一個寄存器中,使該值遞增,然後將其儲存到 objCt 中。
在多線程應用程式中,一個已載入並遞增該值的線程可能會被另一個線程搶先,搶先的線程執行全部的三個步驟;當第一個線程繼續執行並儲存其值時,它覆蓋 objCt,但不考慮該值在它暫停執行期間已更改這一事實。
這種特殊的競爭條件通過使用 Interlocked 類的方法(如 Interlocked::Increment)即可輕鬆避免。若要瞭解在多個線程間同步資料的其他技巧,請參見為多執行緒同步資料。
競爭條件也可能會在同步多個線程的活動時發生。編寫每一行代碼,都必須考慮出現以下特殊情況時會發生什麼情況,這裡的特殊情況是指:一個線程在執行該行代碼(或構成該行的任何機器指令)前,其他線程搶先執行了該代碼。
處理器數目
多線程編程技術能夠解決單一處理器電腦和多處理器電腦的諸多問題,單一處理器電腦大多用來運行終端使用者軟體,多處理器電腦通常用作伺服器。
單一處理器電腦
多線程編程為電腦使用者提供了更好的響應能力,並且使用空閑時間處理背景工作。如果在單一處理器電腦上使用多線程編程,那麼:
在任何時刻都只有一個線程在運行。
後台線程僅在主使用者線程空閑時才執行。連續啟動並執行前台線程將使後台線程得不到處理器時間。
對一個線程調用 Thread::Start 方法時,該線程只有等到當前線程結束或被作業系統搶佔後才會執行。
出現競爭條件的原因通常是,程式員未預見到一個線程可能會在一個難以控制的時刻被搶佔這一事實,有時就會出現另一線程搶先使用代碼塊這種情況。
多處理器電腦
多線程編程提供了更大的輸送量。十個處理器可以完成一個處理器的十倍的工作量,不過,只有將任務分開並讓十個處理器同時工作才行;線程為劃分任務並利用額外的處理能力提供了一種方便的辦法。如果在多處理器電腦上使用多線程編程,那麼:
可以並發執行的線程的數目取決於處理器的數目。
後台線程只有在正在執行的前台線程的數目小於處理器的數目時才執行。
對一個線程調用 Thread::Start 方法時,該線程可能會,也可能不會立即執行,具體取決於處理器的數目以及當前等待執行的線程的數目。
競爭條件不僅可能因為線程被意外搶佔而發生,還可能因為在不同的處理器上執行的兩個線程在搶用同一代碼塊而發生。
靜態成員和靜態建構函式
在類的類建構函式(C# 中的 static 建構函式、Visual Basic 中的 Shared Sub New)完成運行之前,該類不會初始化。為防止對未初始化的類型執行代碼,在類建構函式完成運行之前,通用語言執行平台會禁止從其他線程到類的 static 成員(Visual Basic 中的 Shared 成員)的所有調用。
例如,如果某個類建構函式啟動了一個新線程,並且該線程程序呼叫了該類的 static 成員,則在該類建構函式完成之前,會一直禁止新線程。
以上情況適用於可擁有 static 建構函式的任意類型。
一般性建議
使用多線程時要考慮以下準則:
不要使用 Thread::Abort 終止其他線程。對另一個線程調用 Abort 無異於引發該線程的異常,也不知道該線程已處理到哪個位置。
不要使用 Thread::Suspend 和 Thread::Resume 同步多個線程的活動。請使用 Mutex、ManualResetEvent、AutoResetEvent 和 Monitor。
不要從主程式中控制輔助線程的執行(如使用事件),而應在設計程式時讓輔助線程負責等待任務,執行任務,並在完成時通知程式的其他部分。如果不阻止輔助線程,請考慮使用線程池線程。如果阻止輔助線程,Monitor::PulseAll 會很有協助。
不要將類型用作鎖定對象。例如,避免在 C# 中使用 lock(typeof(X)) 代碼,或在 Visual Basic 中使用 SyncLock(GetType(X)) 代碼,或將 Monitor::Enter 和 Type 對象一起使用。對於給定類型,每個應用程式定義域只有一個 System::Type 執行個體。如果您鎖定的對象的類型是 public,您的代碼之外的代碼也可鎖定它,但會導致死結。有關其他資訊,請參見可靠性最佳做法。
鎖定執行個體時要謹慎,例如,C# 中的 lock(this) 或 Visual Basic 中的 SyncLock(Me)。如果您的應用程式中不屬於該類型的其他代碼鎖定了該對象,則會發生死結。
一定要確保已進入監視器的線程始終離開該監視器,即使當線程在監視器中時發生異常也是如此。C# 的 lock 語句和 Visual Basic 的 SyncLock 語句可自動提供此行為,它們用一個 finally 塊來確保調用 Monitor::Exit。如果無法確保調用 Exit,請考慮將您的設計更改為使用 Mutex。Mutex 在當前擁有它的線程終止後會自動釋放。
一定要針對那些需要不同資源的任務使用多線程,避免向單個資源指定多個線程。例如,任何涉及 I/O 的任務都會從其擁有其自己的線程這一點得到好處,因為此線程在 I/O 操作期間將阻止,從而允許其他線程執行。使用者輸入是另一種可從專用線程獲益的資源。在單一處理器電腦上,涉及大量計算的任務可與使用者輸入和涉及 I/O 的任務並存,但多個計算量大的任務將相互競爭。
對於簡單的狀態更改,請考慮使用 Interlocked 類的方法,而不是 lock 語句(在 Visual Basic 中為 SyncLock)。lock 語句是一個優秀的通用工具,但是 Interlocked 類為必須是原子性的更新提供了更好的效能。如果沒有爭奪,它會在內部執行一個鎖定首碼。在查看代碼時,請注意類似於以下樣本所示的代碼。在第一個樣本中,狀態變數是遞增的:類庫的建議
在為多線程編程設計類庫時,請考慮以下準則:
如果可能,請避免同步需求。對於大量使用的代碼更應如此。例如,可以將一個演算法調整為容忍爭用情況,而不是完全消除爭用情況。不必要的同步會降低效能,並且可能導致出現死結和爭用情況。
預設情況下使待用資料(在 Visual Basic 中為 Shared)是安全執行緒的。
預設情況下不要使執行個體資料是安全執行緒的。通過添加鎖來建立安全執行緒的代碼的做法會降低效能、加劇鎖爭奪,並且可能導致出現死結。在常見的應用程式模型中,某一時刻只有一個線程執行使用者代碼,這樣可以使對安全執行緒的需求變為最小。出於此原因,.NET Framework 類庫預設情況下不是安全執行緒的。
避免提供可更改靜態狀態的靜態方法。在常見的伺服器方案中,靜態狀態在各個請求之間是共用的,這意味著多個線程可在同一時刻執行該代碼。這樣就有可能出現線程錯誤。請考慮使用一種設計模式,將資料封裝到在各請求之間不共用的執行個體中。此外,如果同步待用資料,更改狀態的靜態方法間的調用可導致死結或冗餘同步,從而降低效能。