.NET中双重检查锁定中对volatile修饰符的需求


85

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

为什么“锁(syncRoot)”无法实现必要的内存一致性?在“锁定”语句之后,读取和写入都是不稳定的,这样就可以实现必要的一致性,这不是真的吗?


2
这已经被很多次咀嚼过了。 yoda.arachsys.com/csharp/singleton.html
汉斯·

1
不幸的是,在Jon的文章中,他两次引用了“ volatile”,而没有一个引用直接引用他给出的代码示例。
丹·埃斯帕萨

请参阅本文以了解问题所在:igoro.com/archive/volatile-keyword-in-c-memory-model-explained 基本上,从理论上讲,JIT可以为实例变量使用CPU寄存器-特别是如果您需要那里几乎没有多余的代码。因此,执行两次if语句可能会返回相同的值,而不管它是否在另一个线程上更改。在现实中,答案是有点复杂lock语句可能会或可能不会是负责使事情更好地在这里(续)
user2685937

(请参阅前面的评论,继续)-这是我认为确实正在发生的事情-基本上,任何比读取或设置变量更复杂的代码都可能触发JIT发出提示,而忘记尝试优化这一点,让我们直接加载并保存到内存中,因为如果一个函数被调用,则JIT可能需要在每次将寄存器存储在其中时保存并重新加载该寄存器,而不是这样做,它只是每次都直接从内存中写入和读取。我怎么知道锁没什么特别的?看看我在Igor上一条评论中发布的链接(续下
一条

(请参阅上面的2条评论)-我测试了Igor的代码,当它创建一个新线程时,我在其周围添加了一个锁,甚至使其循环。它仍然不会导致代码退出,因为实例变量被提升到了循环之外。在while循环中添加一个简单的局部变量集仍使该变量脱离循环-现在,任何更复杂的操作(例如if语句或方法调用,甚至是锁调用)都会阻止优化,从而使其无法工作。因此,任何复杂的代码通常会强制直接变量访问,而不是允许JIT优化。(续下
一条

Answers:


59

挥发是不必要的。好吧,**

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

约瑟夫·阿尔巴哈里(Joseph Albahari)解释这种东西的方式比我以往任何时候都好。

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


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

**仅当您在x86和x64处理器上使用CLR时,我的回答才是正确的。在其他内存模型中,例如在Mono(和其他实现),Itanium64和将来的硬件上,可能是正确的。这就是乔恩在“陷阱”中的文章中针对双重检查锁定所指的内容。

为了使代码在内存模型较弱的情况下正常工作,可能需要执行以下操作之一(将变量标记为volatile,使用进行读取Thread.VolatileRead或插入对的调用)Thread.MemoryBarrier

据我了解,在CLR(即使是在IA64上),写入也不会重新排序(写入始终具有发布语义)。但是,在IA64上,除非将其标记为易失性,否则可能会将读取重新排序为先于写入。不幸的是,我无权使用IA64硬件,因此我所说的只是猜测。

我还发现这些文章有所帮助:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
万斯莫里森的文章(一切链接到这一点,它谈论的双重检查锁定)
克里斯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


乔恩·斯凯特(Jon Skeet)实际上说,需要使用volatile修饰符来创建适当的内存屏障,而第一个链接作者说,锁定(Monitor.Enter)就足够了。谁是正确的???
康斯坦丁2009年

@Konstantin似乎Jon指的是Itanium 64处理器上的内存模型,因此在这种情况下,可能需要使用volatile。但是,在x86和x64处理器上不需要volatile。我会再更新一点。

如果锁确实在造成内存障碍,并且内存障碍确实与指令顺序和缓存无效有关,那么它应该在所有处理器上都可以工作。无论如何,如此基本的事情引起了如此多的混乱,真是太奇怪了……
Konstantin

2
这个答案对我来说似乎是错误的。如果volatile上是不必要的任何平台,那么这将意味着JIT无法优化内存加载object s1 = syncRoot; object s2 = syncRoot;object s1 = syncRoot; object s2 = s1;该平台上。对我来说,这似乎不太可能。
user541686 2013年

1
即使CLR不会对写入进行重新排序(我确实是对的,通过这样做可以完成许多非常好的优化),但是只要我们可以内联构造函数调用并就地创建对象,它仍然是有问题的。 (我们可以看到一半的初始化对象)。独立的任何内存模型的基本CPU使用!据埃里克利珀至少介绍构造后membarrier,否认优化,但是这并不规范所要求,我不会对ARM发生例如同样的事情算..英特尔的CLR
VOO

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;

这有什么不同吗?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

这是通过不允许对内存访问进行重新排序来避免内存障碍的:

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 2014年

1
@MichaelGG:在您描述的情况下,如果该类将支持多个线程来访问它,那么这是一个问题。如果构造函数调用被抖动内联,则CPU可能以存储的引用指向未完全初始化的实例的方式对指令重新排序。这不是CLR安全问题,因为可以避免,这是程序员的责任:在此类构造函数内部使用互锁,内存屏障,锁和/或易失字段。
Miguel Angelo

2
ctor内部的障碍无法解决。如果CLR在ctor完成之前将引用分配给新分配的对象,并且没有插入成员,则另一个线程可以在半初始化对象上执行实例方法。
MichaelGG 2014年

这是ReSharper 2016/2017在C#中使用DCL时建议的“替代模式”。OTOH,Java的确实保证结果new完全初始化..
user2864740

我知道MS .net的实现在构造函数的末尾放置了一个内存屏障……但是它比后悔更安全。
米格尔·安杰洛

7

我认为没有人真正回答过这个问题,因此我将尝试一下。

volatile和第一个if (instance == null)不是“必需的”。锁将使该代码成为线程安全的。

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

原因大概是避免不必要地执行代码的锁定部分。当您在锁中执行代码时,试图执行该代码的任何其他线程也会被阻止,如果您尝试从多个线程中频繁访问单例,则会降低程序的速度。根据语言/平台的不同,您还希望避免锁本身产生额外的开销。

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

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

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

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

现在,这真的有用吗?

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

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

我只能想到无法进行缓存的唯一情况是,当您有大量线程时(例如,使用新线程来处理每个请求的服务器可能会创建数百万个运行时间非常短的线程,每个线程则必须调用一次Singleton.Instance)。

因此,我怀疑双重检查锁定是一种在非常关键的性能至关重要的情况下确实存在的机制,然后每个人都攀登了“这是做到这一点的正确方法”的潮流,而没有真正考虑它的作用和是否在他们使用它的情况下实际上将是必要的。


6
这是在错与错之间。volatile与双重检查锁定中的锁定语义无关,它与内存模型和缓存一致性相关。其目的是确保一个线程不会收到仍由另一个线程初始化的值,而这种双重检查锁定模式并不能固有地防止这种情况发生。在Java中,您肯定需要volatile关键字;在.NET中,它很模糊,因为ECMA认为这是错误的,而运行时却是正确的。无论哪种方式,lock绝对都不会照顾它。
2011年

??我看不到您的陈述与我所说的不一致,也没有说过volatile与锁定语义有关。
詹森·威廉姆斯

6
您的答案与该线程中的其他几条语句一样,都声称lock会使代码安全。该部分是正确的,但仔细检查锁定模式可能使其不安全那就是你似乎所缺少的。这个答案似乎是关于双重检查锁的含义和目的,而不是解决造成该问题的线程安全性问题volatile
亚伦诺特,2011年

1
如果instance标有,如何使它不安全volatile
UserControl 2013年

5

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

大多数人都将本文作为您不需要挥发的证明:https : //msdn.microsoft.com/zh-cn/magazine/cc163715.aspx#S10

但是他们没有读到结尾:“最后的警告-我只是从现有处理器上观察到的行为猜测x86内存模型。因此,低锁技术也很脆弱,因为随着时间的推移,硬件和编译器会变得更具攻击性这里有一些使这种脆弱性对代码的影响最小化的策略:首先,尽可能避免使用低锁技术(...)最后,假设最弱的内存模型,使用易失性声明而不是依赖隐式保证。”

如果您需要更多说服力,请阅读ECMA规范上的本文,该文章将用于其他平台:msdn.microsoft.com/zh-cn/magazine/jj863136.aspx

如果您需要进一步的说服力,请阅读此较新的文章,其中可能进行了优化,以防止其无法正常工作:msdn.microsoft.com/zh-cn/magazine/jj883956.aspx

总而言之,它暂时可以为您工作而无需使用volatile,但不要偶然编写适当的代码并使用volatile或volatileread / write方法。建议采取其他方式的文章有时会排除一些可能会影响您的代码的JIT /编译器优化的潜在风险,以及将来可能会破坏您的代码的优化。另外,正如上一篇文章中提到的假设一样,先前关于不使用volatile的假设可能已经不适用于ARM。


1
好答案。关于这个问题的唯一正确答案是简单的“否”。据此,公认的答案是错误的。
丹尼斯·卡塞尔

3

AFAIK(并且-小心一点,我并没有做很多并行的事情)。该锁仅使您可以在多个竞争程序(线程)之间进行同步。

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

请参阅http://msdn.microsoft.com/zh-cn/library/ms998558.aspx,并注意以下引号:

同样,该变量声明为易失性的,以确保可以在访问实例变量之前完成对实例变量的分配。

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


2
“锁”还提供了与易失性相同(或优于易失性)的存储屏障。
Henk Holterman

2

我想我已经找到了想要的东西。本文中有详细信息-http: //msdn.microsoft.com/zh-cn/magazine/cc163715.aspx#S10

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


1
在该文章的最底部,请仔细阅读,尤其是最后一句话,作者指出:“最后的警告:我只是从观察到的现有处理器行为上猜测x86内存模型。因此,低锁技术也很脆弱,因为随着时间的流逝,硬件和编译器会变得更具攻击性。以下是一些策略,可最大程度地减少这种脆弱性对代码的影响:首先,尽可能避免使用低锁技术。(...)最后,假设可能的最弱内存模型使用易失性声明,而不是依赖隐式保证。”
user2685937 '10 / 10/11

1
如果您需要更多说服力,请阅读将ECMA规范用于其他平台的这篇文章:msdn.microsoft.com/en-us/magazine/jj863136.aspx如果您需要进一步说服力,请阅读此较新的文章,其中可能包含优化内容阻止其在没有volatile的情况下运行:msdn.microsoft.com/en-us/magazine/jj883956.aspx总之,它暂时“可以”为您工作而没有volatile,但是不要碰巧它编写正确的代码并且可以使用volatile或volatile读/写方法。
user2685937 '10 / 10/11

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

乔恩·斯凯特(Jon Skeet)在他的文章(yoda.arachsys.com/csharp/singleton.html)中说,在这种情况下,需要使用volatile来实现适当的内存屏障。马克,您对此有何评论?
康斯坦丁2009年

啊,我没注意到双重检查的锁。简单地:不要那样做;-p
马克·

实际上,我认为双重检查锁在性能方面是一件好事。另外,如果有必要使字段可变,而在锁内访问该字段,则双锁锁并不比任何其他锁差很多……
Konstantin

但这是否像乔恩提到的单独的课堂方法一样好?
Marc Gravell

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.