问题请参考:您能看出这个Double Check里的问题吗?
已经很有很多朋友得到了结果,是由于m_categories过早初始化,而导致double check的验证条件被破坏(或者说,满足)。
1 private object m_mutex = new object();
2 private Dictionary<int, Category> m_categories;
3 public Category GetCategory(int id){
4 if (this.m_categories == null) {
5 lock (this.m_mutex) {
6 if (this.m_categories == null) {
7 LoadCategories();
8 }
9 }
10 }
11 return this.m_categories[id];
12 }
13 private void LoadCategories(){
14 this.m_categories = new Dictionary<int,Category>();
15 this.Fill(GetCategoryRoots());
16 }
17 private void Fill(IEnumerable<Category> categories){
18 foreach (var cat in categories) {
19 this.m_categories.Add(cat.CategoryID, cat);
20 Fill(cat.Children);
21 }
22 }
23
假设第一个线程进入了GetCategory方法,它自然可以畅通无阻地执行LoadCategories。只可惜,在LoadCategories方法的第一行就为m_categories设置了一个空字典。如果现在立即有另一个线程访问了GetCategory方法,就会发现m_categories字段不是null,并直接执行this.m_categories[id]这行代码——但此时,第一个线程还没有将这个字典填充完毕!
因此,这段代码其实是一个有问题的Double Check实现。那么我们该怎么改呢?
一位匿名朋友提出,可以增加一个标记,用来表示有没有初始化完毕。如下:
private bool m_initialized = false;public Category GetCategory(int id){ if (!this.m_initialized) { lock (this.m_mutex) { if (!this.m_initialized) { LoadCategories(); this.m_initialized = true; } } } return this.m_categories[id];}
这是个非常漂亮的做法,完全没有问题。不过我并没有使用这种修改方式。
private void LoadCategories(){ var categories = new Dictionary<int,Category>(); Fill(categories, GetCategoryRoots()); this.m_categories = categories;}private static void Fill(Dictionary<int, Category> container, IEnumerable<Category> categories){ foreach (var cat in categories) { container.Add(cat.CategoryID, cat); Fill(container, cat.Children); }}
我稍稍改变了一下Fill方法,它不再直接访问m_categories字段,而是把内容填充至container参数中。而在LoadCategories方法中,我们创建一个字典,但是直到填充完毕后才将其赋给m_categories字段。这样就保证了在m_categories字段不为null的时候,一定已经初始化完毕了。这也是一种可行的办法。我没有使用第一种做法的原因,并不是因为所谓的“节省空间”,而是……一下子就想到了第二种做法。:)
这里反映了Double Check在使用时的一个准则:在满足if条件的时候,一定要确保所有的初始化已经完成了。或者说,一定要将“满足if条件”的操作放在初始化完毕之后进行。至于是否使用某个标记,倒不是什么大问题。
如果您使用.NET编写代码,目前已经没有问题了,但是在某些情况下这样的代码还是会出现问题。我认为这也是多线程编程时最麻烦的地方——就是所谓的“Memory Consistency Model”。
为了性能考虑,编译器在将文本代码转化为机器码,以及CPU在执行机器码时都会对执行进行“重新排序(reorder)”,reorder的作用是为了提升性能。虽然从单线程的角度来看,reorder不会形成问题,但是在多线程的环境中,reorder就会破坏代码的逻辑了。如果没有一个“标准”在进行统一的话,不同的编译器,虚拟机,CPU架构都会有不同的reorder策略。例如微软并行库之父Joe Duffy在这篇文章中简单地提到了不同平台(JVM / CLR 2.0)或不同CPU架构(x86 / IA64)下reorder规则的区分。
而臭名昭著的Double Check的bug便是由于store reordering造成的。在JVM或普通的C、C++中并不保证store reordering不会发生。也就是说,您在代码中看到的两个变量的“设置”顺序,并不代表CPU在执行的时候,也是同样的效果。因此,如果你观察下面的代码:
class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) synchronized(this) { if (helper == null) helper = new Helper(); } return helper; }}
看上去这是一段再正常不过的实现Double Check的Java代码,但是由于发生了store reordering,可能在Helper构造函数中的操作还没有全部执行完成之前,就设置了helper字段。因此另一个线程就可能会访问到一个没有初始化完整的Helper对象。如果您对这个话题感兴趣,可以参考《The "Double-Checked Locking is Broken" Declaration》。
而在CLR 2.0中,只会发生load reordering,而不会出现store reordering。于是.NET中编写的Double Check代码不会出现任何问题。那么CLR是如何保证在不同的CPU平台上出现相同的行为呢?那是因为CLR会根据不同的平台,在合适的情况下插入一些辅助代码(如Memory Barrier),可见CLR为我们的并行编程环境已经形成了一个相对比较方便的平台了——虽然,并行编程还是很困难。