.NET中各種執行緒同步鎖

莱布尼茨發表於2024-08-18

程式設計編的久了,總會遇到多執行緒的情況,有些時候我們要幾個執行緒合作完成某些功能,這時候可以定義一個全域性物件,各個執行緒根據這個物件的狀態來協同工作,這就是基本的執行緒同步

​支援多執行緒程式設計的語言一般都內建了一些型別和方法用於建立上述所說的全域性物件也就是鎖物件,它們的作用類似,使用場景有所不同。.Net中這玩意兒有很多,若不是經常使用,我想沒人能完全記住它們各自的用法和相互的區別。為了便於查閱,現將它們記錄在此。

ps:本文雖然關注 .Net 平臺,但涉及到的大部分鎖概念都是平臺無關的,在很多其它語言(如_Java__)中都能找到對應。_

volatile 關鍵字

確切地說,volatile 並不屬於鎖的範疇,但其背後蘊藏著多執行緒的基本概念,有時人們也使用它實現自定義鎖。

快取一致性

瞭解volatile,首先要了解.Net/Java的記憶體模型(.Net 當年是諸多借鑑了 Java 的設計理念)。而 Java 記憶體模型又借鑑了硬體層面的設計。

我們知道,在現代計算機中,處理器的指令速度遠超記憶體的存取速度,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體來作為主存與處理器之間的緩衝。處理器計算直接存取的是快取記憶體中的資料,計算完畢後再同步到主存中。

在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主存。

而 Java 記憶體模型的每個執行緒有自己的工作記憶體,其中保留了被執行緒使用的變數的副本。執行緒對變數的所有的操作都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變數。不同執行緒之間也不能直接訪問對方工作記憶體中的變數,執行緒間變數的值的傳遞需要透過主記憶體中轉來完成。

雖然兩者的設計相似,但是前者主要解決存取效率不匹配的問題,而後者主要解決記憶體安全(競爭、洩露)方面的問題。顯而易見,這種設計方案引入了新的問題——快取一致性(CacheCoherence)——即各工作記憶體、工作記憶體與主存,它們儲存的相同變數對應的值可能不一樣。


為了解決這個問題,很多平臺都內建了 volatile 關鍵字,使用它修飾的變數,可以保證所有執行緒每次獲取到的是最新值。這是怎麼做到的呢?這就要求所有執行緒在訪問變數時遵循預定的協議,比如MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等,此處不贅述,只需要知道系統額外幫我們做了一些事情,多少會影響執行效率。

另外 volatile 還能避免編譯器自作聰明重排指令。重排指令在大多數時候無傷大雅,還能對執行效率有一定提升,但某些時候會影響到執行結果,此時就可以使用 volatile。

Interlocked

同 volatile 的可見性作用類似,Interlocked 可為多個執行緒共享的變數提供原子操作,這個類是一個靜態類,它提供了以執行緒安全的方式遞增、遞減、交換和讀取值的方法。

它的原子操作基於 CPU 本身,非阻塞,所以也不是真正意義上的鎖,當然效率會比鎖高得多。


鎖模式

接下來正式介紹各種鎖之前,先了解下鎖模式——鎖分為核心模式鎖使用者模式鎖,後面也有了混合模式鎖

核心模式就是在系統級別讓執行緒中斷,收到訊號時再切回來繼續幹活。該模式線上程掛起時由系統底層負責,幾乎不佔用 CPU 資源,但執行緒切換時效率低。

使用者模式就是透過一些 CPU 指令或者死迴圈讓執行緒一直執行著直到可用。該模式下,執行緒掛起會一直佔用 CPU 資源,但執行緒切換非常快。

長時間的鎖定,優先使用核心模式鎖;如果有大量的鎖定,且鎖定時間非常短,切換頻繁,使用者模式鎖就很有用。另外核心模式鎖可以實現跨程序同步,而使用者模式鎖只能程序內同步

本文中,除文末輕量級同步原語為使用者模式鎖,其它鎖都為核心模式。

lock 關鍵字

lock 應該是大多數開發人員最常用的鎖操作,此處不贅述。需要注意的是使用時應 lock 範圍儘量小,lock 時間儘量短,避免無謂等待。

Monitor

上面 lock 就是Monitor的語法糖,透過編譯器編譯會生成 Monitor 的程式碼,如下:

lock (syscRoot)
{
    //synchronized region
}
//上面的lock鎖等同於下面Monitor
Monitor.Enter(syscRoot);
try
{
    //synchronized region
}
finally
{
    Monitor.Exit(syscRoot);
}

Monitor 還可以設定超時時間,避免無限制的等待。同時它還有 Pulse\PulseAll\Wait 實現喚醒機制。

ReaderWriterLock

很多時候,對資源的讀操作頻率要遠遠高於寫操作頻率,這種情況下,應該對讀寫應用不同的鎖,使得在沒有寫鎖時,可以併發讀(加讀鎖),在沒有讀鎖或寫鎖時,才可以寫(加寫鎖)。ReaderWriterLock就實現了此功能。

主要的特點是在沒有寫鎖時,可以併發讀,而非一概而論,不論讀寫都只能一次一個執行緒。

MethodImpl(MethodImplOptions.Synchronized)

如果是方法層面的執行緒同步,除上述的lock/Monitor之外,還可以使用MethodImpl(MethodImplOptions.Synchronized)特性修飾目標方法。

SynchronizationAttribute

ContextBoundObject

要了解SynchronizationAttribute,不得不先說說ContextBoundObject

首先程序中承載程式集執行的邏輯分割槽我們稱之為AppDomain(應用程式域),在應用程式域中,存在一個或多個儲存物件的區域我們稱之為Context(上下文)

在上下文的介面當中存在著一個訊息接收器負責檢測攔截和處理資訊。當物件是MarshalByRefObject的子類的時候,CLR將會建立Transparent Proxy,實現物件與訊息之間的轉換。應用程式域是 CLR 中資源的邊界。一般情況下,應用程式域中的物件不能被外界的物件所訪問,而MarshalByRefObject 的功能就是允許在支援遠端處理的應用程式中跨應用程式域邊界訪問物件,在使用.NET Remoting遠端物件開發時經常使用到的一個父類。

ContextBoundObject更進一步,它繼承 MarshalByRefObject,即使處在同一個應用程式域內,如果兩個 ContextBoundObject 所處的上下文不同,在訪問對方的方法時,也會藉由Transparent Proxy實現,即採用基於訊息的方法呼叫方式。這使得 ContextBoundObject 的邏輯永遠在其所屬的上下文中執行。

ps: 相對的,沒有繼承自 ContextBoundObjec t的類的例項則被視為上下文靈活的(context-agile),可存在於任意的上下文當中。上下文靈活的物件總是在呼叫方的上下文中執行。


一個程序內可以包括多個應用程式域,也可以有多個執行緒。執行緒可以穿梭於多個應用程式域當中,但在同一個時刻,執行緒只會處於一個應用程式域內。執行緒也能穿梭於多個上下文當中,進行物件的呼叫。

SynchronizationAttribute用於修飾ContextBoundObject,使得其內部構成一個同步域,同一時段內只允許一個執行緒進入。


WaitHandle

在查閱一些非同步框架的原始碼或介面時,經常能看到WaitHandle這個東西。WaitHandle 是一個抽象類,它有個核心方法WaitOne(int millisecondsTimeout, bool exitContext),第二個參數列示在等待前退出同步域。在大部分情況下這個引數是沒有用的,只有在使用SynchronizationAttribute修飾ContextBoundObject進行同步的時候才有用。它使得當前執行緒暫時退出同步域,以便其它執行緒進入。具體請看本文 SynchronizationAttribute 小節。

WaitHandle 包含有以下幾個派生類:

  1. ManualResetEvent
  2. AutoResetEvent
  3. CountdownEvent
  4. Mutex
  5. Semaphore

ManualResetEvent

可以阻塞一個或多個執行緒,直到收到一個訊號告訴 ManualResetEvent 不要再阻塞當前的執行緒。 注意所有等待的執行緒都會被喚醒。

可以想象 ManualResetEvent 這個物件內部有一個訊號狀態來控制是否要阻塞當前執行緒,有訊號不阻塞,無訊號則阻塞。這個訊號我們在初始化的時候可以設定它,如ManualResetEvent event=new ManualResetEvent(false);這就表明預設的屬性是要阻塞當前執行緒。

程式碼舉例:

ManualResetEvent _manualResetEvent = new ManualResetEvent(false);

private void ThreadMainDo(object sender, RoutedEventArgs e)
{
    Thread t1 = new Thread(this.Thread1Foo);
    t1.Start(); //啟動執行緒1
    Thread t2 = new Thread(this.Thread2Foo);
    t2.Start(); //啟動執行緒2
    Thread.Sleep(3000); //睡眠當前主執行緒,即呼叫ThreadMainDo的執行緒
    _manualResetEvent.Set();   //有訊號
}

void Thread1Foo()
{
    //阻塞執行緒1
    _manualResetEvent.WaitOne();
    
    MessageBox.Show("t1 end");
}

void Thread2Foo()
{
    //阻塞執行緒2
    _manualResetEvent.WaitOne();
    
    MessageBox.Show("t2 end");
}

AutoResetEvent

用法上和 ManualResetEvent 差不多,不再贅述,區別在於內在邏輯。

與 ManualResetEvent 不同的是,當某個執行緒呼叫Set方法時,只有一個等待的執行緒會被喚醒,並被允許繼續執行。如果有多個執行緒等待,那麼只會隨機喚醒其中一個,其它執行緒仍然處於等待狀態。

另一個不同點,也是為什麼取名Auto的原因:AutoResetEvent.WaitOne()會自動將訊號狀態設定為無訊號。而一旦ManualResetEvent.Set()觸發訊號,那麼任意執行緒再呼叫 ManualResetEvent.WaitOne() 就不會阻塞,除非在此之前先呼叫anualResetEvent.Reset()重置為無訊號。

CountdownEvent

它的訊號有計數狀態,可遞增AddCount()或遞減Signal(),當到達指定值時,將會解除對其等待執行緒的鎖定。

注意:CountdownEvent 是使用者模式鎖。

Mutex

Mutex 這個物件比較“專制”,同時段內只能准許一個執行緒工作。

Semaphore

對比 Mutex 同時只有一個執行緒工作,Semaphore 可指定同時訪問某一資源或資源池的最大執行緒數。


輕量級同步

.NET Framework 4 開始,System.Threading 名稱空間中提供了六個新的資料結構,這些資料結構允許細粒度的併發和並行化,並且降低一定必要的開銷,它們稱為輕量級同步原語,它們都是使用者模式鎖,包括:

  • Barrier
  • CountdownEvent(上文已介紹)
  • ManualResetEventSlim (ManualResetEvent 的輕量替代,注意,它並不繼承 WaitHandle)
  • SemaphoreSlim (Semaphore 輕量替代)
  • SpinLock (可以認為是 Monitor 的輕量替代)
  • SpinWait

Barrier

當在需要一組任務並行地執行一連串的階段,但是每一個階段都要等待其他任務完成前一階段之後才能開始時,您可以透過使用Barrier類的例項來同步這一類協同工作。當然,我們現在也可以使用非同步Task方式更直觀地完成此類工作。

SpinWait

如果等待某個條件滿足需要的時間很短,而且不希望發生昂貴的上下文切換,那麼基於自旋的等待時一種很好的替換方案。SpinWait不僅提供了基本自旋功能,而且還提供了SpinWait.SpinUntil方法,使用這個方法能夠自旋直到滿足某個條件為止。此外 SpinWait 是一個Struct,從記憶體的角度上說,開銷很小。

需要注意的是:長時間的自旋不是很好的做法,因為自旋會阻塞更高階的執行緒及其相關的任務,還會阻塞垃圾回收機制。SpinWait 並沒有設計為讓多個任務或執行緒併發使用,因此需要的話,每一個任務或執行緒都應該使用自己的 SpinWait 例項。

當一個執行緒自旋時,會將一個核心放入到一個繁忙的迴圈中,而不會讓出當前處理器時間片剩餘部分,當一個任務或者執行緒呼叫Thread.Sleep方法時,底層執行緒可能會讓出當前處理器時間片的剩餘部分,這是一個大開銷的操作。

因此,在大部分情況下, 不要在迴圈內呼叫 Thread.Sleep 方法等待特定的條件滿足 。

SpinLock是對 SpinWait 的簡單封裝。


本文在騰訊開發者社群同步釋出

相關文章