在.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并不總是最好的方案,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/67197.html
標籤:C#
