C# 中 ConcurrentDictionary 一定執行緒安全嗎?

技術譯民發表於2020-12-22

根據 .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 擴充套件方法:

System.Linq.Enumerable.ToList

發生異常是因為擴充套件方法 ToList 中呼叫了 List 的建構函式,該建構函式接收一個 IEnumerable<T> 型別的引數,且該建構函式中有一個對 ICollection<T> 的優化(由 ConcurrentDictionary 實現的)。

System.Collections.Generic.List<T> 建構函式:

System.Collections.Generic.List

List 的建構函式中,首先通過呼叫 Count 獲取字典的大小,然後以該大小初始化陣列,最後呼叫 CopyTo 將所有 KeyValuePair 項從字典複製到該陣列。因為字典是可以由多個執行緒改變的,在呼叫 Count 後且呼叫 CopyTo 前,字典的大小可以增加或者減少。當 ConcurrentDictionary 試圖訪問陣列超出其邊界時,將引發 ArgumentException 異常。

ConcurrentDictionary<TKey,TValue> 中實現的 ICollection.CopyTo 方法:
ConcurrentDictionary-CopyTo-ArgumentException


如果您只需要一個包含字典所有項的單獨集合,可以通過呼叫 ConcurrentDictionary.ToArray 方法來避免此異常。它完成類似的操作,但是操作之前先獲取了字典的所有內部鎖,保證了執行緒安全性。

ConcurrentDictionary-ToArray

注意,不要將此方法與 System.Linq.Enumerable.ToArray 擴充套件方法混淆,呼叫 Enumerable.ToArrayEnumerable.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,因為它不是執行緒安全的。


參考:


作者 : 技術譯民
出品 : 技術譯站

相關文章