編程編的久了,總會遇到多線程的情況,有些時候我們要幾個線程合作完成某些功能,這時候可以定義一個全局對象,各個線程根據這個對象的狀態來協同工作,這就是基本的線程同步
。
支持多線程編程的語言一般都內置了一些類型和方法用于創建上述所說的全局對象也就是鎖對象
,它們的作用類似,使用場景有所不同。.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 包含有以下幾個派生類:
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 方法等待特定的條件滿足 。
SpinLock
是對 SpinWait 的簡單封裝。
轉自https://www.cnblogs.com/newton/p/18365359
該文章在 2024/8/19 8:31:40 編輯過