.net原始碼分析 - ConcurrentDictionary泛型

風靈使發表於2018-11-30

繼上篇Dictionary原始碼分析,上篇講過的在這裡不會再重複

ConcurrentDictionary原始碼地址:

https://github.com/dotnet/corefx/blob/master/src/System.Collections.Concurrent/src/System/Collections/Concurrent/ConcurrentDictionary.cs

前言

ConcurrentDictionary一大特點是執行緒安全,在沒有ConcurrentDictionary之前在多執行緒下用Dictionary,不管讀寫都要加個鎖,不但麻煩,效能上也不是很好,因為在上篇分析中我們知道Dictionary內部是由多個bucket組成,不同bucket的操作即使在多執行緒下也可以不互相影響,如果一個鎖把整個Dictionary都鎖住實在有點浪費。

不過凡事都有兩面性,給每個Bucket都加一個鎖也不可取,Bucket的數量和Dictionary元素數量是一樣的,而Bucket可能會有一部分是空的,而且訪問Dictionary的執行緒如果數量不是太多也根本用上不這麼多鎖,想想即使有10個執行緒在不停的操作這個Dictionary,同時操作的最多也就10個,即使兩兩衝突訪問同一個Bucket,5個鎖就夠了,當然這是最好的情況,最壞情況是這5個bucket用同一個鎖。所以,要得到最好的結果需要嘗試取一個最優解,而影響因素則是bucket數量和執行緒數量。我們想要的結果是鎖夠用但又不浪費。

微軟得出的結果是預設的鎖的數量是CPU核的個數,這個執行緒池預設的執行緒數量一樣。隨著Dictionary的擴容,鎖的個數也可以跟著增加,這個可以在建構函式中自己指定。

下面看看ConcurrentDictionary裡元素是做了怎樣的封裝。

private volatile Tables _tables; // 這不同於Dictionary的bucket 陣列,而是整個封裝起來,而且用volatile來保證讀寫時的原子性
 
private sealed class Tables
{
    internal readonly Node[] _buckets; // bucket成了這樣,也就是ConcurrentDictionary可以認為是一個bucket陣列,每個Bucket裡又由next來形成連結串列
    internal readonly object[] _locks; // 這個就是鎖的陣列了
    internal volatile int[] _countPerLock; // 這個是每個鎖罩的元素個數

    internal Tables(Node[] buckets, object[] locks, int[] countPerLock)
    {
        _buckets = buckets;
        _locks = locks;
        _countPerLock = countPerLock;
    }
}

//由Dictionary裡的Entry改成Node,並且把next放到Node裡
private sealed class Node
{
    internal readonly TKey _key;
    internal TValue _value;
    internal volatile Node _next; //next由volatile修飾,確保不被優化且讀寫原子性
    internal readonly int _hashcode;

    internal Node(TKey key, TValue value, int hashcode, Node next)
    {
        _key = key;
        _value = value;
        _next = next;
        _hashcode = hashcode;
    }
}

裡面的一些變數:

private readonly bool _growLockArray; // 是否在Dictionary擴容時也增加鎖的數量

private int _budget; // 單個鎖罩的元素的最大個數

private const int DefaultCapacity = 31;  //ConcurrentDictionary預設大小,和List,Dictionary不一樣

private const int MaxLockNumber = 1024;  //最大鎖的個數,不過也可以在建構函式中弄個更大的,不般沒必要

看看建構函式初始化做了些啥


internal ConcurrentDictionary(int concurrencyLevel, int capacity, bool growLockArray, IEqualityComparer<TKey> comparer)
{
    if (concurrencyLevel < 1)
    {
        throw new ArgumentOutOfRangeException(nameof(concurrencyLevel), SR.ConcurrentDictionary_ConcurrencyLevelMustBePositive);
    }
    if (capacity < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(capacity), SR.ConcurrentDictionary_CapacityMustNotBeNegative);
    }
    if (comparer == null) throw new ArgumentNullException(nameof(comparer));

    // The capacity should be at least as large as the concurrency level. Otherwise, we would have locks that don't guard
    // any buckets.
    if (capacity < concurrencyLevel) //concurrencyLevel就是鎖的個數,容量小於鎖的個數時,一部分鎖就真是完全沒用了
    {
        capacity = concurrencyLevel; //所以容量至少要和鎖的個數一樣
    }

    object[] locks = new object[concurrencyLevel]; //初始化鎖陣列
    for (int i = 0; i < locks.Length; i++)
    {
        locks[i] = new object();
    }

    int[] countPerLock = new int[locks.Length]; //初始化鎖罩元素個數的陣列
    Node[] buckets = new Node[capacity]; //初始化Node
    _tables = new Tables(buckets, locks, countPerLock); //初始化table

    _comparer = comparer;
    _growLockArray = growLockArray;  //這就是指定鎖是否增長
    _budget = buckets.Length / locks.Length;  //鎖最大罩【容量/鎖個數】這麼多元素(畢竟不是扛把子,罩不了太多),多了怎麼辦,擴地盤。。
}

通過常用的函式看看是怎麼做到多執行緒安全的

TryGet

public bool TryGetValue(TKey key, out TValue value)
{
    if (key == null) ThrowKeyNullException();
    return TryGetValueInternal(key, _comparer.GetHashCode(key), out value);
}

private bool TryGetValueInternal(TKey key, int hashcode, out TValue value)
{
    Debug.Assert(_comparer.GetHashCode(key) == hashcode);
            
    //先用本地變數存一下,免得在另外一個執行緒擴容時變了
    Tables tables = _tables;

    //又是hashcode取餘哈,不多說
    //int bucketNo = (hashcode & 0x7fffffff) % bucketCount;
    int bucketNo = GetBucket(hashcode, tables._buckets.Length);

    //這個用Valatile確保讀了最新的Node
    Node n = Volatile.Read<Node>(ref tables._buckets[bucketNo]);
    //遍歷bucket,真懷疑這些程式碼是幾個人寫的,風格都不一樣
    while (n != null)
    {
        //找到了
        if (hashcode == n._hashcode && _comparer.Equals(n._key, key))
        {
            //返回true和value
            value = n._value;
            return true;
        }
        n = n._next;
    }

    value = default(TValue);
    return false;
}

GetOrAdd

public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
    if (key == null) ThrowKeyNullException();
    if (valueFactory == null) throw new ArgumentNullException(nameof(valueFactory));

    int hashcode = _comparer.GetHashCode(key);

    TValue resultingValue;
    //先TryGet,沒有的再TryAdd
    if (!TryGetValueInternal(key, hashcode, out resultingValue))
    {
        TryAddInternal(key, hashcode, valueFactory(key), false, true, out resultingValue);
    }
    return resultingValue;
}

private bool TryAddInternal(TKey key, int hashcode, TValue value, bool updateIfExists, bool acquireLock, out TValue resultingValue)
{
    Debug.Assert(_comparer.GetHashCode(key) == hashcode);

    while (true)
    {
        int bucketNo, lockNo;

        Tables tables = _tables;
        //GetBucketAndLockNo函式裡面就是下面兩句
        //bucketNo = (hashcode & 0x7fffffff) % bucketCount; 取餘得bucket No.,和Dictionary一樣
        //lockNo = bucketNo % lockCount; 也是取餘得鎖No. 也就是一個鎖也是可能給多個Bucket用的
        GetBucketAndLockNo(hashcode, out bucketNo, out lockNo, tables._buckets.Length, tables._locks.Length);

        bool resizeDesired = false;
        bool lockTaken = false;
        try
        {
            if (acquireLock) //引數指定需要鎖的話就鎖上這個bucket的鎖,也就在建構函式初始化時不需要鎖
                Monitor.Enter(tables._locks[lockNo], ref lockTaken);

            //這裡是做個校驗,判斷tables是否在這邊取完鎖後其他執行緒把元素給擴容了,擴容會生成一個新的tables,tables變了的話上面的鎖就沒意義了,需要重來,所以這整個是在while(true)裡面
            if (tables != _tables)
            {
                continue;
            }

            Node prev = null;
            //這裡就遍歷bucket裡的連結串列了,和Dictionary差不多
            for (Node node = tables._buckets[bucketNo]; node != null; node = node._next)
            {
                Debug.Assert((prev == null && node == tables._buckets[bucketNo]) || prev._next == node);
                if (hashcode == node._hashcode && _comparer.Equals(node._key, key))//看是否找到
                {
                    //看是否需要更新node
                    if (updateIfExists)
                    {
                        if (s_isValueWriteAtomic) //這個是判斷是否是支援原子操作的值型別,比如32位上byte,int,byte,short都是原子的,而long,double就不是了,支援原子操作的直接賦值就可以了,得注意是值型別,引用型別可不能這麼搞
                        {
                            node._value = value;
                        }
                        else //不是原子操作的值型別就new一個node
                        {
                            Node newNode = new Node(node._key, value, hashcode, node._next);
                            if (prev == null)
                            {
                                tables._buckets[bucketNo] = newNode;
                            }
                            else
                            {
                                prev._next = newNode;
                            }
                        }
                        resultingValue = value;
                    }
                    else//不更新就直接取值
                    {
                        resultingValue = node._value;
                    }
                    return false;  //找到了返回false,表示不用Add就Get了
                }
                prev = node;
            }

            // 找了一圈沒找著,就Add吧,new一個node用Volatile的寫操作寫到bucket裡
            Volatile.Write<Node>(ref tables._buckets[bucketNo], new Node(key, value, hashcode, tables._buckets[bucketNo]));
            checked//這裡如果超出int大小,拋overflow exception, 能進這裡表示一個鎖罩int.MaxValue大小的Node,真成扛把子了,極端情況下只有一個鎖而且Node的大小已經是Int.MaxValue才可能會出現(還要看budget同不同意)
            {
                tables._countPerLock[lockNo]++;
            }

            //如果鎖罩的Node個數大於budget就表示差不多需要擴容了,黑社會表示地盤不夠用了
            if (tables._countPerLock[lockNo] > _budget)
            {
                resizeDesired = true;
            }
        }
        finally
        {
            if (lockTaken) //出現異常要把鎖釋放掉
                Monitor.Exit(tables._locks[lockNo]);
        }

        if (resizeDesired)
        {
            GrowTable(tables); //擴容
        }

        resultingValue = value; //result值
        return true;
    }
}

再來看看是怎麼擴容的

private void GrowTable(Tables tables)
{
    const int MaxArrayLength = 0X7FEFFFFF;
    int locksAcquired = 0;
    try
    {
        // 先把第一個鎖鎖住,免得其他執行緒也要擴容走進來
        AcquireLocks(0, 1, ref locksAcquired);

        //如果table已經變了,也就是那些等著上面鎖的執行緒進來發現已經擴容完了直接返回就好了
        if (tables != _tables)
        {
            return;
        }

        // 計算每個鎖罩的元素的個數總和,也就是當前元素的個數
        long approxCount = 0;
        for (int i = 0; i < tables._countPerLock.Length; i++)
        {
            approxCount += tables._countPerLock[i];
        }

        //如果元素總和不到Bucket大小的1/4,說明擴容擴得不是時候,歸根結底是budget小了
        if (approxCount < tables._buckets.Length / 4)
        {
            _budget = 2 * _budget;//2倍增加budget
            if (_budget < 0) //小於0說明overflow了,看看,前面用check,這裡又用小於0。。
            {
                _budget = int.MaxValue; //直接最大值吧
            }
            return;
        }

        int newLength = 0;
        bool maximizeTableSize = false;
        try
        {
            checked
            {
                //2倍+1取得一個奇數作了新的容量
                newLength = tables._buckets.Length * 2 + 1;

                //看是否能整除3/5/7,能就+2,直到不能整除為止,也挺奇怪這演算法,List是2倍,Dictionary是比2倍大的一個質數,這裡又是另外一種,只能說各人有各人的演算法
                while (newLength % 3 == 0 || newLength % 5 == 0 || newLength % 7 == 0)
                {
                    newLength += 2;
                }

                Debug.Assert(newLength % 2 != 0);

                if (newLength > MaxArrayLength)
                {
                    maximizeTableSize = true;
                }
            }
        }
        catch (OverflowException)
        {
            maximizeTableSize = true;
        }

        if (maximizeTableSize)//進這裡表示溢位了
        {
            newLength = MaxArrayLength; //直接給最大值

            _budget = int.MaxValue; //budget也給最大值,因為沒法再擴容了,給小了進來也沒意義
        }

        //擴容之後又是熟悉的重新分配元素,和Dictionary基本一致,這裡要先把所有鎖鎖住,前面已經鎖了第一個,這裡鎖其他的
        AcquireLocks(1, tables._locks.Length, ref locksAcquired);

        object[] newLocks = tables._locks;

        //如果允許增加鎖並則鎖的個數還不到1024,就增加鎖
        if (_growLockArray && tables._locks.Length < MaxLockNumber)
        {
            newLocks = new object[tables._locks.Length * 2]; //也是2倍增加
            Array.Copy(tables._locks, 0, newLocks, 0, tables._locks.Length); //舊鎖複製到新陣列裡
            for (int i = tables._locks.Length; i < newLocks.Length; i++) //再初始化增的鎖
            {
                newLocks[i] = new object();
            }
        }

        //新的Node陣列
        Node[] newBuckets = new Node[newLength];
        int[] newCountPerLock = new int[newLocks.Length];

        //遍歷bucket
        for (int i = 0; i < tables._buckets.Length; i++)
        {
            Node current = tables._buckets[i];//當前node
            while (current != null)
            {
                Node next = current._next;
                int newBucketNo, newLockNo;
                //算新的bucket No.和lock No.
                GetBucketAndLockNo(current._hashcode, out newBucketNo, out newLockNo, newBuckets.Length, newLocks.Length);
                //重建個新的node,注意next指到了上一個node,和Dictionary裡一樣
                newBuckets[newBucketNo] = new Node(current._key, current._value, current._hashcode, newBuckets[newBucketNo]);

                checked
                {
                    newCountPerLock[newLockNo]++; //這個鎖又罩了一個小弟,加一個
                }

                current = next;
            }
        }

        //調整下budget
        _budget = Math.Max(1, newBuckets.Length / newLocks.Length); 

        //得到新的table
        _tables = new Tables(newBuckets, newLocks, newCountPerLock);
    }
    finally
    {
        // 釋放鎖
        ReleaseLocks(0, locksAcquired);
    }
}

通過這幾個函式差不多也就清楚了ConcurrentDictionary整個的原理,其他函式有興趣的可以去看看,都差不多這個意思。
總結

說完了,總結下,ConcurrentDictionary可以說是為了避免一個大鎖鎖住整個Dictionary帶來的效能損失而出來的,當然也是採用空間換時間,不過這空間換得還是很值得的,一些object而已。

原理在於Dictionary本質是是一個連結串列陣列,只有在多執行緒同時操作到陣列裡同一個連結串列時才需要鎖,所以就用到一個鎖陣列,每個鎖罩著幾個小弟(bucketbucket內的連結串列元素),這樣多執行緒讀寫不同鎖罩的區域的時候可以同時進行而不會等待,進而提高多執行緒效能。

不過也凡事無絕對,不同業務場景的需求不一樣,可能Dictionary配合ReaderWriterLockSlim在某些場景(比如讀的機會遠大於寫的)可能會有更好的表現。

相關文章