多个文本说,当在.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)”完成必要的内存一致性?在“lock”语句之后,读取和写入都是不稳定的,因此必须实现必要的一致性,这不是真的吗?
易失性是不必要的。好吧,有点**
volatile
用于在变量的读写之间创建内存屏障*。
lock
在使用时会导致在lock
内的块周围创建内存屏障,此外还限制对一个线程的块访问。
内存障碍使得每个线程都读取变量的最新值(不是某个寄存器中缓存的本地值),并且编译器不会重新排序语句。使用volatile
是不必要的**因为你已经锁定了。
Joseph Albahari比以往任何时候都更好地解释了这个问题。
一定要在C#中查看Jon Skeet的guide to implementing the singleton
更新:
* volatile
导致变量的读取为VolatileRead
s并且写为VolatileWrite
s,其在CLR上的x86和x64上,用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's article(一切都链接到这个,它谈到双重检查锁定) chris brumme's article(一切都链接到此) Joe Duffy: Broken Variants of Double Checked Locking
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
有一种方法可以在没有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;
这有什么不同吗?如果您想到单个线程,请不要。如果您想到多个线程,则为YES ...如果线程在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;
快乐的编码!
我认为没有人真正回答过这个问题,所以我会试一试。
挥发性和第一个if (instance == null)
不是“必要的”。锁将使此代码线程安全。
所以问题是:你为什么要添加第一个if (instance == null)
?
原因可能是避免不必要地执行锁定的代码段。当您在锁内执行代码时,任何其他尝试执行该代码的线程都会被阻止,如果您尝试从多个线程中频繁访问该单例,这将减慢您的程序速度。根据语言/平台的不同,锁定本身也可能存在您希望避免的开销。
因此,添加第一个空检查是一种非常快速的方法,可以查看是否需要锁定。如果您不需要创建单例,则可以完全避免锁定。
但是你无法检查引用是否为null而不以某种方式锁定它,因为由于处理器缓存,另一个线程可能会更改它,你会读到一个“陈旧”值,导致你不必要地进入锁。但你正试图避免锁定!
因此,您可以使单例易失性,以确保您读取最新值,而无需使用锁定。
您仍然需要内部锁定,因为volatile仅在单次访问变量时保护您 - 您无法在不使用锁定的情况下安全地进行测试和设置。
现在,这实际上有用吗?
好吧,我会说“在大多数情况下,没有”。
如果Singleton.Instance可能因锁而导致效率低下,那么为什么要频繁调用它以至于这将是一个重大问题呢?单例的重点是只有一个,所以你的代码可以读取和缓存单例引用一次。
我能想到这种缓存不可能的唯一情况是当你拥有大量线程时(例如,使用新线程来处理每个请求的服务器可能会创建数百万个非常短的运行线程,每个线程都是这将不得不调用Singleton.Instance一次)。
因此,我怀疑双重检查锁定是一种在非常具体的性能关键情况下具有真实位置的机制,然后每个人都已经抓住了“这是正确的方式来做到这一点”并没有真正想到它做什么以及是否在他们使用它的情况下实际上是必要的。
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的描述
你应该使用带有双重检查锁定模式的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或volatileread / write方法。建议不这样做的文章有时会遗漏可能影响代码的JIT /编译器优化的一些可能风险,以及可能会破坏您的代码的未来优化。同样如上一篇文章中提到的假设,以前没有使用volatile的工作假设已经不适用于ARM。
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) { ... } } }
我想我找到了我想要的东西。详细信息在这篇文章中 - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10。
总结一下 - 在这种情况下确实不需要.NET volatile修饰符。但是,在较弱的内存模型中,在写入字段后,在延迟启动的对象的构造函数中进行的写入可能会延迟,因此其他线程可能会在第一个if语句中读取损坏的非null实例。
关于使用带双重检查锁定的volatile,这是一个非常好的帖子:
http://tech.puredanger.com/2007/06/15/double-checked-locking/
在Java中,如果目标是保护变量,如果它被标记为volatile,则不需要锁定