.NET中双重检查锁定需要volatile修饰符

Kon*_*tin 80 c# singleton volatile

多个文本说,当在.NET中实现双重检查锁定时,您锁定的字段应该应用volatile修饰符.但为什么呢?考虑以下示例:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}
Run Code Online (Sandbox Code Playgroud)

为什么不"锁定(syncRoot)"完成必要的内存一致性?在"lock"语句之后,读取和写入都是不稳定的,因此必须实现必要的一致性,这不是真的吗?

dan*_*dan 59

易失性是不必要的.好吧,有点**

volatile用于在变量的读写之间创建内存屏障*.
lock当使用时,lock除了限制对一个线程的块的访问之外,还会在其内部的块周围创建内存屏障.
内存障碍使得每个线程都读取变量的最新值(不是某个寄存器中缓存的本地值),并且编译器不会重新排序语句.使用volatile是不必要的**因为你已经锁定了.

Joseph Albahari比以往任何时候都更好地解释了这个问题.

请务必查看Jon Skeet的C#单例实现指南


update:
*volatile导致变量的读取为VolatileReads,写入为VolatileWrites,在CL86上的x86和x64上,用a实现MemoryBarrier.它们可能在其他系统上更精细.

**如果您在x86和x64处理器上使用CLR,我的答案才是正确的.这可能是在其他内存模型真实,就像在Mono(和其他实现),Itanium64和未来的硬件.这就是Jon在他的文章中提到的双重检查锁定的"陷阱".

执行{标记变量volatile,读取Thread.VolatileRead或插入调用Thread.MemoryBarrier}之一可能需要使代码在弱内存模型情况下正常工作.

根据我的理解,在CLR上(甚至在IA64上),写入永远不会被重新排序(写入总是具有释放语义).但是,在IA64上,读取可能会在写入之前重新排序,除非它们标记为volatile.不幸的是,我无法使用IA64硬件,因此我所说的任何内容都是猜测.

我也发现这些文章很有用:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
vance morrison的文章(一切都链接到这个,它谈到双重检查锁定)
克里斯布鲁姆的文章 (一切都链接到这个)
Joe Duffy:双重锁定的破坏变种

luis abreu关于多线程的系列文章也很好地概述了这些概念
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http:// msmvps. COM /博客/ luisabreu /存档/ 2009/07/03 /多线程引入记忆,fences.aspx

  • 这个答案对我来说不对.如果在*any*平台上不需要`volatile`那么就意味着JIT无法优化内存负载`对象s1 = syncRoot; object s2 = syncRoot;`to`object s1 = syncRoot; 对象s2 = s1;`在该平台上.这对我来说似乎不太可能. (2认同)

Mig*_*elo 33

有一种方法可以在没有volatile字段的情况下实现它.我会解释一下......

我认为锁内部的内存访问重新排序是危险的,这样你就可以在锁之外获得一个未完全初始化的实例.为了避免这种情况,我这样做:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}
Run Code Online (Sandbox Code Playgroud)

理解代码

想象一下,Singleton类的构造函数中有一些初始化代码.如果在使用新对象的地址设置字段后重新排序这些指令,那么您有一个不完整的实例...想象该类具有以下代码:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}
Run Code Online (Sandbox Code Playgroud)

现在想象一下使用new运算符调用构造函数:

instance = new Singleton();
Run Code Online (Sandbox Code Playgroud)

这可以扩展到这些操作:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;
Run Code Online (Sandbox Code Playgroud)

如果我重新排序这些说明怎么办:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;
Run Code Online (Sandbox Code Playgroud)

这有什么不同吗?NO,如果你认为一个单线程的.YES,如果你认为多线程的......如果线程刚过interruped set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized
Run Code Online (Sandbox Code Playgroud)

这是内存屏障避免的,不允许内存访问重新排序:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;
Run Code Online (Sandbox Code Playgroud)

快乐的编码!

  • ctor 内部的障碍无法修复它。如果 CLR 在 ctor 完成之前将引用分配给新分配的对象并且没有插入 membarrier,那么另一个线程可以在半初始化对象上执行实例方法。 (2认同)

Jas*_*ams 7

我认为没有人真正回答过这个问题,所以我会试一试.

不稳定和第一个if (instance == null)不是"必要的".锁将使此代码线程安全.

所以问题是:你为什么要加第一个if (instance == null)呢?

原因可能是避免不必要地执行锁定的代码段.当您在锁内部执行代码时,任何其他尝试执行该代码的线程都会被阻止,如果您尝试从多个线程中频繁访问该单例,这将减慢您的程序速度.根据语言/平台的不同,锁定本身也可能存在您希望避免的开销.

因此,添加第一个空检查是一种非常快速的方法,可以查看是否需要锁定.如果您不需要创建单例,则可以完全避免锁定.

但是你无法检查引用是否为null而不以某种方式锁定它,因为由于处理器缓存,另一个线程可能会更改它,你会读到一个"陈旧"值,导致你不必要地进入锁.但你正试图避免锁定!

因此,您可以使单例易失性,以确保您读取最新值,而无需使用锁定.

您仍然需要内部锁定,因为volatile仅在单次访问变量时保护您 - 您无法在不使用锁定的情况下安全地进行测试和设置.

现在,这实际上有用吗?

好吧,我会说"在大多数情况下,没有".

如果Singleton.Instance可能因锁而导致效率低下,那么为什么要频繁调用它以至于这将是一个重大问题呢?单例的重点是只有一个,所以你的代码可以读取和缓存单例引用一次.

我能想到这种缓存不可能的唯一情况是当你有大量的线程时(例如,使用新线程来处理每个请求的服务器可能会创建数百万个非常短的运行线程,每个线程都是这将不得不调用Singleton.Instance一次).

因此,我怀疑双重检查锁定是一种在非常具体的性能关键情况下具有真实位置的机制,然后每个人都已经抓住了"这是正确的方式来做到这一点"并没有真正想到它做什么以及是否在他们使用它的情况下实际上是必要的.

  • 这是在错误和缺失之间的某个地方.`volatile`与双重检查锁中的锁语义无关,它与内存模型和缓存一致性有关.其目的是确保一个线程不会接收仍由另一个线程初始化的值,而双重检查锁定模式本身并不会阻止该值.在Java中,你肯定需要`volatile`关键字; 在.NET中它很模糊,因为根据ECMA它是错误的,但根据运行时间而言.无论哪种方式,`lock`绝对不会*照顾它. (5认同)
  • 你的答案,就像这个帖子中的其他几个语句一样,声称`lock`使代码线程安全.那部分是真的,**但是双重检查锁定模式可以使它*不安全*.**这就是你似乎缺少的东西.这个答案似乎蜿蜒于双重检查锁的意义和目的,而没有解决线程安全问题,这是"volatile"的原因. (5认同)

use*_*937 5

您应该将 volatile 与双重检查锁定模式一起使用。

大多数人指出这篇文章证明你不需要 volatile:https : //msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

但他们没有读到最后:“最后一句警告——我只是从现有处理器上观察到的行为中猜测 x86 内存模型。因此,低锁定技术也很脆弱,因为硬件和编译器会随着时间的推移变得更加积极. 这里有一些策略可以最大限度地减少这种脆弱性对您的代码的影响。首先,尽可能避免使用低锁技术。(...) 最后,假设可能的最弱内存模型,使用 volatile 声明而不是依赖隐式保证.”

如果您需要更有说服力,请阅读有关将用于其他平台的 ECMA 规范的这篇文章:msdn.microsoft.com/en-us/magazine/jj863136.aspx

如果您需要进一步令人信服,请阅读这篇较新的文章,其中可能会进行优化以防止它在没有 volatile 的情况下工作:msdn.microsoft.com/en-us/magazine/jj883956.aspx

总之,它“可能”在没有 volatile 的情况下为您工作,但不要冒险编写正确的代码并使用 volatile 或 volatile 读/写方法。建议不这样做的文章有时会遗漏一些可能影响您的代码的 JIT/编译器优化的可能风险,以及我们未来可能发生的可能会破坏您的代码的优化。同样如上一篇文章中提到的假设,之前在没有 volatile 的情况下工作的假设可能不适用于 ARM。