在.NET4.0之前,如果我們需要在多線程環境下使用Dictionary類,除了自己實現線程同步來保證線程安全外,我們沒有其他選擇。很多開發人員肯定都實現過類似的線程安全方案,可能是通過創建全新的線程安全字典,或者僅是簡單的用一個類封裝一個Dictionary對象,併在所有方法中加上鎖機制,我們稱這 ...
在.NET4.0之前,如果我們需要在多線程環境下使用Dictionary類,除了自己實現線程同步來保證線程安全外,我們沒有其他選擇。很多開發人員肯定都實現過類似的線程安全方案,可能是通過創建全新的線程安全字典,或者僅是簡單的用一個類封裝一個Dictionary對象,併在所有方法中加上鎖機制,我們稱這種方案叫“Dictionary+Locks”。
但是,我們有了ConcurrentDictionary,在MSDN中的Dictionary類文檔的線程安全的描述中指出,如果你需要用一個線程安全的實現,請使用ConcurrentDictionary。所以,既然現在已經有了一個線程安全的字典類,我們再也不需要自己實現了,很棒,不是嗎?
一、問題起源
事實上,我之前只使用過ConcurrentDictionary一次,就是在我測試其反應速度的測試中。因為在測試中它表現得很好,所以我立即把它替換到了我得類中,並做了些測試,然後,居然出了異常。那麼,到底哪裡出了問題?不是說線程安全嗎?經過了更多得測試,我找到了問題得根源,但不知道為什麼,MSDN的4.0版本中,關於GetOrAdd方法簽名的描述沒有包含一個需要傳遞一個委托類型參數的說明,在查看4.5版本後,我找到了這段備註:If you call GetOrAdd simultaneously on different threads, addValueFactory may be called multiple times, but its key/value pair might not be added to the dictionary for every call.
這就是我碰到的問題,因為之前的文檔中並沒有描述,所以我不得不做了更多的測試來確認這個問題,當然,我碰到的問題與我的使用方法有關,一般來說,我會使用字典類型來緩存一些數據:
1、這些數據創建起來非常慢。
2、這些數據只能創建一次,因為創建第二次會拋出異常,或者多次創建會導致資源泄露。
我就是在第二個條件上遇到了問題,如果兩個線程同時發現某個數據不存在,都會創建一個該數據,但只有一個結果會被成功的保存,那麼另一個怎麼辦?如果創建的過程會拋出異常,可以通過try……catch來解決(雖然不夠優雅,但能解決問題)。但如果某個資源被創建後未被回收該怎麼辦?你可能會說,一個對象被創建後,如果已經對其沒有任何引用,將會被垃圾回收掉,但,請在考慮以下,如果下麵描述的情形發生了,會怎樣:
1、使用Email動態生成代碼,我在一個Remoting框架中使用了這種方式,並且將所有的實現都放到了一個不能被回收的程式集中,如果一個類型被創建了兩次,第二個將一直存在,即使其從未被使用過。
2、直接的或間接地創建一個線程,比如我們需要創建一個組件,其使用專有地線程處理非同步消息,並且依賴於消息地接受順序。當實例化該組件時,會創建一個線程,當銷毀這個組件時,線程也會被結束。但如果銷毀組件後我們刪除了對該對象地引用,但那個線程因某種原因未結束,並且持有這個對象地引用,那麼,如果線程不死亡,這個對象也不會被回收。
3、進行P/Invoke操作,需要對所接受到地句柄地關閉次數必須與打開次數相同。
4、可以肯定的是,還可以列舉出很多類似的情形,比如一個字典對象會持有一個遠程服務上的一個服務的連接,該連接只能請求一次,如果請求第二次,對方服務會認為發生了某種錯誤,進而記錄到日誌中,(我工作過的一個公司,這種條件會遭到一些法律上的處罰),所以我們很容易的看到,並不能草率的將Dictionary+Locks直接替換成ConcurrentDictionary,即使文檔上說它是線程安全的。
二、分析問題
還不明白?
的確,在Dictionary+Locks方式下可不會產生這個問題,因為這依賴於具體的實現,讓我們來看下麵一個簡單的實例:
1 TValue result;
2 lock(dictionary)
3 {
4 if (!dictionary.TryGetValue(key, out result))
5 {
6 result = createValue(key);
7 dictionary.Add(key, result);
8 }
9 }
10 return result;
在上面的這段代碼中,在開始查詢鍵值之前,我們持有了對該字典的鎖,如果指定的鍵值不存在,將會直接創建一個,同時,因為我們已經持有了對該字典的鎖,可以直接將鍵值對添加到字典中,然後釋放字典鎖。如果兩個線程同時在查詢同一個鍵值,第一個得到字典鎖的線程將會完成對象的創建工作,另一個線程會等待這個創建的完成,併在得到字典鎖之後獲取已創建的鍵值結果。
這樣挺好的,不是嗎?
真不是!我認為像這種在並行方式下創建對象,最後只有一個被使用的情況不會產生我所描述的問題。我想闡述的情況和問題可能並不總是能復現,在並行環境中,我們可以簡單的創建兩個對象,然後丟棄一個。那麼,到底我們改如何比較Dictionary+Locks和ConcurrentDictionary呢?答案是:具體依賴於鎖使用策略和字典的使用方式。
三、並行創建同一對象
首先,我們假設某個對象可以被創建兩次,那麼如果有兩個線程在同時創建這個對象時,會發生什麼?其次,在類似的創建過程中,我們會消耗多長時間?
我們可以簡單的構建一個例子,比如實例化一個對象需要耗時10秒鐘,當第一個線程創建對象5秒鐘後,第二個實現嘗試調用GetOrAdd方法來獲取對象,因為對象仍然不存在,所以它也開始創建對象。在這種條件下,我們有兩顆CPU在並行工作5秒鐘,當第一個線程工作結束後,第二個線程仍然需要繼續運行5秒鐘來完成對象的創建,當第二個線程構建對象完畢後,發現已經有一個對象存在了,其選擇使用已存在的對象,而將剛創建的對象直接丟棄。
假如第二個線程只是簡單等待,而讓第二顆CPU處理其他工作(運行其他線程或應用程式,節省了些資源消耗),在5秒鐘之後其就可以獲取到所需的對象,而不是10秒鐘。所以,在這種條件下,Dictionary+Locks更優一些。
四、並行訪問不同對象
不,你說的情況根本就不成立!
好吧,上面的例子有點特殊,但確實描述了問題,只是這種用法比較極端。那麼,考慮下,如果當第一個線程正在創建對象時,第二個線程需要訪問另一個鍵值對象,並且該鍵值對象已經存在,會發生什麼?
在ConcurrentDictionary中,由於其沒有對讀操作進行加鎖,也就是Lock-Free的設計會使讀操作非常迅速,如果Dictionary+Locks方式,會對讀操作進行鎖互斥控制,即使需要讀取的是另一個完全不同的鍵值,顯然讀取操作會變慢。這樣看來,ConcurrentDictionary更好一些。
註:大家可以瞭解一下字典類中的 Bucket、Node、Entry等幾個概念,可能對你理解更有幫助一些。
五、多讀單寫
在Dictionary+Locks中,如果使用多個讀取方、單一寫入的方式(Mutiple Readers and Single Writer)來取待對字典的完全鎖,情況會如何?
如果一個線程正在創建對象,並且持有了一個可升級的鎖,直到這個對象創建完畢,將該鎖升級為寫操作鎖,那麼讀操作就可以在並行的環境下執行。我們也可以通過讓一個讀操作空閑等待10秒鐘來解決問題。但如果讀操作遠遠多於寫操作,我們會發現,ConcurrentDictionary的速度仍然很快,因為它實現了Lock-Free模式的讀取。
對Dictionary使用ReaderWriterLockSlim會使讀操作變的更糟糕,通常更推薦對Dictionary使用完全鎖,而不使用ReaderWriterLockSlim。所以在這種條件下,ConcurrentDictionary更優一些。
六、添加多個鍵值對
如果我們有多個鍵值需要添加,並且所有的鍵不會產生碰撞並會被分配在不同的Bucket中,情況如何?
起初,這個問題還是讓我很好奇地,但我做了個不太合適地測試,我使用了<int,int>類型地字典,並且對象地構造工廠會直接返回一個負數地結果作為鍵。我本來期待ConcurrentDictionary應該是最快地,但它卻是最慢地。而Dictionary+Locks卻表現的更快,這是為什麼呢?
這是因為,ConcurrentDictionary會分配Node並將它們放到不同的Bucket中,這種優化是為了滿足於讀操作的Lock-Free的設計,但是,在新增鍵值項時,創建Node的過程就會顯得昂貴,即使在並行的條件下,分配Node所消耗的時間仍然比使用完全鎖多。所以,這種情況下Dictionary+Locks更優一些。
七、讀操作頻率更高
坦白的說,如果有一個能快速實例化對象的委托,我們就不需要一個Dictionary了,我們可以直接調用委托來獲取對象,對吧?其實答案也是,要看情況。
想象下,如果鍵類型為string,並且包含web伺服器中各種頁面的路徑映射,而對應的值為一個對象類型,該類型包含對該頁當前訪問用戶的記錄和自伺服器啟動後所有對該頁面的訪問的數量。創建類似這種對象幾乎是瞬間的事情,並且在此之後,你不需要再創建新的對象,僅需要更改其中保存的值。所以可以允許創建兩次的方式,直到僅有一個實例被使用,然而,因為ConcurrentDictioanry分配Node資源更慢,使用Dictionary+Locks將會得到更快的創建時間。所以通過這個例子非常特殊,我們也看到了Dictionary+Locks在這種條件下表現的更好,花費了更少的時間。
雖然ConcurrentDictionary中Node分配要慢一些,我也沒有嘗試將1億個數據項放入其中來測試時間,因為那顯然很花費時間。但大部分情況下,一個數據項被創建後,其總是被讀取,而數據項的內容是如何變化的就是另外的事情了。所以說,創建數據項的過程多花笑了多少毫秒不重要,因為讀取操作更快(也是快了若幹毫秒而已),但讀操作發生的頻率更高。所以,ConcurrentDictionary更優一些。
八、創建消耗不同時間的對象
針對不同數據項的創建所消耗的時間不同,將會怎樣?
創建多個消耗不同時間的數據項,並且並行的添加至字典中,這是ConcurrentDictionary的最強點。
ConcurrentDictionary使用了多種不同的鎖機制來允許併發地添加數據項,但是諸如決定使用哪個鎖,為改變Bucket尺寸而請求鎖等邏輯,並沒有為此帶來幫助,把數據項放入Bucket中地速度是機器快速的。真正使ConcurrentDictionary勝出的原因是因為它能夠並行的創建對象。
不過,其實我們也可以做同樣的事情,如果我們並不關心是否在並行的創建對象,或者其中的一些已經被丟棄,我們可以加鎖,用來檢測該數據項是否已經存在,然後釋放鎖,創建數據項,然後再獲取鎖,再次檢查數據項是否存在,如果不存在,則添加數據項,代碼可能類似於:
1 int result;
2 lock(_dictionary)
3 if (_dictionary.TryGetValue(i, out result))
4 return result;
5
6 int createdResult = _createValue(i);
7 lock(_dictionary)
8 {
9 if (_dictionary.TryGetValue(i, out result))
10 return result;
11
12 _dictionary.Add(i, createdResult);
13 return createdResult;
14 }
註:我使用了一個<int,int>類型的字典。
在上面的簡單的結構中,當在並行條件下創建並添加數據項時,Dictionary+Locks的表現幾乎和ConcurrentDictionary一樣好,但也有同樣的問題,就是某些值可能被生成來,但從沒被使用過。
九、結論
那麼,有結論嗎?此時此刻,還是有一些的:
1、所有的字典速度都非常快,即使我已經創建了上百萬的數據,速度依然很快,通常情況下,我們只是創建少量的數據項,並且讀取還有一些時間間隔,所以我們一般不會察覺到讀取數據項的時間開銷。
2、如果相同的對象不能被創建兩次,則不要使用ConcurrentDictionary。
3、如果你的確很關註性能問題,可能Dictionary+Locks仍然是一個很好的方案,重要的因素是,添加和刪除數據項的數量,但如果是讀操作過多 ,就建議用ConcurrentDictionary。
4、雖然我沒有介紹,但其實使用Dictionary+Locks方案會有更大的自由性,比如你可以鎖定一次,添加多個數據項,刪除多個數據項,或者查詢多次等等,之後再釋放鎖。
5、一般來說,如果讀操作遠遠多於寫操作,可避免使用ReaderWriterLockSlim,字典類型北河完全鎖已經比獲取一個讀寫鎖中的讀鎖快很多了,當然,也依賴於在一個鎖中創建對象鎖消耗的時間。
所以,我認為儘管舉的例子有些極端,但卻表明瞭使用ConcurrentDictionary並不總是最好的方案。