根據 .NET 官方文檔的定義:ConcurrentDictionary<TKey,TValue>
Class 表示可由多個線程同時訪問的線程安全的鍵/值對集合。這也是我們在並發任務中比較常用的一個類型,但它真的是絕對線程安全的嗎?
仔細閱讀官方文檔,我們會發現在文檔的底部線程安全性小節里這樣描述:
ConcurrentDictionary<TKey,TValue>
的所有公共和受保護的成員都是線程安全的,可從多個線程並發使用。但是,通過一個由ConcurrentDictionary<TKey,TValue>
實現的接口的成員(包括擴展方法)訪問時,不保證其線程安全性,並且可能需要由調用方進行同步。
也就是說,調用 ConcurrentDictionary 本身的方法和屬性可以保證都是線程安全的。但是由於 ConcurrentDictionary 實現了一些接口(例如 ICollection、IEnumerable 和 IDictionary 等),使用這些接口的成員(或者這些接口的擴展方法)不能保證其線程安全性。System.Linq.Enumerable.ToList
方法就是其中的一個例子,該方法是 IEnumerable
的一個擴展方法,在 ConcurrentDictionary 實例上使用該方法,當它被其它線程改變時可能拋出 System.ArgumentException
異常。下面是一個簡單的示例:
static void Main(string[] args)
{
var cd = new ConcurrentDictionary<int, int>();
Task.Run(() =>
{
var random = new Random();
while (true)
{
var value = random.Next(10000);
cd.AddOrUpdate(value, value, (key, oldValue) => value);
}
});
while (true)
{
cd.ToList(); //調用 System.Linq.Enumerable.ToList,拋出 System.ArgumentException 異常
}
}
System.Linq.Enumerable.ToList
擴展方法:
發生異常是因為擴展方法 ToList
中調用了 List
的構造函數,該構造函數接收一個 IEnumerable<T>
類型的參數,且該構造函數中有一個對 ICollection<T>
的優化(由 ConcurrentDictionary 實現的)。
System.Collections.Generic.List<T>
構造函數:
在 List
的構造函數中,首先通過調用 Count
獲取字典的大小,然后以該大小初始化數組,最后調用 CopyTo
將所有 KeyValuePair
項從字典復制到該數組。因為字典是可以由多個線程改變的,在調用 Count
后且調用 CopyTo
前,字典的大小可以增加或者減少。當 ConcurrentDictionary
試圖訪問數組超出其邊界時,將引發 ArgumentException
異常。
ConcurrentDictionary<TKey,TValue> 中實現的 ICollection.CopyTo 方法:
如果您只需要一個包含字典所有項的單獨集合,可以通過調用 ConcurrentDictionary.ToArray
方法來避免此異常。它完成類似的操作,但是操作之前先獲取了字典的所有內部鎖,保證了線程安全性。
注意,不要將此方法與 System.Linq.Enumerable.ToArray
擴展方法混淆,調用 Enumerable.ToArray
像 Enumerable.ToList
一樣,可能引發 System.ArgumentException
異常。
看下面的代碼中:
static void Main(string[] args)
{
var cd = new ConcurrentDictionary<int, int>();
Task.Run(() =>
{
var random = new Random();
while (true)
{
var value = random.Next(10000);
cd.AddOrUpdate(value, value, (key, oldValue) => value);
}
});
while (true)
{
cd.ToArray(); //ConcurrentDictionary.ToArray, OK.
}
}
此時調用 ConcurrentDictionary.ToArray
,而不是調用 Enumerable.ToArray
,因為后者是一個擴展方法,前者重載解析的優先級高於后者。所以這段代碼不會拋出異常。
但是,如果通過字典實現的接口(繼承自 IEnumerable)使用字典,將會調用 Enumerable.ToArray
方法並拋出異常。例如,下面的代碼顯式地將 ConcurrentDictionary
實例分配給一個 IDictionary
變量:
static void Main(string[] args)
{
System.Collections.Generic.IDictionary<int, int> cd = new ConcurrentDictionary<int, int>();
Task.Run(() =>
{
var random = new Random();
while (true)
{
var value = random.Next(10000);
cd[value] = value;
}
});
while (true)
{
cd.ToArray(); //調用 System.Linq.Enumerable.ToArray,拋出 System.ArgumentException 異常
}
}
此時調用 Enumerable.ToArray
,就像調用 Enumerable.ToList
時一樣,引發了 System.ArgumentException
異常。
總結
正如官方文檔上所說的那樣,ConcurrentDictionary 的所有公共和受保護的成員都是線程安全的,可從多個線程並發調用。但是,通過一個由 ConcurrentDictionary 實現的接口的成員(包括擴展方法)訪問時,並不是線程安全的,此時要特別注意。
如果需要一個包含字典所有項的單獨集合,可以通過調用 ConcurrentDictionary.ToArray
方法得到,千萬不能使用擴展方法 ToList
,因為它不是線程安全的。
參考:
- http://blog.i3arnon.com/2018/01/16/concurrent-dictionary-tolist/ ConcurrentDictionary Is Not Always Thread-Safe
- https://docs.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2 ConcurrentDictionary<TKey,TValue> Class
作者 : 技術譯民
出品 : 技術譯站