• .Net 4.0并行库实用性演练(续)


      接着上一次说,即使用了新的线程安全的集合BlockingCollection,这段代码还是会有问题。

        static void testFillParallel()
        {
            var list = new BlockingCollection<Person>(9999);
            Enumerable.Range(1, 99999).AsParallel().ForAll(n =>
            {
                var name = "Person " + n % 9;
                if (list.Count(p => p.Name == name) < 1) list.Add(new Person { Id = n, Name = name });
            });
            Console.WriteLine("Person's count is {0}", list.Count);
        }
    

      代码逻辑就是根据序号生成一个名字,并将名字不重复的人加入集合中。显然,最后集合中应该有9个人,应该很简单吧。执行一下,请看结果:

      可见,绝大多数结果都是正确,只有两次执行出现了异常,在正式系统运行中,这可是要命的两条!难道是BlockingCollection的问题吗?细想不太可能,微软怎么也不会漏过这么明显的设计问题,实际上是自己对线程安全认识不准确。集合元素之所以偶尔会多一个,是这样的情况:线程A抢先一步,占住集合判断是否存在这个人名,线程B被BlockingCollection拦在外面;A发现集合中查无此人,正想加一个,然而不知怎么回事,它没马上继续,就好像龟兔赛跑,兔子要到终点了,做了个白日梦,梦一醒自己成了老二。B趁机赶上,正好锁也解了,碰巧它查的人也没有,一鼓作气跑完全程。这时A跑了一大半,岂肯甘心,赖皮着到终点,不管三七二十一,有没有和B刚才加的重复,硬把自己塞了进去。

      这样理解,也容易解释,第一次执行出现异常结果概率很高,因为开始时线程间步调几乎完全一致,刚才故事的前半段最有可能上演。另外条件判断时间越久,异常结果出现概率越小,比如把name = "Person " + n % 9改成name = "Person " + n % 50,这时A就是做白日梦,B也全力追赶,无奈前面被落后太多,一千次中只有一两次结果异常。

      其实System.Collections.ConCurrent命名空间底下的类,只对多线程环境下的某次访问保证健壮性,却不能保证多线程下,作为业务对象的业务操作的准确性,实际上也无法做到。然而话说回来,我们使用这些类,是让它们在凶险的多线程战场上,为我们奋勇杀敌,而不是明哲保身的。如果业务出错了,仗都打输了,集合再线程安全亦无济于事。所以,不要让线程安全误导我们,要着眼在业务上,业务安全实现了,自然线程安全自然不在话下。

      现在要解决结果异常,自然又想到了老办法—上锁,先保证万无一失。显然,并行集合也不必上场了,还是用List。为了接近真实场景,将集合最大元素数目提高到999,这样在判断新元素是否重复是要花较多时间,取代Thread.Sleep(1),代码如下:

        static void testFillParallel()
        {
            var list = new List<Person>(999);
            var L = new object();
            Enumerable.Range(1, 9999).AsParallel().ForAll(n =>
            {
                var name = "Person " + n % 999;
                lock (L)
                {
                    if (list.Count(p => p.Name == name) < 1) list.Add(new Person { Id = n, Name = name });
                }
            });
            Console.WriteLine("Person's count is {0}", list.Count);
        }
    

      测试结果如下:

     次数

     1

     2

     3

     4

     Fill 方法

     304

     292

     291

     292

     FillParallel 方法

     340

     298

     296

     297

      可见,虽然运用并行方式,也保证每个迭代有一定的执行时间,虽然加锁可以保证结果正确,但大多数时候,只有一个线程能进行工作,其他线程再多也只能等待,实际上还不如单线程,也失去了并行运算的意义。

      学习.Net4.0 的并行库并非我们的目的,我们目的是解决现实的问题。解决关键,还是在锁上。锁是把双刃剑,我们要让线程占用锁时间尽可能短。在填加集合时,加锁没办法避免,但只是为遍历集合而加锁,好像是种浪费。根据三级锁协议,应该允许多个只读操作同时进行,可是实现IEnumerable的集合,都不允许在遍历访问时修改集合,既然如此,我们自己搞一个集合副本,只作判断用,如果原集合更新,副本也随之更新,不是就能解决同时遍历的问题了吗?

      试验代码:

        static void testFill()
        {
            var list = new List<Person>(999);
            var L = new object();
            Enumerable.Range(1, 9999).ToList().ForEach(n =>
            {
                var name = "Person " + n % 999;
                if (list.Count(p => p.Name == name) < 1) list.Add(new Person { Id = n, Name = name });
            });
            Console.WriteLine("Person's count is {0}", list.Count);
        }
    
        static void testFillParallel()
        {
            var list = new List<Person>(999);
            var L = new object();
            var resultCopy = new Person[999];
            bool hasNewMember = false;
            Enumerable.Range(1, 9999).AsParallel().ForAll(n =>
            {
                var name = "Person " + n % 999;
                if (resultCopy.Count(p => p!=null && p.Name == name) < 1)
                {
                    lock (L)
                    {
                        // 如果有新成员,要再判断一遍
                        if (hasNewMember)
                        {
                            if (resultCopy.Count(p => p != null && p.Name == name) > 0) return;
                            hasNewMember = false;
                        }
                        list.Add(new Person { Id = n, Name = name });
                        list.CopyTo(resultCopy);
                        hasNewMember = true;
                    }
                }
            });
            Console.WriteLine("Person's count is {0}", list.Count);
        }
    

      试验结果(单位ms):

     次数

     1

     2

     3

     4

     Fill 方法(和上次一样)

     309

     291

     292

     292

     FillParallel 方法

     210

     166

     165

     166

      看来,自己总算写了第一段能发挥并行运算的代码。

      当然,最后代码还有很多可改进的地方,比如list.CopyTo方法,还有发现有新成员后第二次判断,向让性能与CPU核数正比的目标努力。

      还有,那个if(...){ lock(...) { if(...) { 的语句,跟单例模式的一种普遍实现很像,单例模式也是多线程环境下一种设计模式,也许其中有些异曲同工之处吧。

        static void testFillParallel ()

        {

            var list = new BlockingCollection<Person>(9999);

            Enumerable.Range(1, 9999).AsParallel().ForAll(n =>

            {

                var name = "Person " + n % 9;

                if (list.Count(p => p.Name == name) < 1) list.Add(new Person { Id = n, Name = name });

            });

            Console.WriteLine("Person's count is {0}", list.Count);

        }

  • 相关阅读:
    学了N年英语,你学会翻译了吗?——最基本的数据库连接
    混编,还是会犯错~
    call dword prt[eax]
    时间复杂度和空间复杂度1 数据结构和算法03
    call dword prt[eax+5]
    地址反向增长(栈底为大地址,栈顶为小地址)
    OD使用教程3(上) 调试篇03|解密系列
    call dword prt[eax+5]
    时间复杂度和空间复杂度1 数据结构和算法03
    call dword prt[eax]
  • 原文地址:https://www.cnblogs.com/XmNotes/p/1822498.html
Copyright © 2020-2023  润新知