編程編的久了,總會遇到多線程的情況,有些時候我們要幾個線程合作完成某些功能,這時候可以定義一個全局對象,各個線程根據這個對象的狀態來協同工作,這就是基本的線程同步。 支持多線程編程的語言一般都內置了一些類型和方法用於創建上述所說的全局對象也就是鎖對象,它們的作用類似,使用場景有所不同。.Net中這玩 ...
編程編的久了,總會遇到多線程的情況,有些時候我們要幾個線程合作完成某些功能,這時候可以定義一個全局對象,各個線程根據這個對象的狀態來協同工作,這就是基本的線程同步
。
支持多線程編程的語言一般都內置了一些類型和方法用於創建上述所說的全局對象也就是鎖對象
,它們的作用類似,使用場景有所不同。.Net
中這玩意兒有很多,若不是經常使用,我想沒人能完全記住它們各自的用法和相互的區別。為了便於查閱,現將它們記錄在此。
ps:本文雖然關註 .Net 平臺,但涉及到的大部分鎖概念都是平臺無關的,在很多其它語言(如_Java
__)中都能找到對應。_
鎖模式
正式介紹各種鎖之前,先瞭解下鎖模式——鎖分為內核模式鎖
和用戶模式鎖
,後面也有了混合模式鎖
。
內核模式就是在系統級別讓線程中斷,收到信號時再切回來繼續幹活。該模式線上程掛起時由系統底層負責,幾乎不占用 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 包含有以下幾個派生類:
- ManualResetEvent
- AutoResetEvent
- CountdownEvent
- Mutex
- 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 方法等待特定的條件滿足。可以認為 SpinWait 是 Thread.Sleep 的輕量替換。它並非鎖,但可以通過它實現自定義鎖(下文會講到)。
SpinLock
是對 SpinWait 的簡單封裝。
自定義鎖
由 SpinWait 使用方法易知,搭配一個或多個全局條件,就可以實現自定義鎖。除此之外,還有一些東東,本身並不屬於鎖的範疇,但可藉助以實現自定義鎖,比如下麵描述的volatile
和Interlocked
。
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 中,原子操作需要考慮多個核心同時訪問共用資源的情況,需要使用特殊的機制來確保操作的原子性,如硬體支持的原子指令或鎖機制。
這兩個特點,使得原子操作可作為內部條件實現自定義鎖。