前兩篇博客,分別介紹了用戶模式和內核模式的同步構造,由於它們各有優勢和劣勢。本文將介紹如何將這兩者的優勢結合在一起,構建一個性能良好的同步機制。
一,實現一個簡單的混合同步鎖
#region hybird lock /// <summary> /// 簡單的混合同步鎖 /// </summary> private sealed class HybirdLock { private int m_waiters = 0; AutoResetEvent m_waitLock = new AutoResetEvent(false); public void Enter() { //如果只有一個線程,直接返回 if (Interlocked.Increment(ref m_waiters) == 1) return; //1個以上的線程在這里被阻塞 m_waitLock.WaitOne(); } public void Leave() { //如果只有一個線程,直接返回 if (Interlocked.Decrement(ref m_waiters) == 0) return; //如果有多個線程等待,就喚醒一個 m_waitLock.Set(); } }
優點:只有一個線程的時候僅在用戶模式下運行(速度極快),多於一個線程時才會用到內核模式(AutoRestEvent),這大大的提升了性能。由於線程的並發訪問畢竟是少數,多數情況下都是一個線程在訪問資源,利用用戶模式構造可以保證速度,利用內核模式又可以阻塞其它線程(雖然也有線程切換代價,但比起用戶模式的一直自旋浪費cpu時間可能會更好,況且只有在多線程沖突時才會使用這個內核模式,幾率很低)。
二、實現一個加入自旋,線程所有權,遞歸的混合同步鎖
- 自旋:使多線程並發時,可以在一定的時間內維持在用戶模式,如果在這個期間獲得了鎖,就不用切換到內核模式,以避免切換的開銷。
- 線程所有權:只有獲得鎖的線程才能釋放鎖。
- 遞歸:就是同一線程可以多次調用獲取鎖的方法,然后調用等次數的釋放鎖的操作(mutex就屬於這種類型)。
下面來看看具體的實現:
/// <summary> /// 加入自旋,線程多有權,遞歸的混合同步鎖 /// </summary> private sealed class AnotherHybirdLock : IDisposable { //等待的線程數 private int m_waiters = 0; //切換到內核模式是,用於同步 AutoResetEvent m_waitLock = new AutoResetEvent(false); //用戶模式自旋的次數(可以調整大小) private int m_spinCount = 4000; //用於判斷獲取和釋放鎖是不是同一線程 private int m_owningThreadId = 0; //同一線程循環計數(為0時,代表該線程不擁有鎖了) private int m_recursion = 0; private void Enter() { int threadId = Thread.CurrentThread.ManagedThreadId; //同一線程,多次調用的情況 if (m_owningThreadId == threadId) { m_recursion++; return; } //先采用用戶模式自旋,這避免了切換 SpinWait spinWait = new SpinWait();//.Net自帶的用於用戶模式等待的類 for (int i = 0; i < m_spinCount; i++) { //試圖在用戶模式等待獲得鎖,如果獲得成功,應跳過內核模式的阻塞 if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) { //這里用了goto語句,可以用flag等去掉goto goto GotLock; } spinWait.SpinOnce(); } //內核模式阻塞(在嘗試獲取了一次) //如果=1,也不用在內核模式阻塞 if (Interlocked.Increment(ref m_waiters) > 1) { //多個線程在這里都會被阻塞 m_waitLock.WaitOne();//性能損失 //等這個線程醒來時,它擁有鎖,並記錄一些狀態 } GotLock: //線程獲取鎖是記錄線程Id,重置計數為1 m_owningThreadId = threadId; m_recursion = 1; } private void Leave() { int threadId = Thread.CurrentThread.ManagedThreadId; //檢查釋放鎖的線程的一致性 if (threadId != m_owningThreadId) throw new SynchronizationLockException("Lock not owned by calling thread"); //同一線程,循環計數沒有歸0,不能遞減線程計數 if (--m_recursion > 0) return; m_owningThreadId = 0;//沒有線程擁有鎖 //么有其它線程被阻塞,直接返回 if (Interlocked.Decrement(ref m_waiters) == 0) return; //有其他線程被阻塞,喚醒其中一個 m_waitLock.Set();//這里有性能損失 } #region IDisposable [MethodImpl(MethodImplOptions.Synchronized)] public void Dispose() { m_waitLock.Dispose(); } #endregion }
注釋中,已經寫的相當詳細了,一定要好好理解它的實現方式,我們最常用的Monitor類和它的實現方式幾乎一樣。
三、細數FCL提供的混合構造:
有了上面的自定義混合同步構造的基礎,再來看看.net為我們都准備了哪些能夠直接使用的混合同步構造。
特別要注意一點:它們的性能都會比單純的內核模式構造(如Mutex,AutoResetEvent等)要好很多,在實際項目中,要酌情使用。
2.1 ManualResetEventSlim,SemaphoreSlim
它們的構造和內核模式的ManualResetEvent,Semaphore完全一致,只是它們都在用戶模式中“自旋”,而且都推遲到發生第一次競爭時,才創建內核模式的構造。另外,可以向wait方法傳入CancellationToken以支持取消。
2.2 Monitor類和同步塊
Monitor類應該是我們我們使用得最頻繁的同步技術。它提供了一個互斥鎖,這個鎖支持自旋,線程所有權和遞歸。和我們上面展示的那個自定義同步類AnotherHybirdLock相似。它是一個靜態類,提供了Enter和Exit方法用於獲取鎖和釋放鎖,會使用到傳遞給Enter和Exit方法對象的同步塊。同步塊的構造和AnotherHybirdLock的字段相似,包含一個內核對象、擁有線程的ID、一個遞歸計數、以及一個等待線程的計數。關於同步塊的概念,可以查閱其它的資料,這里不做太多的講解。
Monitor存在的問題以及使用建議:
- Monitor類如果鎖住了一個業務對象,那么其他線程在該對象上的任何操作都會被阻塞。所以,最好的辦法是提供一個私有的專用字段用於鎖。如:private objectm_lock = new object();如果方法是靜態的,那么這個鎖字段也標注成靜態(static)就可以了。
- 不要對string對象加鎖。原因是,字符串可能留用(interning),兩個完全不同的代碼段可能指向同一個string對象。如果加鎖,兩個代碼段在完全不知情的情況下就被同步了。另一個原因是跨界一個AppDomain傳遞一個字符串時,不會復制副本,相反,它傳遞的是一個引用,如果加鎖,也會出現上面的情況。這是CLR在AppDomain隔離中的一個bug。
- 不要鎖住一個類型(Type)。如果一個類型對象是以“AppDomain中立”的方式加載,它會被其它AppDomain共享。線程會跨越AppDomain對該類型對象加鎖,這也是CLR的一個已知bug。
- 不要對值類型加鎖。每次調用Monitor的Enter方法,都會對這個值類型裝箱,造成每次鎖的對象都不一樣,無法做到線程同步。
- 避免向一個方法應用[MethodImpl( MethodImplOptions.Synchronized)]特性。如果方法是一個實例方法,那么JIT編譯器會加入Monitor.Enter(this)和Monitor.Exit(this)來包圍代碼。如果是一個靜態方法,傳給Enter方法的就是這個類的類型。
- 調用一個類型的類型構造器(靜態構造函數)時,CLR要獲取類型對象上的一個鎖,確保只有一個線程初始化類型對象及其靜態字段。同樣,如果類型是以“AppDomain中立”的方式加載,也會出現問題。例如,靜態構造函數里出現一個死循環,進程中所有AppDomain都不能使用該類型。所以要盡量保證靜態函數短小簡單,或盡量避免用類型構造器。
2.3 lock關鍵字
lock關鍵字是對Monitor類的一個簡化語法。
public void SomeMethod() { lock (this) { //對數據的獨占訪問。。。 } } //等價於下面這樣 public void SomeMehtodOther() { bool lockToken = false; try { //線程可能在這里推出,還沒有執行Enter方法 Monitor.Enter(this, ref lockToken); //對數據的獨占訪問。。。 } finally { if (lockToken) Monitor.Exit(this); } }
lockToken變量的作用:如果一個線程在沒有調用Enter方法時就退出,這時它的值為false,finally塊中就不會調用Exit方法;如果成功獲得鎖,它就為true,這時就可以調用Exit方法。
lock關鍵字存在的問題:
Jeffrey指出,編譯器為lock關鍵字生成的代碼默認加上了try/finally塊,如果在對數據的獨占訪問時發生了異常,當前線程是可以正常退出的。但是,如果有其他的線程正在等待,它們會被喚醒,從而訪問到由於異常而被破壞掉的臟數據,進而引發安全漏洞。與其這樣,還不如讓進程終止。另外,進入一個try塊和finally塊會使代碼的速度變慢。它建議我們杜絕使用lock關鍵字,當然,估計太多的程序員都在使用lock關鍵字,該不該杜絕使用,自己判斷。
2.4 ReaderWriterLockSlim
互斥鎖保證多線程在訪問一個資源時,只有一個線程才會運行,其它的線程都阻塞了,這會降低應用程序的吞吐量。如果所有線程都以只讀的方法訪問資源,我們就沒有必要阻塞它。另一方面,如果一個線程希望修改數據,就需要獨占的訪問。ReaderWriterLockSlim就能解決這個問題。
它的實現方式是這樣的:
- 一個線程寫入數據時,其它的所有線程都被阻塞。
- 一個線程讀取數據時,請求讀取的線程繼續執行,請求寫入的線程被阻塞。
- 一個線程寫入結束后,要么解除一個寫入線程的阻塞,要么解除一個讀取線程的阻塞。如果沒有線程被阻塞,鎖就自由了。
- 所有讀取線程結束后,一個寫入線程解除阻塞。(可見讀取更優先)
一個簡單的例子:
public class MyResource:IDisposable { //LockRecursionPolicy(NoRecursion,SupportsRecursion) //SupportsRecursion會導致增加遞歸,開銷會變得很大,盡量用NoRecursion private ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); private object m_source; public void WriteSource(object source) { m_lock.EnterWriteLock(); //寫獨占訪問 m_source = source; m_lock.ExitWriteLock(); } public object GetSource() { m_lock.EnterReadLock(); //共享訪問 object temp = m_source; m_lock.ExitReadLock(); return temp; } #region IDisposable public void Dispose() { m_lock.Dispose(); } #endregion }
.net 1.0提供了一個ReaderWriterLock,少了一個Slim后綴。它存在下面的幾個問題:
- 不存在線程競爭,數度也很慢。
- 線程所有權和遞歸被它進行了封裝,並且還取消不了。
- 相比writer,它更青睞reader,這可能造成writer排很長的對而得不到執行。
2.5 CountDownEvent
不太常用。這個構造阻塞一個線程,直到它的內部計數為0。這和Semaphore恰恰相反。如果它的CurrentCount變為0,就不能再度更改了。再次調用AddCount方法會拋出異常。
2.6 Barrier
不太常用。它可以用於一系列線程並行工作。每個參與者線程完成階段性工作后,都調用SignalAndWait方法阻塞自己,最后一個參與者線程調用SignalAndWait方法后會解除所有線程的阻塞。
如果你覺得本文對你還有一絲絲幫助,支持一下吧,總結提煉也要花很多精力呀,傷不起。。。
主要參考資料:
CLR Via C# 3 edition