双重双锁检查的实现可能不正确

时间:2018-10-26 09:58:24

标签: 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;
    }
}

从我的角度来看,这是错误的实现方式,原因是无法保证不同的线程将看到 IsInitialized 属性的最新值。

问题是“我对吗?”。

更新: 我担心会发生以下情况:

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

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

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

1 个答案:

答案 0 :(得分:8)

  

这是错误的实现,因为没有保证不同的线程将看到IsInitialized属性的最新值。问题是“我对吗?”。

您是正确的,这是双重检查锁定的无效实现。您会以多种微妙的方式弄错了为什么错了。

首先,让我们消除您的错误。

在多线程程序中,任何变量的值都“最新颖”的信念是一种错误的信念,这有两个原因。第一个原因是,C#保证了有关如何重新排列读写顺序的某些约束。但是,这些保证不包括存在全局一致的顺序并且可以被所有线程推论的任何保证。在C#内存模型中,对变量进行读写操作以及对这些读写操作进行排序约束是合法的。但是,在那些约束不足以完全执行一个读写顺序的情况下,允许所有线程都没有“规范”顺序。允许两个线程同意所有约束均得到满足,但仍然不同意选择什么顺序。从逻辑上讲,每个变量只有一个规范的“最新鲜”值的说法是完全错误的。不同的线程可能会比其他线程“更新鲜”。

第二个原因是,即使没有这个奇怪的属性,模型也不允许两个线程在读写顺序上达成共识,在任何低锁程序中说 仍然是错误的您可以读取“最新鲜”的值。您所拥有的所有原始操作都保证,某些写入和读取不会在时间上向前或向后移动超过代码中的某些点。那里什么也没有说“最新鲜”,无论什么意思。您能说的最好的是,某些读取将读取 fresher 值。内存模型未定义“最新鲜”的概念。

您错的另一种方法确实非常微妙。 根据处理器刷新缓存,您会做出很好的推理。但是在C#文档中,没有任何地方提到处理器刷新缓存的一句话!这是芯片实现的详细信息,只要您的C#程序在不同的体系结构上运行,该细节就会更改。除非您知道程序将完全在一种体系结构上运行并且您完全了解该体系结构,否则请不要理会处理器刷新缓存。而是关于内存模型施加的约束的原因。我知道模型的文档非常缺乏,但这就是您应该考虑的事情,因为这实际上是您可以依靠的。

您弄错的另一种方法是,虽然可以,但实现已被破坏,但由于您没有读取初始化标志的最新值,因此实现没有被破坏。问题在于,由标志控制的已初始化状态不受时间移动的限制!

让您的示例更加具体:

private C c = null;
protected virtual void OnInitialize()
{
     c = new C();
}

以及一个使用网站:

this.InitializeIfRequired();
this.c.Frob();

现在,我们来讨论真实问题。没有什么可以阻止对IsInitializedc的读取的及时移动。

假设线程Alpha和Bravo都在运行此代码。 Thread Bravo赢得比赛,它所做的第一件事是将c读为null。请记住,之所以可以这样做是因为对读写没有顺序限制,因为Bravo永远不会输入锁

实际上,这怎么可能发生? 允许允许C#编译器或抖动将其移至更早的位置,但不允许这样做。简要地回到缓存体系结构的真实世界,c的读操作可能在逻辑上比标志读取的前移,因为c已经在缓存中。也许它与最近读取的另一个变量接近。或者,分支预测可能正在预测该标志将导致您跳过该锁,而处理器会预取该值。但同样,现实情况是什么也没关系;这就是所有芯片实现的细节。 C#规范允许此读取尽早完成,因此,假设在某个时候它将尽早完成!

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

线程Alpha可以按预期运行。可以看到该标志表明需要初始化,然后进行锁定,初始化c,设置该标志并离开。

现在线程Bravo再次运行,该标志现在指示不需要初始化,因此我们使用我们先前阅读的c版本,并取消引用null

在C#中,严格检查双重锁定是正确的,只要您严格遵循确切的严格检查锁定模式即可。一旦您偏离了它,您就会陷入像我刚才描述的那种可怕,不可复制的种族条件错误的杂草中。只是不要去那里:

  • 不要在线程之间共享内存。我了解到我刚刚告诉您的所有内容后得出的结论是我不够聪明,无法编写共享内存并按设计工作的多线程代码。我只够聪明地编写偶然发生 的多线程代码,这对我来说是不可接受的。
  • 如果必须跨线程共享内存,请锁定所有访问,无一例外。没那么贵!您知道更昂贵的吗?处理一系列无法​​复制的致命崩溃,所有这些崩溃都会丢失用户数据。
  • 如果必须在线程之间共享内存,并且必须具有低锁延迟初始化,那么不要自己编写它。使用Lazy<T>;它包含低锁定延迟初始化的正确实现,您可以依靠它在所有处理器体系结构上都是正确的。

后续问题:

  

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

为澄清起见,此问题是关于在双重检查的锁定方案中是否正确读取了初始化标志。让我们在这里再次解决您的误解。

保证已初始化的标志在锁内正确读取,因为它在锁内写入

但是,正如我之前提到的那样,思考此问题的正确方法不是 来推断有关刷新缓存的任何事情。对此的正确解释是C#规范对如何相对于锁及时移动读写进行了限制。

尤其是,在锁中读取的内部可能不会移至 之前,在锁中写入的 可能不会移至 移到 锁之后。这些事实与锁提供互斥的事实相结合,足以得出结论:在锁内部读取初始化标志是正确的。

再次,如果您不愿意进行此类推论-我可不是! -然后不要编写低锁代码。