好久没写blog了,今天还是想写一下关于线程安全的问题。从我以前的blog中可以清楚的知道,我是比较反对使用singleton模式的。这里我只是想举一个非常简单的例子来说明singleton带来的问题很可能比我们想想的要严重的多。
话说我反对使用singleton的主要原因是,singleton的提供者通常无法很好实现线程安全,要么对线程安全的认知,要么干脆认为线程安全什么的无关紧要。
那么一个线程不怎么安全的代码到底会出现写什么问题那?
例子1——Random
先来看看这段代码:
1 using System; 2 using System.Threading; 3 4 namespace NotThreadSafe 5 { 6 class Program 7 { 8 static volatile bool s_running = true; 9 static CountdownEvent s_event = new CountdownEvent(2); 10 static Random s_random = new Random(); 11 12 static void Main(string[] args) 13 { 14 ThreadPool.QueueUserWorkItem(DoRandomHeavily); 15 ThreadPool.QueueUserWorkItem(DoRandomHeavily); 16 Thread.Sleep(1000); 17 s_running = false; 18 s_event.Wait(); 19 Console.WriteLine(s_random.Next()); 20 } 21 22 static void DoRandomHeavily(object _) 23 { 24 while (s_running) 25 s_random.Next(); 26 s_event.Signal(); 27 } 28 } 29 }
那么大家来猜猜看,这段代码运行后的输出是什么。
我估计大部分人会说结果在0~int.MaxValue之间的任何一个数,但是实际上,这段代码如果在多核电脑上跑出来的结果(至少是>99.9%的概率)是0。是不是比较意外?
Why?!
来看看为什么结果是如此的诡异,首先,请查阅msdn,msdn上清楚的说明了Random类型不保证实例成员的线程安全,因此,想上面代码中那样使用random是不正确的,会导致一些问题。
有很多人认为random的作用本来就就是随便给个数,线程不安全也无所谓,不还是随便给个数。
不过.net把random设计成一个可以通过seed重复生成某个序列的类,虽然这个序列看起来很random,实际上还是一个算法的,有算法不是问题,问题是这个算法依赖于一些实例字段。通过反编译,可以发现random有三个重要的实力字段:一个int[]用于存储一堆数字;两个int,用于决定取数组中的那两个数来做一系列操作(核心是一个减法)。
显然读取两个int的字段和写入两个int的字段都不是原子的,因此在多线程环境中,很可能会出现读取第一个int字段的指是来自当前cpu本地的缓存值,而在读取第二个int字段之前,当前cpu的缓存刷新了,读取出来的值变成来自其他cpu写入的值。虽然这看起来确实改变了random应有的序列,但是还不至于影响到我们的目的——随机。但是别忘了,这个改变可以导致两个int字段的数值是相同的情况,而且这个概率已经大到在数学上还不能被称为小概率!
一旦两个int字段变成了相同的值,那么噩梦就开始了,从数组的相同位置取出两次数,那么在>99%的概率下是相同的(<1%的是遇到多线程问题,算你狗屎运。。。),两数相减得0,做一系列运算,还是0,再存入数组,一圈转下来,只要中间不发什么多线程问题之类的,数组就全被刷成了0,之后的两个int在这么随便指都无所谓了,因为0减0一定是0。
还不过瘾?例子2-Dictionary
来看下代码:
1 using System; 2 using System.Collections.Generic; 3 using System.Threading; 4 5 namespace NotThreadSafe 6 { 7 class Program 8 { 9 static volatile bool s_running = true; 10 static CountdownEvent s_event = new CountdownEvent(2); 11 static Dictionary<int, int> s_dict = new Dictionary<int, int>(); 12 13 static void Main(string[] args) 14 { 15 ThreadPool.QueueUserWorkItem(DoItHeavily); 16 ThreadPool.QueueUserWorkItem(DoItHeavily); 17 Thread.Sleep(1000); 18 s_running = false; 19 s_event.Wait(); 20 Console.WriteLine(s_dict.Count); 21 } 22 23 static void DoItHeavily(object _) 24 { 25 while (s_running) 26 { 27 s_dict[1] = 1; 28 s_dict.Remove(1); 29 } 30 s_event.Signal(); 31 } 32 } 33 }
继续问,大家认为会输出什么?
没看懂上面的人估计会说0,看懂了上面的人也许会说程序报错崩溃,不过事实总是让人意外,在多核心机上跑出的结果通常是程序无法退出,并且直接占用两个核的计算资源(即:双核是100%占用,4核是50%占用...)
为什么,简单的说就是:Dictionary的内部数据结构被多线程破坏,导致在Add时直接陷入了死循环。如果想听复杂的解释,不妨自己去抓下dump。
更多的例子我就不举了,回到msdn查一下线程安全,不难发现Framework为我们提供的99%的类型都写着不保证实例成员的线程安全,几乎只有个别类型会写着线程安全,而且这里面的大部分还是那些用于处理线程安全的锁类型,那么singleton的提供者们,请看下代码确实线程安全了么?该加锁的都加了么?如果保证不了线程安全,那么只要是多线程环境,外加使用的足够heavy,1秒内就可以使代码瞬间崩塌。
PS:代码部分以.net为例,但是别认为只有.net有这个问题哦,所有基于线程编程的oop语言都会有这个问题,根据我对部分java的程序员的了解,大部分还是很喜欢拿着spring直接注入一个对象,方式么——singleton,问原因,省内存啊。。。