88

多个文本说,在 .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;
      }
   }
}

为什么“lock (syncRoot)”不能实现必要的内存一致性?不是真的在“锁定”语句之后读取和写入都将是易失的,因此会实现必要的一致性?

4

8 回答 8

62

挥发性是不必要的。嗯,有点**

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

Joseph Albahari比以往任何时候都更好地解释了这些东西。

并且一定要查看 Jon Skeet 的在 C#中实现单例的指南


update :
*volatile导致对变量的读取为VolatileReads,写入为VolatileWrites,在 x86 和 CLR 上的 x64 上,使用MemoryBarrier. 它们在其他系统上的粒度可能更细。

**我的回答只有在 x86 和 x64 处理器上使用 CLR 时才是正确的。在其他内存模型中可能是这样,例如在 Mono(和其他实现)、Itanium64 和未来的硬件上。这就是 Jon 在他的文章中提到的双重检查锁定的“陷阱”。

执行{将变量标记为volatile、用 读取它Thread.VolatileRead或插入对Thread.MemoryBarrier} 的调用之一可能是代码在弱内存模型情况下正常工作所必需的。

据我了解,在 CLR 上(甚至在 IA64 上),写入永远不会重新排序(写入总是具有释放语义)。但是,在 IA64 上,读取可能会被重新排序以在写入之前进行,除非它们被标记为易失性。不幸的是,我无法使用 IA64 硬件,所以我所说的一切都是猜测。

我还发现这些文章很有帮助: http:
//www.codeproject.com/KB/tips/MemoryBarrier.aspx
vance morrison 的文章(一切都链接到这个,它谈到了双重检查锁定)
chris brumme 的文章 (一切都链接到这个)
Joe Duffy:双重检查锁定的破碎变体

luis abreu 的多线程系列也很好地概述了这些概念
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http://msmvps。 com/blogs/luisabreu/archive/2009/07/03/multithreading-introducing-memory-fences.aspx

于 2009-12-27T01:06:29.760 回答
34

有一种方法可以在没有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;
      }
   }
}

理解代码

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

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

private Singleton()
{
    this._value = 1;
}

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

instance = new Singleton();

这可以扩展到这些操作:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

如果我像这样重新排序这些说明会怎样:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

这有什么不同吗?,如果你想到一个线程。的,如果您想到多个线程...如果线程在之后中断怎么办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

这就是内存屏障通过不允许内存访问重新排序来避免的:

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;

快乐编码!

于 2012-10-18T00:38:31.617 回答
7

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

volatile 和 firstif (instance == null)不是“必要的”。锁将使此代码成为线程安全的。

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

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

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

但是如果不以某种方式锁定引用,则无法检查引用是否为空,因为由于处理器缓存,另一个线程可能会更改它,并且您将读取一个“陈旧”值,这会导致您不必要地进入锁定。但是您正试图避免锁定!

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

您仍然需要内部锁,因为 volatile 仅在对变量的单次访问期间保护您 - 如果不使用锁,您将无法安全地测试和设置它。

现在,这真的有用吗?

好吧,我会说“在大多数情况下,不”。

如果 Singleton.Instance 可能由于锁而导致效率低下,那么为什么要如此频繁地调用它以至于这将是一个重大问题?单例的全部意义在于只有一个,因此您的代码可以读取和缓存单例引用一次。

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

所以我怀疑双重检查锁定是一种在非常具体的性能关键情况下具有真实位置的机制,然后每个人都在没有真正思考它的作用以及是否它的情况下攀上了“这是做它的正确方法”的潮流在他们使用它的情况下实际上是必要的。

于 2009-12-27T08:55:52.747 回答
6

您应该将 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 或 volatileread/write 方法。建议不这样做的文章有时会忽略 JIT/编译器优化可能会影响您的代码的一些风险,以及我们未来可能发生的可能会破坏您的代码的优化。同样正如上一篇文章中提到的假设,之前没有 volatile 的假设可能已经不适用于 ARM。

于 2015-02-23T04:18:31.700 回答
3

AFAIK(并且 - 谨慎对待,我没有做很多并发的东西)不。锁只是让您在多个竞争者(线程)之间进行同步。

另一方面,volatile 告诉您的机器每次都重新评估该值,这样您就不会偶然发现缓存的(和错误的)值。

请参阅http://msdn.microsoft.com/en-us/library/ms998558.aspx并注意以下引用:

此外,该变量被声明为 volatile 以确保在可以访问实例变量之前完成对实例变量的赋值。

volatile的描述:http: //msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

于 2009-12-27T00:20:50.510 回答
2

我想我已经找到了我要找的东西。详细信息在这篇文章中 - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

综上所述 - 在这种情况下,确实不需要 .NET 中的 volatile 修饰符。然而,在较弱的内存模型中,延迟启动对象的构造函数中的写入可能会在写入字段后延迟,因此其他线程可能会在第一个 if 语句中读取损坏的非空实例。

于 2009-12-27T22:56:13.440 回答
1

lock就足够了。MS 语言规范(3.0)本身在第 8.12 节中提到了这个确切的场景,没有提到volatile

更好的方法是通过锁定私有静态对象来同步对静态数据的访问。例如:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
于 2009-12-27T09:19:22.230 回答
-3

这是一篇关于使用带有双重检查锁定的 volatile 的非常好的帖子:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

在 Java 中,如果目的是保护一个变量,如果它被标记为 volatile,则不需要锁定

于 2009-12-27T00:19:21.947 回答