Singleton仔细检查并发问题

时间:2012-04-23 13:00:07

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

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

为了避免这种情况,必须使实例值变为易失性。 “

2 个答案:

答案 0 :(得分:12)

返回null不是问题。问题是新实例可能处于另一个线程所感知的部分构造状态。请考虑Foo

的此声明
class Foo
{
  public int variable1;
  public int variable2;

  public Foo()
  {
    variable1 = 1;
    variable2 = 2;
  }
}

以下是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;

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

volatile修复了此问题,因为它将读取视为获取围栏并写为发布围栏

  • acquire-fence:一个记忆障碍,其他读取&写作不允许在围栏前移动。
  • release-fence:一个记忆障碍,其他读取&在围栏之后不允许写入。

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

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;

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

Thread.VolatileWrite也可以解决这个问题,并且可以在没有volatile关键字的语言中使用,如VB.NET。如果您看一下VolatileWrite的实施方式,您会看到这一点。

public static void VolatileWrite(ref object address, object value)
{
  Thread.MemoryBarrier();
  address = value;
}

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

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

public static void VolatileWrite(ref object address, object value)
{
  ↑ // full barrier
  ↓ // full barrier
  address = value;
}

因此,技术上调用VolatileWrite比写volatile字段所做的更多。请记住,例如,VB.NET中不允许使用volatile,但VolatileWrite是BCL的一部分,因此可以在其他语言中使用。


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

答案 1 :(得分:3)

Bill Pugh撰写了几篇关于这个主题的文章,并且是关于该主题的参考文献。

值得注意的参考是The "Double-Checked Locking is Broken" Declaration.

粗略地说,问题在于:

在mutlicore VM中,在到达同步障碍(或内存防护)之前,线程写入可能对其他线程不可见。你可以阅读“Memory Barriers: a Hardware View for Software Hackers”,这是一篇关于此事的非常好的文章。

因此,如果一个线程使用一个字段A初始化一个对象a,并将该对象的引用存储在另一个对象ref的字段B中,那么我们内存中有两个“单元格”:aref。两个内存位置的更改可能不会同时对其他线程可见,除非线程强制使用内存栅栏进行更改的可见性。

在java中,可以使用synchronized强制同步。这很昂贵,并且可以将字段声明为volatile,在这种情况下,对此单元格的更改始终对所有线程都可见。

但是,Java 4和5之间的易变性的语义。在Java 4中,您需要将aref定义为volatile,以便在示例I中使用doulbe检查描述。

这不直观,大多数人只会将ref设置为易变。所以他们改变了这个并且在Java 5+中,如果修改了一个volatile字段(ref),它会触发修改的其他字段(a)的同步。

编辑:我现在只看到你要求C#,而不是Java ......我留下了答案,因为它可能仍然有用。