最近看並行編程書本的一些心得,簡單記錄下多線程和並行編程必知必會的幾個概念,再次加深自己的理解。
.NET Framework4提供了一個新的命名空間System.Collections.Concurrent用於解決常用集合在並發情況下的線程安全問題(ps:通過這個命名空間還可以訪問用於並行化循環和PLINQ的自定義分區器Partitioner)。這個命名空間下的所有線程安全集合都在某種程度上使用了無鎖技術。也就是說,這些集合通過使用比較並交換(Compare And Swap,CAS)指令和內存屏障(Memory Barrier),避免了使用典型的互斥的重量級的鎖,雖然實際開發中對性能要求不高的業務系統中加鎖可以獲得最經濟實惠的開發效益。
在多線程和並發這兩個主題下從來都離不開鎖的身影,鎖是用來做並發最簡單但是代價也可能是最高的方式,在某些場景下加鎖是非常經濟實惠的解決方案。
鎖很好用,代碼也極好維護,但是必須清楚不能濫用,否則可能導致嚴重性能問題。因為加鎖會增加系統內核態與用戶態之間的切換開銷以及線程調度開銷。我們知道,加鎖、釋放鎖會導致上下文切換和調度延時,等待鎖的線程會被掛起直至鎖釋放。內核態的鎖需要操作系統進行一次上下文切換,在上下文切換的時候,cpu之前緩存的指令和數據都將失效,對性能影響最大。
a、不使用鎖
b、使用小粒度的鎖,常見的鎖如互斥鎖(lock)、讀寫鎖(ReaderWriterLockSlim,ReaderWriterLock)等等
c、鎖住盡可能短的時間
為了封裝鎖的邏輯,通常需要一個同步對象。比如常見的簡單粗暴的同步代碼裡,lock(sth)的sth就是一個同步對象。
同步對象必須是引用類型(字符串通常不適合做同步對象,想想為什麼),而且它通常是私有的,通常是一個instance或者static field。
為了精確的控制鎖的scope和粒度,我們通常會創建一個dedicated字段,比如locker,asyncObj等;
避免使用lock(this) 或者lock(typeof(sometype))或者lock(string),這種使用方法將無法封裝鎖的邏輯,難以避免死鎖和過度的阻塞,甚至在一個進程內還會溢出app domain邊界。
如果你的設計為了復用而有很多共享數據,那麼在多線程高並發環境下使用Lock還是LockFree的同步算法都是不可避免的。
根據經驗,當我們需要訪問共享的可寫字段時,通常就可以通過鎖來同步。
為了減少鎖,我們需要減少共享數據的使用。
CAS,簡單說來就是比較並交換,大致邏輯就是如果A與B相等,那麼將C賦值給A。
CAS 操作包含三個操作數:內存位置(V)、預期原值(A)和新值(B)。
如果內存位置的值V與預期原值A相匹配,那麼處理器會自動將該位置值更新為新值B;否則,處理器不做任何操作。
CAS(T* ptr, T expected, T fresh) { (*ptr != expected) ; *ptr = fresh; ; }
CAS是CPU指令級的操作,看上去只有一步原子操作,避免了請求操作系統來裁定鎖的問題,所以一般很快。CAS操作是基於共享數據不會被修改的假設,當同步沖突出現的機會很少時,這種假設就能帶來較大的性能提升。主要優點如下:
a、避免通常加鎖所導致的嚴重性能開銷,減少了內核態與用戶態之間的切換開銷以及線程調度開銷;
b、實現更細力度的並行控制,提高系統吞吐量,有些情況下可以達到成倍的關鍵業務的性能提升。
CAS雖然有明顯的優點,但天下沒有免費的午餐 ,通過CAS實現的LockFree也存在很多問題,比如:
a、與硬件體系結構的內存讀寫模型相關,所以存在移植問題
b、實現復雜,其正確性很難被證明
(a)、受限於CPU指令
(b)、即使簡單的數據結構也要通過復雜的算法來實現
(c)、ABA問題
c、代碼難以維護
d、存在活鎖(livelock)問題
所謂活鎖,簡單來講就是指事物1可以使用資源,但它讓其他事物先使用資源;事物2可以使用資源,但它也讓其他事物先使用資源,於是兩者一直謙讓,結果兩者都無法使用資源。
為什麼需要MemoryBarrier(內存屏蔽),MSDN的解釋是:
MemoryBarrier is required only on multiprocessor systems with weak memory ordering (for example, a system employing multiple Intel Itanium processors).
Synchronizes memory access as follows: The processor executing the current thread cannot reorder instructions in such a way that memory accesses prior to the call to MemoryBarrier execute after memory accesses that follow the call to MemoryBarrier.
簡單來說就是處理器會對運行CPU指令順序重排優化,而編譯後的程序可能因為編譯器優化或者計算機硬件結構比如分布式系統等諸多原因,不以編碼時的順序執行,從而引發預期外的問題。
Memory Barrier就是一種在底層保證語句按順序執行的解決方案,調用Thread.MemoryBarrier()之後的代碼中內存訪問不能在這之前就完成了,也就是它可以限制指令重排和內存讀寫的緩存。
參考:
http://stackoverflow.com/questions/3556351/why-we-need-thread-memorybarrier
Barrier類,允許多個任務同步它們不同階段上的並發工作。
System.Collections.Concurrent命名空間下主要的線程安全並行集合有如下幾種:
ConcurrenctQueue是System.Collections.Queue的並發版本。它是一個FIFO(Fisrt In,First Out,先進先出)的集合。
ConcurrenctQueue是完全無鎖的,但是當CAS操作失敗且面臨資源爭用的時候,它可能會自旋並且進行重試操作。
ConcurrenctStack是System.Collections.Stack的並發版本。它是一個LIFO(Lastt In,First Out,後進先出)的集合。
ConcurrenctStack是完全無鎖的,但是當CAS操作失敗且面臨資源爭用的時候,它可能會自旋並且進行重試操作。
ConcurrenctBag提供了一個無序的對象集合,而且支持對象重復,當不用考慮順序時非常有用。
ConcurrenctBag使用了很多不同的機制,最大程度地減少了同步的需求以及同步所帶來的開銷。
ConcurrenctBag為每一個訪問集合的線程維護了一個本地隊列,而且在可能的情況下,它會以無鎖的方式訪問這個本地隊列。
ConcurrenctBag在同一個線程添加元素(生產)和刪除元素(消費)的場合下效率非常高。然而,ConcurrenctBag有時候會用到鎖,因此,在生產者和消費者線程完全分開的場景下效率非常低下。
ConcurrenctDictionary與經典的鍵值對的字典類似,提供了並發的鍵值訪問。它是System.Collections.IDictionary實現的並發版本。
ConcurrenctDictionary對於讀操作是完全無鎖的,它對於需要頻繁使用讀取的操作進行了優化。
當很多任務或者線程在字典中添加或者修改數據的時候,ConcurrenctDictionary會使用細粒度的鎖。
BlockingCollection與經典的阻塞隊列數據結構類似,它是對一個IProducerConsumerCollection<T>實例的包裝器,提供了阻塞(block)和限界(bound)的能力。
BlockingCollection能夠適用於有多個任務添加和刪除數據的生產者-消費者的情形。
這裡再順帶提一下IProducerConsumerCollection<T>這個接口,它繼承自IEnumerable<T>、ICollection和IEnumerable。忍不住要為IProducerConsumerCollection<T>、IEnumerable<T>、ICollection和IEnumerable這幾個接口的抽象拍手叫好。可以說,MS對集合的設計是非常富有遠見並適應變化的。
PS:關於並行集合和線程安全,很久之前我也寫過總結,可以參考之前的拙文淺析線程安全容器的實現。
參考:
<<C#並行編程高級教程>>
http://msdn.microsoft.com/zh-cn/library/system.collections.concurrent(v=vs.110).aspx
http://msdn.microsoft.com/zh-cn/library/dd267312(v=vs.110).aspx
http://blog.hesey.net/2011/09/resolve-aba-by-atomicstampedreference.html