昨天在做code review时看到一位同事写了这样的代码。这段代码的目的使用Double Check的做法来保证线程安全的延迟加载。但是我看到这代码之后发现了一个问题,这个问题不是第一次出现。因此,我打算在博客上记录一笔,希望可以给更多人提个醒吧。
假设,我们有这样一个Category类型,记录的是一个树型的分类结构:
public class Category
{
public int CategoryID { get; set; }
public List Children { get; set; }
}
然后,我们需要一个CategoryLoader,提供一个Get方法从ID获得指定的Category对象:
public class CategoryLoader
{
private object m_mutex = new object();
private Dictionary m_categories;
public Category GetCategory(int id)
{
if (this.m_categories == null)
{
lock (this.m_mutex)
{
if (this.m_categories == null)
{
LoadCategories();
}
}
}
return this.m_categories[id];
}
private void LoadCategories()
{
this.m_categories = new Dictionary();
this.Fill(GetCategoryRoots());
}
private void Fill(IEnumerable categories)
{
foreach (var cat in categories)
{
this.m_categories.Add(cat.CategoryID, cat);
Fill(cat.Children);
}
}
private IEnumerable GetCategoryRoots() { ... }
}
代码的逻辑非常简单:使用一个字典作为容器,在GetCategory方法内部使用Double Check的方式来保证线程安全(即多个线程同时访问同一个对象的GetCategory方法不会出现问题)。如果没有加载,在使用LoadCategories方法构造并填充字典。在LoadCategories方法中会获取所有的“根分类”,并调用Fill方法填充字典。Fill方法会将传入的categories集合添加到字典中,并且递归地将它们的子分类也填充至字典中。
只可惜,上面的代码有一些问题,导致Double Check没有能够实现我们预期的效果。您能看出这个问题来吗?
当然,为了演示代码的简单,我省略了很多细节。例如Category的ID缺失或有重复,Category对象不是immutable,Children属性可能会包含null,这可能都会形成问题。不过,我们就暂时不在这方面考究了吧。
=================================
已经很有很多朋友得到了结果,是由于m_categories过早初始化,而导致double check的验证条件被破坏(或者说,满足)。
private object m_mutex = new object();
private Dictionary m_categories;
public Category GetCategory(int id)
{
if (this.m_categories == null)
{
lock (this.m_mutex)
{
if (this.m_categories == null)
{
LoadCategories();
}
}
}
return this.m_categories[id];
}
private void LoadCategories()
{
this.m_categories = new Dictionary();
this.Fill(GetCategoryRoots());
}
private void Fill(IEnumerable categories)
{
foreach (var cat in categories)
{
this.m_categories.Add(cat.CategoryID, cat);
Fill(cat.Children);
}
}
假设第一个线程进入了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();
Fill(categories, GetCategoryRoots());
this.m_categories = categories;
}
private static void Fill(Dictionary container, IEnumerable 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为我们的并行编程环境已经形成了一个相对比较方便的平台了——虽然,并行编程还是很困难。
(似乎关于Memory Model的有些说法不太确切,随时更新,希望了解这些的朋友们也可以提点意见,我晚上回家后再查些资料)