淺析線程安全容器的實現


最近寫了個小程序用到了C#4.0中的線程安全集合。想起很久以前用C#2.0開發的時候寫后台windows服務,為了利用多線程實現生產者和消費者模型,經常要封裝一些線程安全的容器,比如泛型隊列和字典等等。下面就結合部分MS的源碼和自己的開發經驗淺顯地分析一下如何實現線程安全容器以及實現線程安全容器容易產生的問題。

一、ArrayList

在C#早期版本中已經實現了線程安全的ArrayList,可以通過下面的方式構造線程安全的數組列表:

var array = ArrayList.Synchronized(new ArrayList());

我們從Synchronized方法入手,分析它的源代碼看是如何實現線程安全的:

Synchronized

 

繼續跟進去,發現SyncArrayList是一個繼承自ArrayList的私有類,內部線程安全方法的實現經過分析,很多都是像下面這樣lock(注意是lock_root對象而不是數組列表實例對象)一下完事:

lock (this._root)

有心的你可以查看SyncArrayList的源碼:

SyncArrayList

 

ArrayList線程安全實現過程小結:定義ArrayList的私有實現SyncArrayList,子類內部通過lock同步構造實現線程安全,在ArrayList中通過Synchronized對外間接調用子類


 

二、Hashtable

同樣,在C#早期版本中實現了線程安全的Hashtable,它也是早期開發中經常用到的緩存容器,可以通過下面的方式構造線程安全的哈希表:

var ht = Hashtable.Synchronized(new Hashtable());

同樣地,我們從Synchronized方法入手,分析它的源代碼看是如何實現線程安全的:

Synchronized

 

繼續跟進去,發現SyncHashtable是一個繼承自Hashtable和IEnumerable接口的私有類,內部線程安全方法的實現經過分析,很多都是像下面這樣lock(注意是lock哈希表的SyncRoot Object實例對象而不是哈希表實例)一下完事:

lock (this._table.SyncRoot)

貼一下SyncHashtable的源碼:

SyncHashtable

 

Hashtable線程安全實現過程小結:定義Hashtable的私有實現SyncHashtable,子類內部通過lock同步構造實現線程安全,在Hashtable中通過Synchronized對外間接調用子類

 

三、4.0中的線程安全容器

1、ConcurrentQueue

從上面的實現分析來說,封裝一個線程安全的容器看起來並不是什么難事,除了對線程安全容器的異常處理心有余悸,其他的似乎按步就班就可以了,不是嗎?也許還有更高明的實現吧?

在4.0中,多了一個System.Collections.Concurrent命名空間,懷着忐忑的心情查看C#4.0其中的一個線程安全集合ConcurrentQueue的源碼,發現它繼承自IProducerConsumerCollection<T>, IEnumerable<T>, ICollection, IEnumerable接口,內部實現線程安全的時候,通過SpinWait和通過互鎖構造(Interlocked)及SpinWait封裝的Segment,間接實現了線程安全。Segment的實現比較復雜,和線程安全密切相關的方法就是TryXXX那幾個方法,源碼如下:

Segment

 

上面的代碼稍微分析一下就知道它的作用。ConcurrentQueue的線程安全的Enqueue方法實現如下:

Enqueue

ConcurrentQueue<T>線程安全實現過程小結:繼承接口,子類內部通過同步構造實現接口的線程安全,直接對外公開調用

和ArrayList以及Hashtable線程安全的“曲折”實現有點不同,ConcurrentQueue<T>一開始就是朝着線程安全方向實現去的。它沒有使用lock,因為大家知道使用lock性能略差,對於讀和寫操作,應該分開,不能一概而論。ConcurrentQueue<T>具體實現在性能和異常處理上應該已經考慮的更全面周到一點。

在我看來,ConcurrentQueue<T>線程安全的具體實現有多吸引人在其次,IProducerConsumerCollection<T>接口的抽象和提取非常值得稱道,查看源碼發現ConcurrentStack<T>和ConcurrentBag<T>也繼承自該接口。<<CLR via C#>>一書中在談到接口和抽象類的時候特別舉了集合和流(Stream)的例子,微軟為什么如此設計,想起來果然很有深意。

 

2、ConcurrentDictionary<TKey, TValue>

對於線程安全的泛型字典ConcurrentDictionary<TKey, TValue>,我們也可以查看它的源碼看它的具體實現方式。看源碼有1200多行,實現稍微復雜一些。我們僅從最簡單的TryAdd方法分析:

TryAdd

 

其中內部方法TryAdd的主要實現如下:

TryAddInternal

同步構造Monitor瞬間吸引眼球,然后它的try…finally異常處理方式是不是也很眼熟?

 

四、如法炮制

如果讓我來構造實現線程安全容器,最簡單直接快速高效的方式就是參考ArrayList和 Hashtable,我們完全可以模仿它們的處理方式,通過繼承一個容器,然后內部通過lock一個SyncRoot對象,中規中矩地實現framework中其他容器的線程安全。比如要實現線程安全的泛型隊列Queue<T>,貼一下大致的偽代碼

SyncQueue

 

通過類繼承我們可以得到泛型隊列的所有特點,需要實現線程安全的地方只要按需重寫它即可,對外調用也很簡單,直接模仿ArrayList和Hashtable,添加Synchronized方法間接調用隊列的子類即可,多么清晰簡潔啊,關鍵時刻copy-paste也很有效嘛!

你可能覺得上面這樣不動腦的方式似乎很傻很天真,但這絕對是一種正常人都能想到的思路,誰讓MS的數組列表和哈希表就是這么實現的呢?

當然,我們還能想到的一種常見實現方式就是通過組合而不是類繼承,實現的偽代碼類似下面這樣:

SyncQueue

 

上面這種方式和類繼承的實現方式又有不同。它是通過在內部包裝一個容器,然后按需進行方法、屬性等等的線程安全處理,其他的所有特點依賴於那一個私有泛型隊列組合對象queue。這種情況下泛型SyncQueue和泛型隊列是組合關系,依賴性和耦合性更低,相對更靈活,封裝性更好,是一種較為通用的設計,實際開發和使用中這種方式比較常見。

到這里,我們至少可以分析得出,實現一般的線程安全容器的思路至少有兩種:類繼承(內部實現偏向使用組合)和(或)組合,線程安全的地方只要通過framework的同步構造如lock、Interlocked等實現即可。

思考:如果讓您實現線程安全容器,您優先會怎么實現呢?

 

五、線程安全並不真正安全

1、foreach遍歷

CacheUtil緩存實現的偽代碼如下:

CacheUtil

 

foreach的代碼很簡單,從哈希表構造的緩存中取數據並遍歷,如下:

GetAndForeach

 

上面的遍歷代碼一般情況下是不會有問題的。但是在多線程修改哈希表的Value的情況下,上面的foreach遍歷有可能發生異常。為什么呢?下面來簡單分析一下:

從代碼中可以看出來,哈希表中的Value存放的是IList類型,那么值所保存的應該是一個引用(也就是指針)。
(1)、當線程1通過索引器得到這個IList時,這個TryGet讀取操作是線程安全的。接着線程1進行的操作是列表遍歷。在foreach進行遍歷不為空的List的時候,遍歷的其實是存放在IList指針指向的引用。

(2)、在foreach遍歷集合的時候,這時候線程2如果正好對哈希表的key所對應的Value進行修改,IList的指針所指向的引用改變了,所以線程1的遍歷操作就會拋出異常。

這是一個簡單而又經典的陷阱,在哈希表的MSDN線程安全塊有一段說明:

Enumerating through a collection is intrinsically not a thread safe procedure. Even when a collection is synchronized, other threads can still modify the collection, which causes the enumerator to throw an exception. To guarantee thread safety during enumeration, you can either lock the collection during the entire enumeration or catch the exceptions resulting from changes made by other threads.

 

2、通過索引取集合中的數據

列表通過索引取值,一個簡單的示例代碼如下:

GetFirstOrDefault

 

當列表中的元素為1個的時候,上面的操作非常容易進入一個無厘頭的陷阱之中。有人會問怎么會有陷阱,你看取數據之前都判斷了啊,邏輯正確了啊,這哪里還有問題嗎?

按照類似於1中的分析,GetFirstOrDefault應該可以分為下面兩步:

(1)線程1取數據,判斷list.Count的時候發現列表內有1個元素,這一步線程安全,沒有任何問題,然后准備返回索引為0的元素;

(2)線程2在線程1將要取索引為0的元素之前移除了列表中的唯一元素或者直接將list指向null,這樣線程1通過索引取元素就拋出異常了。

 

3、如何保證容器數據操作安全?

從上面的兩個示例,我們得知通常所看到的線程安全實際上並不一定安全。不安全的主要原因就是容器內的數據很容易被其他線程改變,或者可以簡要概括為:一段時間差引發的血案。實際上,我們平時所做的業務系統,歸根結底很多bug或者隱藏的缺陷都是由不起眼的一小段時間差引起的。

保證容器內的數據和操作都安全,一種簡單而有效的方法就是將你所要進行的操作進行“事務”處理。比如示例1中哈希表的Value的遍歷操作,通常情況下,我們分作兩步:

(1)、(安全地)讀取數據

(2)、(不安全地)遍歷;

為了達到遍歷操作不拋出異常,我們可以把兩步合並為一步,抽象出一個線程安全的新方法TryGetAndEnumerate,這樣可以保證線程安全地取數據和遍歷,具體實現無非是lock一下SyncRoot類似的這種思路。但是這種線程安全的遍歷可能代價很高,而且極其不通用。

線程安全集合容易產生的問題和解決方法,請參考JaredPar MSFT的Why are thread safe collections so hard?,這篇文章對設計一個線程安全的容器的指導原則是:

1、Don’t add an decision procedures(procedures like Count as decision procedures).  They lead users down the path to bad code.
2、Methods which query the object can always fail and the API should reflect this.

實際上大家都知道利用事務處理思想多用TryXXX方法一般是沒錯的。

注意,在.net framework4.0中,使用ConcurrentQueue的TryDequeue方法會引發內存泄漏(Memory Leak),但Dequeue方法則不會,這個bug微軟已經在.net framework4.5中修復,可放心使用。關於ConcurrentQueue的TryDequeue引發內存泄漏的bug的討論可參考這里

 

參考:

http://msdn.microsoft.com/en-us/library/system.collections.arraylist(v=VS.100).aspx

http://msdn.microsoft.com/en-us/library/system.collections.hashtable(v=vs.100).aspx

http://blogs.msdn.com/b/jaredpar/archive/2009/02/11/why-are-thread-safe-collections-so-hard.aspx

http://msdn.microsoft.com/en-us/library/dd287108

http://msdn.microsoft.com/en-us/library/dd287147

http://blog.zhaojie.me/2012/04/exception-handling-in-csharp-async-await-1.html

http://blog.zhaojie.me/2012/04/exception-handling-in-csharp-async-await-2.html

http://stackoverflow.com/questions/2678165/net-framework-possible-memory-leaky-classes/

<<CLR via C#>>

<<C# in Depth>>


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM