1. 程式人生 > >ConcurrentDictionary線程不安全麽

ConcurrentDictionary線程不安全麽

數據庫 文章 知識點 項目 讀者

話題

本節的內容算是非常老的一個知識點,在.NET4.0中就已經出現,並且在園中已有園友作出了一定分析,為何我又拿出來講呢?理由如下:

(1)沒用到過,算是自己的一次切身學習。

(2)對比一下園友所述,我想我是否能講的更加詳盡呢?挑戰一下。

(3)是否能夠讓讀者理解的更加透徹呢?打不打臉不要緊,重要的是學習的過程和心得。

在.NET1.0中出現了HashTable這個類,此類不是線程安全的,後來為了線程安全又有了Hashtable.Synchronized,之前看到同事用Hashtable.Synchronized來進行實體類與數據庫中的表進行映射,緊接著又看到別的項目中有同事用ConcurrentDictionary類來進行映射,一查資料又發現Hashtable.Synchronized並不是真正的線程安全,至此才引起我的疑惑,於是決定一探究竟, 園中已有大篇文章說ConcurrentDictionary類不是線程安全的。為什麽說是線程不安全的呢?至少我們首先得知道什麽是線程安全,看看其定義是怎樣的。定義如下:

線程安全:如果你的代碼所在的進程中有多個線程在同時運行,而這些線程可能會同時運行這段代碼。如果每次運行結果和單線程運行的結果是一樣的,而且其他的變量的值也和預期的是一樣的,就是線程安全的。

一搜索線程安全比較統一的定義就是上述所給出的,園中大部分對於此類中的GetOrAdd或者AddOrUpdate參數含有委托的方法覺得是線程不安全的,我們上述也給出線程安全的定義,現在我們來看看其中之一。

技術分享

        private static readonly ConcurrentDictionary<string, string> _dictionary            = new ConcurrentDictionary<string, string>();        public static void Main(string[] args)
        {            var task1 = Task.Run(() => PrintValue("JeffckWang"));            var task2 = Task.Run(() => PrintValue("cnblogs"));
            Task.WaitAll(task1, task2);

            PrintValue("JeffckyWang from cnblogs");
            Console.ReadKey();
        }        public static void PrintValue(string valueToPrint)
        {            var valueFound = _dictionary.GetOrAdd("key",
                        x =>
                        {                            return valueToPrint;
                        });
            Console.WriteLine(valueFound);
        }

技術分享

對於GetOrAdd方法它是怎樣知道數據應該是添加還是獲取呢?該方法描述如下:

TValue GetOrAdd(TKey key,Func<TKey,TValue> valueFactory);

當給出指定鍵時,會去進行遍歷若存在直接返回其值,若不存在此時會調用第二個參數也就是委托將運行,並將其添加到字典中,最終返回給調用者此鍵對應的值。

此時運行上述程序我們會得到如下二者之一的結果:

技術分享

技術分享

我們開啟兩個線程,上述運行結果不都是一樣的麽, 按照上述定義應該是線程安全才對啊,好了到了這裏關於線程安全的定義我們應該消除以下兩點才算是真正的線程安全。

(1)競爭條件

(2)死鎖

那麽問題來了,什麽又是競爭條件呢?好吧,我是傳說中的十萬個什麽。

就像女朋友說的哪有這麽多為什麽,我說的都是對的,不要問為什麽,但對於這麽嚴謹的事情,我們得實事求是,是不。競爭條件是軟件或者系統中的一種行為,它的輸出不會受到其他事件的影響而影響,若因事件受到影響,如果事件未發生則後果很嚴重,繼而產生bug諾。 最常見的場景發生在當有兩個線程同時共享一個變量時,一個線程在讀這個變量,而另外一個變量同時在寫這個變量。比如定義一個變量初始化為0,現在有兩個線程共享此變量,此時有一個線程操作將其增加1,同時另外一個線程操作也將其增加1此時此時得到的結果將是1,而實際上我們期待的結果應該是2,所以為了解決競爭我們通過用鎖機制來實現在多線程環境下的線程安全。

那麽問題來了,什麽是死鎖呢?

至於死鎖則不用多講,死鎖發生在多線程或者並發環境下,為了等待其他操作完成,但是其他操作一直遲遲未完成從而造成死鎖情況。滿足什麽條件才會引起死鎖呢?如下:

(1)互斥:只有進程在給定的時間內使用資源。

(2)占用並等待。

(3)不可搶先。

(4)循環等待。

到了這裏我們通過對線程安全的理解明白一般為了線程安全都會加鎖來進行處理,而在ConcurrentDictionary中參數含有委托的方法並未加鎖,但是結果依然是一樣的,至於未加鎖說是為了出現其他不可預料的情況,依據我個人理解並非完全線程不安全,只是對於多線程環境下有可能出現數據不一致的情況,為什麽說數據不一致呢?我們繼續向下探討。我們將上述方法進行修改如下:

技術分享

        public static void PrintValue(string valueToPrint)
        {            var valueFound = _dictionary.GetOrAdd("key",
                   x =>
                   {
                       Interlocked.Increment(ref _runCount);
                       Thread.Sleep(100);                       return valueToPrint;
                   });
            Console.WriteLine(valueFound);
        }

技術分享

主程序輸出運行次數:

技術分享

            var task1 = Task.Run(() => PrintValue("JeffckyWang"));            var task2 = Task.Run(() => PrintValue("cnblogs"));
            Task.WaitAll(task1, task2);

            PrintValue("JeffckyWang from cnblogs");

            Console.WriteLine(string.Format("運行次數為:{0}", _runCount));

技術分享

技術分享

此時我們看到確確實實獲得了相同的值,但是卻運行了兩次,為什麽會運行兩次,此時第二個線程在運行調用之前,而第一個線程的值還未進行保存而導致。整個情況大致可以進行如下描述:

(1)線程1調用GetOrAdd方法時,此鍵不存在,此時會調用valueFactory這個委托。

(2)線程2也調用GetOrAdd方法,此時線程1還未完成,此時也會調用valueFactory這個委托。

(3)線程1完成調用,並返回JeffckyWang值到字典中,此時檢查鍵還並未有值,然後將其添加到新的KeyValuePair中,並將JeffckyWang返回給調用者。

(4)線程2完成調用,並返回cnblogs值到字典中,此時檢查此鍵的值已經被保存在線程1中,於是中斷添加其值用線程1中的值進行代替,最終返回給調用者。

(5)線程3調用GetOrAdd方法找到鍵key其值已經存在,並返回其值給調用者,不再調用valueFactory這個委托。

從這裏我們知道了結果是一致的,但是運行了兩次,其上是三個線程,若是更多線程,則會重復運行多次,如此或造成數據不一致,所以我的理解是並非完全線程不安全。難道此類中的兩個方法是線程不安全,.NET團隊沒意識到麽,其實早就意識到了,上述也說明了如果為了防止出現意想不到的情況才這樣設計,說到這裏就需要多說兩句,開源最大的好處就是能集思廣益,目前已開源的 Microsoft.AspNetCore.Mvc.Core ,我們可以查看中間件管道源代碼如下:

技術分享

    /// <summary>
    /// Builds a middleware pipeline after receiving the pipeline from a pipeline provider    /// </summary>
    public class MiddlewareFilterBuilder
    {        // ‘GetOrAdd‘ call on the dictionary is not thread safe and we might end up creating the pipeline more        // once. To prevent this Lazy<> is used. In the worst case multiple Lazy<> objects are created for multiple        // threads but only one of the objects succeeds in creating a pipeline.
        private readonly ConcurrentDictionary<Type, Lazy<RequestDelegate>> _pipelinesCache            = new ConcurrentDictionary<Type, Lazy<RequestDelegate>>();        private readonly MiddlewareFilterConfigurationProvider _configurationProvider;        public IApplicationBuilder ApplicationBuilder { get; set; }
   }

技術分享

通過ConcurrentDictionary類調用上述方法無法保證委托調用的次數,在對於mvc中間管道只能初始化一次所以ASP.NET Core團隊使用Lazy<>來初始化,此時我們將上述也進行上述對應的修改,如下:

技術分享

               private static readonly ConcurrentDictionary<string, Lazy<string>> _lazyDictionary            = new ConcurrentDictionary<string, Lazy<string>>();                var valueFound = _lazyDictionary.GetOrAdd("key",
                x => new Lazy<string>(
                    () =>
                    {
                        Interlocked.Increment(ref _runCount);
                        Thread.Sleep(100);                        return valueToPrint;
                    }));
                Console.WriteLine(valueFound.Value);

技術分享

此時將得到如下:

技術分享

我們將第二個參數修改為Lazy<string>,最終調用valueFound.value將調用次數輸出到控制臺上。此時我們再來解釋上述整個過程發生了什麽。

(1)線程1調用GetOrAdd方法時,此鍵不存在,此時會調用valueFactory這個委托。

(2)線程2也調用GetOrAdd方法,此時線程1還未完成,此時也會調用valueFactory這個委托。

(3)線程1完成調用,返回一個未初始化的Lazy<string>對象,此時在Lazy<string>對象上的委托還未進行調用,此時檢查未存在鍵key的值,於是將Lazy<striing>插入到字典中,並返回給調用者。

(4)線程2也完成調用,此時返回一個未初始化的Lazy<string>對象,在此之前檢查到已存在鍵key的值通過線程1被保存到了字典中,所以會中斷創建,於是其值會被線程1中的值所代替並返回給調用者。

(5)線程1調用Lazy<string>.Value,委托的調用以線程安全的方式運行,所以如果被兩個線程同時調用則只運行一次。

(6)線程2調用Lazy<string>.Value,此時相同的Lazy<string>剛被線程1初始化過,此時則不會再進行第二次委托調用,如果線程1的委托初始化還未完成,此時線程2將被阻塞,直到完成為止,線程2才進行調用。

(7)線程3調用GetOrAdd方法,此時已存在鍵key則不再調用委托,直接返回鍵key保存的結果給調用者。

上述使用Lazy來強迫我們運行委托只運行一次,如果調用委托比較耗時此時不利用Lazy來實現那麽將調用多次,結果可想而知,現在我們只需要運行一次,雖然二者結果是一樣的。我們通過調用Lazy<string>.Value來促使委托以線程安全的方式運行,從而保證在某一個時刻只有一個線程在運行,其他調用Lazy<string>.Value將會被阻塞直到第一個調用執行完,其余的線程將使用相同的結果。

那麽問題來了調用Lazy<>.Value為何是線程安全的呢?

我們接下來看看Lazy對象。方便演示我們定義一個博客類

技術分享

    public class Blog
    {        public string BlogName { get; set; }        public Blog()
        {
            Console.WriteLine("博客構造函數被調用");
            BlogName = "JeffckyWang";
        }
    }

技術分享

接下來在控制臺進行調用:

技術分享

            var blog = new Lazy<Blog>();
            Console.WriteLine("博客對象被定義");            if (!blog.IsValueCreated) Console.WriteLine("博客對象還未被初始化");
            Console.WriteLine("博客名稱為:" + (blog.Value as Blog).BlogName);            if (blog.IsValueCreated) 
                Console.WriteLine("博客對象現在已經被初始化完畢");

技術分享

打印如下:

技術分享

通過上述打印我們知道當調用blog.Value時,此時博客對象才被創建並返回對象中的屬性字段的值,上述布爾屬性即IsValueCreated顯示表明Lazy對象是否已經被初始化,上述初始化對象過程可以簡述如下:

技術分享

            var lazyBlog = new Lazy<Blog>
            (
                () =>
                {                    var blogObj = new Blog() { BlogName = "JeffckyWang" };                    return blogObj;
                }
            );

技術分享

打印結果和上述一致。上述運行都是在非線程安全的模式下進行,要是在多線程環境下對象只被創建一次我們需要用到如下構造函數:

 public Lazy(LazyThreadSafetyMode mode); public Lazy(Func<T> valueFactory, LazyThreadSafetyMode mode);

通過指定LazyThreadSafetyMode的枚舉值來進行。

(1)None = 0【線程不安全】

(2)PublicationOnly = 1【針對於多線程,有多個線程運行初始化方法時,當第一個線程完成時其值則會設置到其他線程】

(3)ExecutionAndPublication = 2【針對單線程,加鎖機制,每個初始化方法執行完畢,其值則相應的輸出】

我們演示下情況:

技術分享

    public class Blog
    {        public int BlogId { get; set; }        public Blog()
        {
            Console.WriteLine("博客構造函數被調用");
        }
    }

技術分享

技術分享

        static void Run(object obj)
        {            var blogLazy = obj as Lazy<Blog>;            var blog = blogLazy.Value as Blog;
            blog.BlogId++;
            Thread.Sleep(100);
            Console.WriteLine("博客Id為:" + blog.BlogId);

        }

技術分享

技術分享

            var lazyBlog = new Lazy<Blog>
            (
                () =>
                {                    var blogObj = new Blog() { BlogId = 100 };                    return blogObj;
                }, LazyThreadSafetyMode.PublicationOnly
            );
            Console.WriteLine("博客對象被定義");
            ThreadPool.QueueUserWorkItem(new WaitCallback(Run), lazyBlog);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Run), lazyBlog);

技術分享

結果打印如下:

技術分享

奇怪的是當改變線程安全模式為 LazyThreadSafetyMode.ExecutionAndPublication 時結果應該為101和102才是,居然返回的都是102,但是將上述blog.BogId++和暫停時間順序顛倒時如下:

  Thread.Sleep(100);          
  blog.BlogId++;

此時兩個模式返回的都是101和102,不知是何緣故!上述在ConcurrentDictionary類中為了兩個方法能保證線程安全我們利用Lazy來實現,默認的模式為 LazyThreadSafetyMode.ExecutionAndPublication 保證委托只執行一次。為了不破壞原生調用ConcurrentDictionary的GetOrAdd方法,但是又為了保證線程安全,我們封裝一個方法來方便進行調用。

技術分享

        public class LazyConcurrentDictionary<TKey, TValue>
        {            private readonly ConcurrentDictionary<TKey, Lazy<TValue>> concurrentDictionary;            public LazyConcurrentDictionary()
            {                this.concurrentDictionary = new ConcurrentDictionary<TKey, Lazy<TValue>>();
            }            public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
            {                var lazyResult = this.concurrentDictionary.GetOrAdd(key, k => new Lazy<TValue>(() => valueFactory(k), LazyThreadSafetyMode.ExecutionAndPublication));                return lazyResult.Value;
            }
        }

技術分享

原封不動的進行方法調用:

技術分享

           _runCount =    LazyConcurrentDictionary<, >=  LazyConcurrentDictionary<, >   Main( task1 = Task.Run(() => PrintValue( task2 = Task.Run(() => PrintValue(.Format(   PrintValue( valueFound = _lazyDictionary.GetOrAdd(=>

技術分享

最終正確打印只運行一次的結果,如下:

技術分享


ConcurrentDictionary線程不安全麽