可能是双重双锁检查的错误实现

And*_*rov 1 c# concurrency locking

我在项目代码中找到了以下双锁检查实现:

  public class SomeComponent
  {
    private readonly object mutex = new object();

    public SomeComponent()
    {
    }

    public bool IsInitialized { get; private set; }

    public void Initialize()
    {
        this.InitializeIfRequired();
    }

    protected virtual void InitializeIfRequired()
    {
        if (!this.OnRequiresInitialization())
        {
            return;
        }

        lock (this.mutex)
        {
            if (!this.OnRequiresInitialization())
            {
                return;
            }

            try
            {
                this.OnInitialize();
            }
            catch (Exception)
            {
                throw;
            }

            this.IsInitialized = true;
        }
    }

    protected virtual void OnInitialize()
    {
        //some code here
    }

    protected virtual bool OnRequiresInitialization()
    {
        return !this.IsInitialized;
    }
}
Run Code Online (Sandbox Code Playgroud)

从我的角度来看,这是错误的实现,因为没有保证不同的线程将看到IsInitialized属性的最新值.

问题是"我是对的吗?".

更新:我害怕发生的情况如下:

步骤1. 在Processor1上执行Thread1并将true写入锁定部分内的IsInitialized.这次旧的IsInitialized值(它是假的)在Processor1的缓存中我们知道,处理器有存储缓冲区,因此Processor1可以将新值(true)放入其存储缓冲区,而不是缓存区.

步骤2. Thread2InitializeIfRequired中,在Processor2上执行并读取IsInitialized.在Processor2的缓存中没有IsInitialized的值,因此Processor2从其他处理器的缓存或内存中询问IsInitialized的值.Processor1在其缓存中具有IsInitialized的值(但记住它的旧值,更新的值仍在Processor1的存储缓冲区中),因此它将旧值发送到Processor2.因此,Thread2可以读取false而不是true.

更新2:
如果锁(this.mutex)刷新处理器的存储缓冲区,那么一切正常,但是有保证吗?

Eri*_*ert 8

这是错误的实现,因为缺少保证不同的线程将看到IsInitialized属性的最新值.问题是"我是对的吗?".

你是对的,这是双重检查锁定的破坏实现.你有多种微妙的方式错误地解释它为什么是错误的.

首先,让我们消除你的错误.

由于两个原因,认为多线程程序中任何变量存在"最新"值的信念是不好的信念.第一个原因是,是的,C#保证了对如何重新排序读写的某些限制.但是,这些保证不包括存在全局一致排序的承诺,并且可以由所有线程推断.在C#内存模型中,对变量进行读取和写入是合法的,并且对那些读取和写入存在排序约束.但是,如果这些约束不足以强制执行一次读写顺序,则允许所有线程都没有观察到"规范"顺序.允许两个线程同意所有约束都得到满足,但仍然不同意所选择的顺序.这在逻辑上暗示了每个变量都有一个单一的,规范的"最新"值的概念是完全错误的.不同的线程可能不同意哪些写入比其他写入"更新鲜".

第二个原因是,即使没有这种奇怪的属性,模型允许两个线程在读写序列上存在分歧,但在任何低锁程序中你都有办法读取"最新鲜"的说法仍然是错误的.值.您所保证的所有原始操作都是某些写入和读取不会在代码中的某些点之后向前或向后移动.那里没有任何关于"最新鲜"的东西,无论那是什么意思.你能说的最好的是,一些读取将读取一个新鲜值."最新鲜"的概念不是由记忆模型定义的.

你错的另一种方式确实非常微妙.您正在很好地推理基于处理器刷新缓存可能发生的情况.但是在C#文档中没有任何地方说过关于处理器刷新缓存的一个词!这是一个芯片实现细节,只要您的C#程序在不同的架构上运行,它就会发生变化.除非您知道您的程序将在一个架构上运行,并且您完全理解该架构,否则不要推理处理器刷新缓存.相反,推理存储器模型所施加的约束.我知道模型上的文档非常缺乏,但这是你应该推理的东西,因为那是你真正可以依赖的东西.

你错了的另一种方式是,虽然是,但是实现被破坏了,它没有被破坏,因为你没有读取初始化标志的最新值.问题是初始化状态控制由标志并不受正在阵子移动限制!

让我们让你的例子更具体:

private C c = null;
protected virtual void OnInitialize()
{
     c = new C();
}
Run Code Online (Sandbox Code Playgroud)

和使用网站:

this.InitializeIfRequired();
this.c.Frob();
Run Code Online (Sandbox Code Playgroud)

现在我们来到真正的问题.没有什么能阻止阅读IsInitializedc被及时移动.

假设线程Alpha和Bravo都运行此代码.螺纹布拉沃赢得比赛,它的第一件事是读cnull.请记住,允许这样做是因为读取和写入没有排序约束,因为Bravo永远不会进入锁定状态.

实际上,这会怎么样?允许 C#编译器或抖动更早地移动读取指令,但它们不会.简单地回到缓存体系结构的真实世界,读取c可能会在读取标志之前逻辑上移动,因为c它已经在缓存中.也许它接近最近读过的另一个变量.或者,分支预测可能会预测该标志将导致您跳过锁定,并且处理器预取该值.但同样,现实场景是什么并不重要; 这是所有芯片实现细节.C#规范允许这种读取提前完成,所以假设在某些时候它会提前完成!

回到我们的场景.我们立即切换到线程Alpha.

线程Alpha按预期运行.它看到该标志表示需要初始化,接受锁定,初始化c,设置标志和离开.

现在线程Bravo再次运行,标志现在说不需要初始化,所以我们使用c我们之前读过的版本,并取消引用null.

只要您严格遵循精确的双重检查锁定模式,双重检查锁定在C#中是正确的.在你偏离它的那一刻,即使你稍微偏离了一些可怕的,不可再现的,种族状况的杂草,就像我刚刚描述的那样.只是不要去那里:

  • 不要跨线程共享内存.我从了解我刚才告诉你的一切中得到的结论是,我不够聪明地编写共享内存并按设计工作的多线程代码.我只是聪明地编写了多线程代码,这些代码偶然起作用,这对我来说是不可接受的.
  • 如果必须跨线程共享内存,请锁定每次访问,无一例外.它并不贵!你知道什么更贵吗?处理一系列不可再生的致命崩溃,这些都会丢失用户数据.
  • 如果你必须跨线程共享内存,你必须有低锁延迟初始化好天堂不自己写.使用Lazy<T>; 它包含低锁定延迟初始化的正确实现,您可以依赖所有处理器体系结构上的正确实现.

后续问题:

如果锁(this.mutex)刷新处理器的存储缓冲区,那么一切正常,但是有保证吗?

为了澄清,这个问题是关于在双重检查的锁定场景中是否正确读取了初始化标志.让我们再次在这里解决你的误解.

保证初始化标志在锁内正确读取,因为它在锁内.

但是,正如我之前提到的,正确思考这个问题的方法不是推理缓存缓存.理由这一点的正确方法是C#规范限制了读取和写入如何在锁定方面及时移动.

特别是,读取里面的锁可能不会被移动到锁定,然后写里面的锁可能不会被移动到锁.这些事实与锁提供互斥的事实相结合,足以得出结论,在锁内部读取初始化标志是正确的.

再说一次,如果你不习惯做这些扣除 - 而我不是! - 然后不要写低锁代码.

  • @AndreyZakharov:这方面没有很多好的资源.Vance Morrison,Joe Duffy或已故伟大的Chris Brumme所写的任何东西都是一个好的开始.我认为可以说,负责编写规范的委员会希望有更好的文档,但有很多其他问题对业务线程序员来说很重要,我怀疑它一直被推迟. (2认同)
  • @mjwills:当乔第一次写"懒惰<T>"时,它有一些关于它可能重新运行的侵略性的变化,但我不记得那是否是其中之一. (2认同)