代码改变世界

您能看出这个Double Check里的问题吗?(解答)

2009-09-02 17:16 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

问题请参考:您能看出这个Double Check里的问题吗?

已经很有很多朋友得到了结果,是由于m_categories过早初始化,而导致double check的验证条件被破坏(或者说,满足)。

private object m_mutex = new object();
private Dictionary<int, Category> 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<int,Category>();
    this.Fill(GetCategoryRoots());
}

private void Fill(IEnumerable<Category> 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<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为我们的并行编程环境已经形成了一个相对比较方便的平台了——虽然,并行编程还是很困难。

(似乎关于Memory Model的有些说法不太确切,随时更新,希望了解这些的朋友们也可以提点意见,我晚上回家后再查些资料)