Singleton仔细检查并发问题

cho*_*ppy 3 c# concurrency singleton volatile double-checked-locking

fallowing子句来自jetbrains.net在阅读了这篇以及网上的其他文章后,我仍然不明白在第一个线程进入锁之后如何返回null.有人确实理解它可以帮助我并以更人性化的方式解释它吗?

"考虑以下代码:

public class Foo
{
  private static Foo instance;
  private static readonly object padlock = new object();

  public static Foo Get()
  {
    if (instance == null)
    {
      lock (padlock)
      {
        if (instance == null)
        {
          instance = new Foo();
        }
      }
    }
    return instance;
  }
};
Run Code Online (Sandbox Code Playgroud)

给定上面的代码,初始化Foo实例的写入可以被延迟,直到写入实例值,从而产生实例返回处于单元化状态的对象的可能性.

为了避免这种情况,必须使实例值易变."

Bri*_*eon 12

回归null不是问题.问题是新实例可能处于另一个线程所感知的部分构造状态.考虑一下这个宣言Foo.

class Foo
{
  public int variable1;
  public int variable2;

  public Foo()
  {
    variable1 = 1;
    variable2 = 2;
  }
}
Run Code Online (Sandbox Code Playgroud)

以下是C#编译器,JIT编译器或硬件如何优化代码.1

if (instance == null)
{
  lock (padlock)
  {
    if (instance == null)
    {
      instance = alloc Foo;
      instance.variable1 = 1; // inlined ctor
      instance.variable2 = 2; // inlined ctor
    }
  }
}
return instance;
Run Code Online (Sandbox Code Playgroud)

首先,请注意构造函数是内联的(因为它很简单).现在,希望很容易看到instance在构造函数内部初始化其组成字段之前获得引用.这是一个有效的策略,因为只要读取和写入不通过lock或改变逻辑流程,读取和写入就可以自由地上下浮动; 他们没有.因此,另一个线程可以instance != null在完全初始化之前查看并尝试使用它.

volatile解决了这个问题,因为它将读取视为获取栅栏并写入释放栅栏.

  • acquire-fence:一种内存屏障,其中不允许其他读写操作在围栏之前移动.
  • release-fence:一种内存屏障,在屏障后不允许其他读写操作.

因此,如果我们标记instancevolatile那么释放栅栏将阻止上述优化.以下是使用屏障注释查看代码的方式.我用↑箭头表示释放栅栏和↓箭头表示获取栅栏.请注意,没有任何东西可以通过↑箭头向上浮动或超过↓箭头.想想箭头将一切推开.

var local = instance;
? // volatile read barrier
if (local == null)
{
  var lockread = padlock;
  ? // lock full barrier
  lock (lockread)
  ? // lock full barrier
  {
    local = instance;
    ? // volatile read barrier
    if (local == null)
    {
      var ref = alloc Foo;
      ref.variable1 = 1; // inlined ctor
      ref.variable2 = 2; // inlined ctor
      ? // volatile write barrier
      instance = ref;
    }
  ? // lock full barrier
  }
  ? // lock full barrier
}
local = instance;
? // volatile read barrier
return local;
Run Code Online (Sandbox Code Playgroud)

对组成变量的写入Foo仍然可以重新排序,但请注意,内存屏障现在可以防止它们在分配后发生instance.使用箭头作为指导,想象允许和禁止的各种不同的优化策略.请记住,不允许任何读取写入通过↑箭头向上浮动或向上移动超过↓箭头.

Thread.VolatileWrite也可以解决这个问题,并且可以在没有volatile像VB.NET这样的关键字的语言中使用.如果你看看如何VolatileWrite实现你会看到这个.

public static void VolatileWrite(ref object address, object value)
{
  Thread.MemoryBarrier();
  address = value;
}
Run Code Online (Sandbox Code Playgroud)

现在,这可能看起来反直觉.毕竟,记忆障碍是作业之前放置的.如何将作业提交给你要求的主内存?在作业完成放置障碍物会不正确?如果这就是你的直觉告诉你的话那就错了.你看到记忆障碍并非严格意义上的"新鲜阅读"或"承诺写作".这完全是关于指令的排序.这是迄今为止我看到的最大混乱的根源.

提到Thread.MemoryBarrier实际产生全栅栏障碍也许是很重要的.所以,如果我用箭头上面的符号,那么它看起来就像这样.

public static void VolatileWrite(ref object address, object value)
{
  ? // full barrier
  ? // full barrier
  address = value;
}
Run Code Online (Sandbox Code Playgroud)

因此,技术上的召唤VolatileWrite不仅仅是对volatile字段的写入所做的事情.请记住,volatile例如,VB.NET中不允许这样做,但它VolatileWrite是BCL的一部分,因此可以在其他语言中使用.


1 这种优化主要是理论上的.ECMA规范在技术上允许它,但ECMA规范的Microsoft CLI实现将所有写入视为已经具有发布范围语义.CLI的另一个实现可能仍然可以执行此优化.