ConcurrentDictionary线程不安全么,你难道没疑惑,你难道弄懂了么?

C#

浏览数:201

2019-5-12

AD:资源代下载服务

前言

事情不太多时,会时不时去看项目中同事写的代码可以作个参考或者学习,个人觉得只有这样才能走的更远,抱着一副老子天下第一的态度最终只能是井底之蛙。前两篇写到关于断点传续的文章,还有一篇还未写出,后续会补上,这里我们穿插一篇文章,这是我看到同事写的代码中有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;
            }
        }

原封不动的进行方法调用:

        private static int _runCount = 0;
        private static readonly LazyConcurrentDictionary<string, string> _lazyDictionary
              = new LazyConcurrentDictionary<string, string>();

        public static void Main(string[] args)
        {
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)); Console.Read(); } public static void PrintValue(string valueToPrint) { var valueFound = _lazyDictionary.GetOrAdd("key", x => { Interlocked.Increment(ref _runCount); Thread.Sleep(100); return valueToPrint; }); Console.WriteLine(valueFound); }

最终正确打印只运行一次的结果,如下:

总结

本节我们学习了ConcurrentDictionary类里面有两个方法严格来说非线程安全,但是也可以得到相同的结果,若我们仅仅只是得到相同的结果且操作不是太耗时其实完全可以忽略这一点,若当利用ConcurrentDictionary类中的此二者方法来做比较耗时的操作,此时就要注意让其线程安全利用Lazy来保证其只能执行一次,所以对ConcurrentDictionary来说并非所有情况都要实现严格意义上的线程安全,根据实际场景而定才是最佳解决方案。时不时多看看别人写的代码,涨涨见识,每天积累一点,日子长了就牛逼了!

作者:Jeffcky