之前寫過兩篇關於線程同步問題的文章(一,二),這篇中將對相關話題進行總結,本文中也對.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 等操作。