Singleton仔细检查并发问题

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实例的写入可能会延迟,直到写入实例值,从而产生实例返回处于单元化状态的对象的可能性。

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

返回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也可以解决这个问题,并且可以在没有像VB.NET这样的volatile关键字的语言中使用。 如果你看看如何实现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的另一个实现可能仍然可以执行此优化。

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

一个值得注意的参考是“双重锁定已破损”声明。

粗略地说,问题在于:

在mutlicore VM中,在到达同步障碍(或内存防护)之前,线程写入可能对其他线程不可见。 您可以阅读“ 内存障碍:软件黑客的硬件视图 ”,这是一篇非常好的文章。

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

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

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

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

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