在.NET中双重检查锁定中需要使用volatile修饰符的原因

92

多个文本表示,在.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)" 不能达到必要的内存一致性? "lock" 语句后读写都是 volatile 的,所以不应该能够达到必要的一致性吗?


2
这个问题已经被反复讨论过很多次了。http://www.yoda.arachsys.com/csharp/singleton.html - Hans Passant
1
不幸的是,在那篇文章中Jon提到了“volatile”两次,而且两次引用都没有直接涉及他提供的代码示例。 - Dan Esparza
请参考这篇文章了解相关问题:http://igoro.com/archive/volatile-keyword-in-c-memory-model-explained/。基本上,理论上 JIT 可以使用 CPU 寄存器来存储实例变量 - 特别是如果需要一些额外的代码。因此,进行两次 if 语句可能会返回相同的值,而不管它在另一个线程中是否发生了更改。实际上,答案有点复杂,lock 语句可能会或可能不会对此起到改善作用(续)。 - user2685937
这是我认为真正发生的事情 - 基本上,任何比读取或设置变量更复杂的代码都可能触发JIT说:“忘记尝试优化这个,让我们只是加载和保存到内存,因为如果调用函数,则JIT潜在地需要保存并重新加载寄存器,而不是这样做,它直接从内存中读写每次。我怎么知道锁定没有什么特别之处?看看我在以前的评论中发布的Igor的链接。 - user2685937
我测试了Igor的代码,当它创建一个新线程时,我在它周围添加了锁,并使其循环。但是,由于实例变量被提升出循环,它仍然不会导致代码退出。在while循环中添加一个简单的局部变量设置仍然会将变量提升出循环。现在,任何更复杂的代码,如if语句、方法调用或甚至锁定调用,都会阻止优化,从而使其正常工作。因此,任何复杂的代码通常强制直接访问变量,而不允许JIT进行优化。 - user2685937
1
如果JIT优化得更好,那么如果不使用volatile,您的双重检查锁定可能会停止工作(或者如果使用Mono,则无法工作,或者在ARM平台上运行也很可能无法工作)。如果人们读完最常引用的(Van M)文章,建议不要在最后使用volatile,他说您应该只是使用volatile以确保安全。 - user2685937
8个回答

62

不需要使用volatile。但是,它可以在变量的读写之间创建内存屏障*。
当使用lock时,会在lock块周围创建内存屏障,并限制对该块的访问仅限于一个线程。
内存屏障使每个线程读取变量的最新值(而不是缓存在某些寄存器中的本地值),并且编译器不会重新排序语句。因为已经使用了lock,所以使用volatile是不必要的**。

Joseph Albahari解释得比我更好。

确保查看Jon Skeet的guide to implementing the singleton in C#


更新:
*volatile导致对变量的读取为VolatileRead,写入为VolatileWrite,在CLR上的x86和x64上,它们是使用MemoryBarrier实现的。在其他系统上可能会更细粒度。

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

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

据我所知,在CLR上(即使在IA64上),写入永远不会被重新排序(写入始终具有释放语义)。然而,在IA64上,读取可能会被重新排序,以便在写入之前进行,除非它们被标记为volatile。不幸的是,我没有IA64硬件可供测试,因此我所说的任何内容都只能是猜测。

我还发现这些文章很有帮助:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
vance morrison's article(所有链接都指向该文章,它讨论了双重检查锁定)
chris brumme's article(所有链接都指向该文章)
Joe Duffy: Broken Variants of Double Checked Locking

路易斯·阿布鲁的多线程系列文章对相关概念进行了很好的概述

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

Jon Skeet实际上说,需要使用volatile修饰符来创建正确的内存屏障,而第一个链接的作者则说lock(Monitor.Enter)就足够了。究竟谁是对的呢? - Konstantin
如果锁确实产生了内存屏障,而且内存屏障确实涉及指令顺序和缓存失效,那么它应该在所有处理器上都能正常工作。不管怎样,这样一个基本的东西引起了如此多的混乱,真是太奇怪了... - Konstantin
2
这个答案在我看来似乎是错误的。如果在任何平台上volatile都是不必要的,那么这意味着JIT无法在该平台上优化内存加载object s1 = syncRoot; object s2 = syncRoot;object s1 = syncRoot; object s2 = s1;。这对我来说似乎非常不可能。 - user541686
相信这个答案至少有一个警告; 问题在于,如果对象不为空,则在读取时没有发出锁定命令或内存屏障命令,因此对于没有易失性变量的读取器没有内存屏障。 如果单例是使用任何共享对象实例化的,则读取线程可能会缓存该共享对象状态的过时版本,这意味着它可以将单例读取为其从未处于的状态。 volatile变量可以防止这种情况,只要易失性变量的读取引起内存屏障。 - Theodore Murdock
1
即使CLR不会重新排序写操作(我怀疑这是不正确的,因为通过这样做可以进行许多非常好的优化),只要我们可以内联构造函数调用并在原地创建对象,它仍然会有错误(我们可能会看到一个半初始化的对象)。与底层CPU使用的任何内存模型无关!根据Eric Lippert的说法,至少在Intel上,CLR在构造函数之后引入了membarrier来拒绝该优化,但规范并不要求这样做,例如在ARM上发生相同的事情,我不会指望。 - Voo
显示剩余2条评论

36

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

理解代码

假设在单例类的构造函数中有一些初始化代码。 如果这些指令在将字段设置为新对象地址之后重新排序,则会导致不完整的实例... 假设该类具有以下代码:

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;

编程愉快!


1
如果CLR允许在对象初始化之前访问该对象,那么这就是一个安全漏洞。想象一下一个特权类,它的唯一公共构造函数设置"SecureMode = 1",然后其实例方法检查该值。如果你可以在构造函数运行之前调用这些实例方法,那么你就可以打破安全模型并违反沙盒隔离。 - MichaelGG
1
如果您描述的类将支持多个线程访问,则存在问题。如果Jitter内联调用构造函数,则CPU有可能以一种使存储的引用指向未完全初始化的实例的方式重新排序指令。这不是CLR安全问题,因为它可以避免,程序员有责任在这种类的构造函数中使用:interlocked、memory barriers、locks和/或volatile字段。 - Miguel Angelo
2
在构造函数内部设置屏障并不能解决问题。如果CLR在构造函数完成之前将引用分配给新分配的对象,并且没有插入membarrier,则另一个线程可能会在半初始化的对象上执行实例方法。 - MichaelGG
这是ReSharper 2016/2017在C#中建议使用的“替代模式”,以防DCL。另一方面,Java确保new的结果完全初始化。 - user2864740
我知道 MS .net 实现在构造函数的结尾放置了一个内存屏障... 但最好还是安全第一。 - Miguel Angelo

7
我认为没有人真正回答了这个问题,所以我来试一试。
volatile和第一个if(instance==null)并不是“必需的”。锁将使这段代码线程安全。
所以问题是:为什么要添加第一个if(instance==null)呢?
原因可能是为了避免不必要地执行锁定部分的代码。当您执行锁定内部的代码时,任何尝试执行该代码的其他线程都会被阻塞,如果您尝试从许多线程频繁访问单例,则会减慢程序速度。根据语言/平台的不同,锁本身也可能存在开销,您希望避免这些开销。
因此,添加第一个空值检查作为一种非常快速的方法,可以看到是否需要锁定。如果不需要创建单例,则可以完全避免锁定。
但是,您无法在某种方式下锁定它而不检查引用是否为空,因为由于处理器缓存,另一个线程可能会更改它,您将读取一个“陈旧”的值,导致您不必要地进入锁定。但是,您正在尝试避免锁定!
因此,使单例易失性以确保您读取最新值,而无需使用锁定。
您仍然需要内部锁定,因为易失性仅在对变量的单个访问期间保护您-您无法安全地测试和设置它而不使用锁定。
那么,这真的有用吗?
嗯,我会说“在大多数情况下,不需要”。
如果Singleton.Instance由于锁定而导致效率低下,那么为什么要频繁调用它以至于这将是一个显着问题?单例的整个重点在于只有一个,因此您的代码可以读取和缓存单例引用一次。
我唯一想到的可能无法缓存的情况是当您有大量线程时(例如,服务器使用新线程处理每个请求可能会创建数百万个非常短暂的线程,每个线程都必须调用Singleton.Instance一次)。
因此,我认为双重检查锁定是一种在非常特定的性能关键情况下具有实际用途的机制,然后每个人都加入了“这是正确的方法”车队,而没有真正思考它的作用以及是否实际上需要在他们为之使用它的情况下。

6
这个说法有些错误或者误解了要点。在双重检查锁定中,“volatile”与锁的语义无关,它与内存模型和缓存一致性有关。其目的是确保一个线程不会接收到另一个线程仍在初始化的值,而双重检查锁定模式本身并不能防止这种情况发生。在Java中,您绝对需要使用“volatile”关键字;在.NET中则比较复杂,因为根据ECMA标准是错误的,但根据运行时是正确的。无论哪种情况,“lock”都肯定不能解决这个问题。 - Aaronaught
哎?我看不出你的陈述与我的相矛盾,我也没说volatile与锁语义有任何关系。 - Jason Williams
6
您的回答与本帖中的其他陈述一样,声称“锁”可以使代码线程安全。这部分是正确的,但双重检查锁定模式可能会使其不安全。这就是您似乎忽略的部分。该回答似乎围绕着双重检查锁的含义和目的而漫无目的地徘徊,而没有解决“易失性”的原因——线程安全问题。 - Aaronaught
1
如果将“实例(instance)”标记为“易失(volatile)”,它会如何变得不安全? - UserControl

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/编译器优化的一些可能风险,这可能会影响您的代码,以及未来可能发生的优化可能会破坏您的代码。此外,正如上一篇文章中提到的假设,在ARM上已经没有volatile的先前假设可能不再成立。


1
好的答案。这个问题唯一正确的答案是简单的“不”。因此,被接受的答案是错误的。 - Dennis Kassel

3
据我所知(请谨慎对待,因为我没有做很多并发处理),不行。锁只能在多个竞争者(线程)之间提供同步。
另一方面,volatile 告诉您的机器每次重新评估值,以便您不会遇到缓存的(错误的)值。
请参见 http://msdn.microsoft.com/en-us/library/ms998558.aspx 并注意以下引用:
“此外,将变量声明为 volatile 可确保在可以访问实例变量之前完成对实例变量的赋值。”
volatile 的描述:http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

2
“锁”也提供了内存屏障,与volatile相同或更好。 - H H

1

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

A better approach is to synchronize access to static data by locking a private static object. For example:

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) {
          ...
        }
    }
}

Jon Skeet 在他的文章(http://www.yoda.arachsys.com/csharp/singleton.html)中说,在这种情况下需要使用 volatile 来实现正确的内存屏障。Marc,你能对此发表评论吗? - Konstantin
啊,我没有注意到双重检查锁定;简单来说:不要那样做 ;-p - Marc Gravell
我认为双重检查锁定在性能方面是一件好事。此外,如果需要在锁内访问字段时将其设置为易失性,则双重检查锁定并不比任何其他锁定差得多... - Konstantin
但是它是否像Jon提到的单独类方法一样好呢? - Marc Gravell

1
我认为我找到了我要找的东西。详细信息请参见本文 - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10
总之,在.NET中,volatile修饰符在这种情况下确实不需要。然而,在较弱的内存模型中,惰性初始化对象的构造函数中进行的写操作可能会延迟到字段写入之后,因此其他线程可能会在第一个if语句中读取损坏的非空实例。

1
请仔细阅读该文章的最后一句话,特别是作者所说的最后一句话:“最后警告 - 我只是根据现有处理器的观察行为猜测x86内存模型。因此,低锁技术也很脆弱,因为硬件和编译器随着时间的推移可能会变得更加激进。以下是一些策略,可将此脆弱性对您的代码的影响降至最小。首先,尽可能避免使用低锁技术。(...)最后,假设内存模型最弱,使用易失性声明而不是依赖于隐式保证。” - user2685937
1
如果您需要更多的说服,请阅读有关ECMA规范将用于其他平台的文章:msdn.microsoft.com/en-us/magazine/jj863136.aspx如果您需要进一步的说服,请阅读这篇较新的文章,其中可能会放置优化以防止它在没有volatile的情况下工作:msdn.microsoft.com/en-us/magazine/jj883956.aspx总之,它“可能”在没有volatile的情况下为您工作,但不要冒险编写正确的代码,并使用volatile或volatileread/write方法。 - user2685937

-3

3
有趣,但并非非常有用。JVM的内存模型和CLR的内存模型并不相同。 - bcat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接