之前寫過兩篇關於執行緒同步問題的文章(一,二),這篇中將對相關話題進行總結,本文中也對.NET 4.0中新增的一些同步機制進行了介紹。
首先需要說明的是為什麼需要執行緒功能同步。MSDN中有這樣一段話很好的解釋了這個問題:
當多個執行緒可以呼叫單個物件的屬性和方法時,對這些呼叫進行同步處理是非常重要的。否則,一個執行緒可能會中斷另一個執行緒正在執行的任務,使該物件處於一種無效狀態。 |
也就說在預設無同步的情況下,任何執行緒都可以隨時訪問任何方法或欄位,但一次只能有一個執行緒訪問這些物件。另外,MSDN中也給出定義,成員不受多執行緒呼叫中斷影響的類即執行緒安全類。
CLI提供了幾種可用來同步對例項和靜態成員的訪問的策略(前面兩邊文章介紹了這其中大部分機制):
- 同步程式碼區域:
可以使用Monitor類或(編譯器支援的語法,如C#中的lock關鍵字)來同步需要安全的接受併發請求的程式碼段,這種方式比其他等效的同步方法有更好的效能。
lock語句通過Monitor的Enter和Exit方法實現程式碼段同步,使用try catch finally結構確保鎖被釋放。當執行緒執行該程式碼時,會嘗試獲取鎖。如果該鎖已由其他執行緒獲取,則在鎖變為可用狀態之前,該執行緒一直處於阻塞狀態。當線 程退出同步程式碼塊時,鎖就會被釋放,這與執行緒的退出方式無關。通常情況下同步一小程式碼塊並且不跨越多個方法的最佳選擇是lock 語句,Monitor類 功能強大但使用不當容易出現孤立鎖與死鎖,而由於lock是通過Monitor的Enter和Exit實現的,因此在臨界區中可以結合Monitor的其 它方法一起使用。
另外可以通過[MethodImpl(MethodImplOptions.Synchronized)]特性標 記一個方法是需要被同步的,方法可以是例項方法也可以是靜態方法。最終實現的效果與使用lock關鍵字或Monitor相關方法相同。注意不要在此特性標 記的方法內使用lock(this)/lock(typeof(this))(注意,單獨使用lock時也不應用物件本身或型別作為鎖(應為型別或例項可 能被其它機制鎖定,如被[MethodImpl(MethodImplOptions.Synchronized)]標記),對於例項方法與靜態方法最好 分別使用宣告新的私有成員或靜態私有成員作為鎖,避免使用公有成員作為鎖)。另外不能對字串加鎖。
- 手動同步:
.NET Framework中提供一些類用於手動進行執行緒間的訪問同步。這些類主要分為3大類別(但正如下文中會看到的這些類別劃分並非絕對,某些同步機制在多個類別之間有交叉):
ü 鎖定
ü 通知
ü 連鎖操作
- 鎖定
排他鎖 |
獨佔鎖 |
最常見的形式就是C#的lock語句,該語句控制對一個程式碼塊的訪問,這個程式碼塊被稱作臨界區。詳見前文xx中對lock的介紹。 |
Monitor類 |
Monitor類提供了許多附加功能,這些功能可以與lock關鍵字結合使用(在lock的臨界區中呼叫Monitor類的方法)。更多細節見執行緒同步問題1方法二中的介紹。 |
|
Mutex類 |
Mutex的作用也是建立一個臨界區以同步對其中物件的訪問,方式類似Monitor類,但最大的不同是Mutex支援跨程式的同步。當然其效率也不如Monitor類,在同一程式內通訊應首先考慮使用Monitor。Mutex的介紹詳見執行緒同步問題2方法五中的介紹。 |
|
SpinLock類 |
.NET4.0中新增 當 Monitor 所需的開銷會造成效能下降時,可以使用 SpinLock 類。當SpinLock請求進入 臨界區時,會反覆地旋轉(執行空迴圈),直至鎖變為可用的。如果請求鎖所需時間非常短,則空轉可比阻塞提供更好的效能。但是,如果鎖保留數十個週期以上, 則SpinLock的表現會和Monitor一樣,而且將使用更多的CPU週期,降低其他執行緒或程式的效能。 |
|
其它鎖 |
有些時候鎖不必獨佔,可以允許一定數目的執行緒併發訪問某個資源。下面列舉的鎖即用於這個目的。 |
|
ReaderWriterLock類 |
允許多個執行緒同時讀取一個資源,但在向該資源寫入時要求執行緒等待以獲得獨佔鎖。更多細節見執行緒同步問題1方法三中的介紹。 |
|
Semaphore類 |
Semaphore類允許指定數目的執行緒訪問某個資源。超過這個數目時,請求該資源的其他執行緒會一直阻塞,直到某個執行緒釋放訊號量。更多細節見執行緒同步問題2方法七中的介紹。 |
|
ReaderWriterLockSlim類 |
.NET4.0中新增 這個類的作用與ReaderWriterLock類完全一致,其擁有更好的效能,在新開發的程式中應當使用ReaderWriterLockSlim而不是ReaderWriterLock。ReaderWriterLockSlim 具有執行緒關聯。 |
|
SemaphoreSlim類 |
.NET4.0中新增 SemaphoreSlim類是用於在單一程式邊界內進行同步的輕量訊號量。使用方式上與Semaphore一致。 |
- 通知
通知機制是等待另一個執行緒的訊號的所有方法的統稱。
Join方法 |
這是等待來自另一個執行緒訊號最簡單的方法,解釋Join方法最好有一個場景,假如我們有ThreadA,ThreadB兩個執行緒,假如我們在ThreadB執行的方法中呼叫ThreadA.Join() 方法。這將阻塞B執行緒的執行直到A執行緒完成。場景中ThreadB可以是主執行緒也可以是其它子執行緒。其中也可以呼叫多個子執行緒的Join方法。這樣 ThreadB將阻塞並等待所有這些執行緒執行完畢後才繼續執行。另外如果ThreadA的方法中呼叫了其它執行緒的Join方法,這將形成一個佇列形式的線 程呼叫,所有這些執行緒將一個個排隊執行。 Join也具有兩個接受時間間隔的過載,用於設定阻塞執行緒等待的最長時間。依然用上面的例子來說,我們在 B執行緒方法中呼叫ThreadA.Join(5000),當在5秒鐘內執行緒A執行完畢了,則Join方法會立刻返回true,ThreadB繼續執行,如 果5秒鐘執行緒A未完成,則Join方法在5秒鐘到時返回false,ThreadA與ThreadB進入並行交替執行狀態。 |
|||
等待控制程式碼 |
等待控制程式碼派生自WaitHandle類,後者又派生自 MarshalByRefObject。從而等待控制程式碼可用於跨應用程式域邊界的執行緒同步。WaitHandle類封裝了Win32的同步控制程式碼,用於表示所有允許多個等待操作的同步物件。 通過呼叫WaitOne例項方法或WaitAll、WaitAny及SignalAndWait中任一個靜態方法方法,可以阻塞當前執行緒以等待WaitHandle發出訊號。 WaitHandle的派生類具有不同的執行緒關聯。事件等待控制程式碼(EventWaitHandle、 AutoResetEvent 和 ManualResetEvent)以及訊號量沒有執行緒關聯。任何執行緒都可以傳送事件等待控制程式碼或訊號量的訊號。另一方 面,mutex有執行緒關聯。擁有mutex的執行緒必須將其釋放;而如果在不擁有mutex的執行緒上呼叫ReleaseMutex方法,則將引發異常。 |
|||
事件等待控制程式碼 |
事件等待控制程式碼包括EventWaitHandle類及其派生類AutoResetEvent和 ManualResetEvent,這些類允許執行緒通過彼此傳送訊號和等待彼此的訊號來同步活動。當通過呼叫Set方法或使用SignalAndWait 方法通知事件等待控制程式碼時,阻塞執行緒會從事件等待控制程式碼中釋放。 事件等待控制程式碼要麼自動重置自身(類似於每次得到訊號時只允許一個執行緒通過的旋轉門),要麼必須手動重置(類似於一 道門,在得到訊號前一直關閉,得到訊號開啟後到其關閉前一直開啟)。顧名思義,AutoResetEvent和ManualResetEvent分別表示 前者和後者。 |
|||
AutoResetEvent |
派生自EventWaitHandle,表示自動重置的本地事件。詳見執行緒同步問題2方法六的介紹。 |
|||
ManualResetEvent |
派生自EventWaitHandle,表示手動重置的本地事件。詳見執行緒同步問題2方法六的介紹 |
|||
ManualResetEventSlim |
.NET4.0中新增 ManualResetEventSlim類提供了ManualResetEvent的簡化版本。其模型與使用方式上與ManualResetEvent一致,主要用於同一程式內執行緒間的同步。 |
|||
CountdownEvent |
.NET4.0中新增 CountdownEvent的作用與Semaphore相反,Semaphore中設定了最大可用槽數,當計數 為0時(即資源不夠用時)則阻塞執行緒。而CountdownEvent用來統計其它執行緒結束工作的情況,當監聽數變為0時,觸發訊號。本篇文章的最後部分 我們詳細介紹CountdownEvent類。 |
|||
Mutex類/ Semaphore類 |
這兩個類均派生自WaitHandle,所以它們均可與WaitHandle的靜態方法一起使用。例如,執行緒可以 使用WaitAll方法/WaitAny方法等待,而以下三個條件均可以使這個執行緒解除阻塞:EventWaitHandle接收到訊號,Mutex被釋 放,Semaphore被釋放。 |
|||
Barrier類 |
.NET4.0中新增 利用 Barrier 類,可以對多個執行緒進行迴圈同步,以便它們都在同一個點上阻塞來等待其他執行緒完成。後文將對這個類進行詳細介紹。 |
|||
- 連鎖操作
聯鎖操作是由 Interlocked 類的靜態方法對某個記憶體位置執行的簡單原子操作。這些原子操作包括新增、遞增和遞減、交換、依賴於比較的條件交換,以及 32 位平臺上的 64 位值的讀取操作。關於Interlocked類詳見執行緒同步問題1方法一。
特別注意,原子性的保證僅限於單個操作;如果必須將多個操作作為一個單元執行,則必須使用更粗粒度的同步機制。
儘管這些操作中沒有一個是鎖或訊號,但它們可用於構造鎖和訊號。因為它們是Windows作業系統固有的,因此聯鎖操作的執行速度非常快。如CountdownEvent的實現中就使用了Interlocked類。
最後注意,只要有一個執行緒避開同步機制直接訪問需要同步訪問的資源,這種同步機制就是無效的。
- 同步上下文:
可以使用SynchronizationAttribute為ContextBoundObject物件(上下文繫結物件)啟用簡單的自動同步。介紹詳見執行緒同步問題1方法四中的介紹。
- 執行緒安全集合:
.NET4.0中新引入的名稱空間System.Collections.Concurrent中提供的集合類內 置對新增和移除操作的同步機制。多個執行緒可以在這些集合中安全高效地新增或移除項,而無需使用者執行其他同步操作。在編寫新程式碼時,如果遇到多個執行緒同時寫 入集合的情況,就應使用併發集合類。如果僅從集合進行(併發)讀取,則可使用System.Collections.Generic名稱空間中的類。
從.NET發展來看,.NET1.0中提供的集合類(Aarry,Hashtable)通過 Synchronized屬性支援同步,但不支援泛型,NET2.0種提供了泛型類的集合,但沒有內建任何同步機制。.NET4.0開始提供的併發集合類 把執行緒安全與型別安全集合起來。為了提高效率這些併發集合的一部分使用了.NET4.0新增的輕量同步機制,如SpinLock、SpinWait、 SemaphoreSlim 和 CountdownEvent,另外ConcurrentQueue<T>和 ConcurrentStack<T>類沒有使用這些同步機制,而是依賴Interlocked操作來實現執行緒安全性。
這個新增的名稱空間下包含如下型別:
型別 |
說明 |
通過實現IProducerConsumerCollection<T>介面,實現了一個支援生產者消費者模型的資料結構。 |
|
鍵/值對字典的執行緒安全實現。 |
|
執行緒安全的佇列實現。 |
|
執行緒安全的堆疊實現。 |
|
無序的元素集合的執行緒安全實現。 |
|
BlockingCollection實現的介面。 |
CLR中不同類別可以根據要求以不同的方式進行同步。下表顯示了上面列出的幾類同步策略為不同類別的欄位和方法提供的同步支援。
類別 |
全域性欄位 |
靜態欄位 |
靜態方法 |
例項欄位 |
例項方法 |
特定程式碼塊 |
無同步 |
不同步 |
不同步 |
不同步 |
不同步 |
不同步 |
不同步 |
同步上下文 |
不同步 |
不同步 |
不同步 |
可以同步 |
可以同步 |
不同步 |
同步程式碼區域 |
不同步 |
不同步 |
當標記時同步 |
不同步 |
當標記時同步 |
當標記時同步 |
手動同步 |
手動 |
手動 |
手動 |
手動 |
手動 |
手動 |
到這,可以發現.NET4.0新增了很多新的同步類(輕量型別),這些類儘可能避免依賴高開銷的Win32核心對 象(例如等待控制程式碼)來提高效能。通常,當等待時間較短並且只有在嘗試了原始同步型別並發現它們並不令人滿意時,才應使用這些型別。另外,在需要跨程式通訊 的方案中不能使用輕量型別。
以下內容來源這篇文章:
CountdownEvent
CountdownEvent,前文中我們提及了CountdownEvent實現的同步效果。這裡我們將給出一 個CountdownEvent適用的場景及示例程式碼。如我們可以在主執行緒中模擬一個執行緒池,通過CountdownEvent使得主執行緒可以等待執行緒池 中所有執行緒結束後才能繼續執行(對所有子執行緒的執行順序沒有要求)。在給出程式碼之前先介紹一些CountdownEvent中一些主要的屬性與方法:
過載的建構函式:CountdownEvent的建構函式接受一個整型值,表示事件控制程式碼最初必須的訊號數。
InitialCount屬性:這個屬性正是建構函式接收的引數所設定的值。
CurrentCount屬性:事件解除阻塞所必需的剩餘訊號數。
AddCount方法:將CurrentCount屬性的值加1。
Single方法:給出一個訊號,這將是CurrentCount的值減1。
class Program
{
static void Main()
{
var customers = Enumerable.Range(1, 20);
using (var countdown = new CountdownEvent(customers.Count()))
{
foreach (var customer in customers)
{
int currentCustomer = customer;
ThreadPool.QueueUserWorkItem(delegate
{
BuySomeStuff(currentCustomer);
countdown.Signal();
//for test
Console.WriteLine(” CountdownEvent:” + countdown.CurrentCount);
});
}
countdown.Wait();
}
//主執行緒繼續執行
Console.WriteLine(“All Customers finished shopping…”);
Console.ReadKey();
}
static void BuySomeStuff(int customer)
{
// Fake work
Thread.SpinWait(200000000);
Console.Write(“Customer {0} finished”, customer);
}
}
程式碼輸出(每次執行子執行緒執行順序可能不同):
Customer 1 finished CountdownEvent:19
Customer 2 finished CountdownEvent:18
Customer 3 finished CountdownEvent:17
Customer 4 finished CountdownEvent:16
Customer 5 finished CountdownEvent:15
Customer 6 finished CountdownEvent:14
Customer 7 finished CountdownEvent:13
Customer 8 finished CountdownEvent:12
Customer 9 finished CountdownEvent:11
Customer 10 finished CountdownEvent:10
Customer 11 finished CountdownEvent:9
Customer 12 finished CountdownEvent:8
Customer 13 finished CountdownEvent:7
Customer 14 finished CountdownEvent:6
Customer 15 finished CountdownEvent:5
Customer 16 finished CountdownEvent:4
Customer 17 finished CountdownEvent:3
Customer 18 finished CountdownEvent:2
Customer 20 finished CountdownEvent:1
Customer 19 finished CountdownEvent:0
All Customers finished shopping…
程式碼中主執行緒中呼叫Wait方法來等待子執行緒完成(即CountdownEvent的CurrentCount屬性變為0)。
CountdownEvent內部通過ManualResetEventSlim與Interlocked實現,ManualResetEventSlim用於實現事件等待控制程式碼,而Interlocked用於執行緒計數。
Barrier
這個類的作用很明確,使用很簡單,首先介紹其中幾個比較重要的屬性與方法,之後直接進入示例:
建構函式:兩個過載共同的引數是需要被同步的執行緒的數量,引數較多的一個過載第二個引數接收一個Action<Barrier>型別物件,表示所有執行緒達到同一階段後執行的方法。
ParticipantCount屬性:即建構函式中設定的需要被同步的執行緒的數量。
SignalAndWait方法:發出參與者已達到Barrier的訊號,等待所有其他參與者也達到Barrier。
場景如下:Charlie、Mac、Dennis三個人相約在途中的加油站會合後一同前往西雅圖。我們用Barrier來模擬這個場景,重要的是在加油站會和這一點進行同步。
程式碼:
class Program
{
static Barrier sync;
static CancellationToken token;
static void Main(string[] args)
{
var source = new CancellationTokenSource();
token = source.Token;
sync = new Barrier(3);
var charlie = new Thread(() => DriveToBoston(“Charlie”, TimeSpan.FromSeconds(1)));
charlie.Start();
var mac = new Thread(() => DriveToBoston(“Mac”, TimeSpan.FromSeconds(2)));
mac.Start();
var dennis = new Thread(() => DriveToBoston(“Dennis”, TimeSpan.FromSeconds(3)));
dennis.Start();
//source.Cancel();
charlie.Join();
mac.Join();
dennis.Join();
Console.ReadKey();
}
static void DriveToBoston(string name, TimeSpan timeToGasStation)
{
try
{
Console.WriteLine(“[{0}] Leaving House”, name);
// Perform some work
Thread.Sleep(timeToGasStation);
Console.WriteLine(“[{0}] Arrived at Gas Station”, name);
// Need to sync here
sync.SignalAndWait(token);
// Perform some more work
Console.WriteLine(“[{0}] Leaving for Boston”, name);
}
catch (OperationCanceledException)
{
Console.WriteLine(“[{0}] Caravan was cancelled! Going home!”, name);
}
}
}
執行結果(同樣每次執行子執行緒執行順序可能不同):
[Charlie] Leaving House
[Mac] Leaving House
[Dennis] Leaving House
[Charlie] Arrived at Gas Station
[Mac] Arrived at Gas Station
[Dennis] Arrived at Gas Station
[Dennis] Leaving for Boston
[Mac] Leaving for Boston
[Charlie] Leaving for Boston
另外可以取消程式碼中的註釋,觀察多執行緒取消的效果。
其它.NET4.0新增的執行緒類
SpinWait
從.NET Framework 4開始,當執行緒必須等待發生某個事件發出訊號時或需要滿足某個條件時,可以使用System.Threading.SpinWait結構,前提是實際等待 時間預計會少於通過使用等待控制程式碼或通過其他方式阻塞當前執行緒所需要的等待時間,否則SpinWait空轉導致的CPU開銷會影響其它程式。通過使 用 SpinWait,可以指定在一個較短的時段內邊等待邊旋轉,然後只有在相應的條件在指定時間內無法得到滿足的情況下放棄旋轉。
其它小話題:
Thread.Interrupt方法可用於使執行緒跳出阻塞狀態(如等待訪問同步程式碼區域)。Thread.Interrupt 還可用於使執行緒跳出 Thread.Sleep 等操作。