内存模型、内存屏障和 .NET 中的单例模式






3.88/5 (10投票s)
解释内存模型和实现单例模式的方法。
引言
本文解释了每个在多处理器架构上工作的开发人员都应该理解的一些概念。对于那些在 IA64(弱内存模型)上工作或使用多核编程语言(任务并行库)进行编码的人来说,这些概念应该很清楚。
概念
重排序
编译器或处理器可以优化代码段,并以与预期不同的顺序重新排列它们。例如,对于给定的语句序列
x=a;
y=1;
上述语句可能会以以下顺序执行:
y=1;
x=a;
这在单核处理器上不会引起问题,但在多核/多处理器上可能会出现问题,尤其是在第二个线程需要代码按特定顺序执行的情况下。除非我们使用某种内存屏障,否则开发人员不应假定代码将按编码的相同顺序执行。
内存屏障或栅栏
内存屏障基本上意味着保证刷新并防止重排序,例如,如果我们要在上述两条语句之间引入一个屏障……
x=a;
Barrier (Thread.MemoryBarrier() API in .NET)
y=1;
……那么屏障上方或下方的任何语句都无法越过屏障。换句话说,可以保证屏障之前的任何语句都将保留在屏障之前,同样,屏障之后的语句也将保留在屏障之后,因此不会发生重排序。
内存屏障 API (Thread.MemoryBarrier()
) 默认具有完整的栅栏,即 发布和获取语义,如果您只想使用发布或获取,可以在 .NET 中使用 Thread.VolatileRead
或 Thread.VolatileWrite
API。
全栅栏内存屏障可确保值被刷新到所有 CPU 缓存。
缓存一致性
x86 和 x64 具有(更)强的内存模型,对缓存的任何更新/写入都会使其他可用核心/处理器中的重复缓存实例失效;因此,在 x86 和 x64 机器上,`volatile` 关键字是多余的(除非您想避免重排序,请参见下面的 `volatile` 说明)。
在 IA64 上,写入操作的缓存一致性不是自动的,因此需要显式的内存屏障才能将写入刷新到其他核心缓存(这就是 `volatile` 发挥作用的地方)。
注意: IA64 的 .NET 2.0 内存模型对写入操作具有发布语义,但这不符合 CLI 标准,因为 ECMA 规范中并未提及,所以我不敢确定 Mono 等是否实现了相同的语义(也许有人可以确认这一点?)。如果您想将代码移植到不同的内存模型实现,请使用内存屏障以确保安全。
Volatile 和锁
MSDN 上 `volatile` 关键字的定义并不完全正确,`volatile`(以及锁)是使用内存屏障实现的,因此它不仅使用发布和获取语义(请参阅 http://msdn.microsoft.com/en-us/library/aa490209.aspx),它还不允许重排序。声明为 `volatile` 的对象经过优化,对读取操作使用读取屏障,对写入操作使用写入屏障。
商店
下面是一个存储示例,其中值 5 被赋给变量或占位符 "x"。
x = 5;
加载和存储
lock(syncroot){x = new someObject();}
这里创建了 `someObject` 并将其赋给 `x`。当 `x` 被并发访问时,这可能会(尽管可能性非常小)导致问题。这是因为在写入操作可能延迟的竞态条件下,`x` 可能在未初始化的情况下被访问。(有关更多解释,请参见 此处)。要解决此问题,请在弱内存模型上的赋值语句之后使用 `memorybarrier`(有关更多解释,请参见存储重排序)。
lock(syncroot){x = new someObject(); Thread.MemoryBarrier();}
存储重排序
这可以用一个简单的单例实现来解释:
private Singleton() {}
public Instance {
get {
if (!initialized) {
lock (slock) {
if (!initialized) {
instance = new Singleton();
initialized = true;
}
}
}
return instance;
}
在 x86 和 x64 上,写入操作不会重排序,即“initialize=true
”不可能在“instance = new Singleton()
”语句之前发生,如果发生这种情况,则可能存在竞态条件,并且其他线程可能会看到“initialized
”设置为 true
并访问未初始化的“instance
”对象。
在 IA64 MM 实现中,写入操作可能会被重排序,因此 intialized=true
可能在“instance
”初始化语句之前执行。要解决此问题,我们只需要一个内存屏障来防止重排序。
instance = new Singleton();
Thread.MemoryBarrier();
initialized = true;
在上面的加载和存储示例中,其他 CPU 线程可能看到未初始化的 x
(这可能是由于上面解释的 此处 和 此处 中解释的内存模型实现损坏)。
因此,如果存储顺序很重要,或者您不确定底层内存模型 ,请使用内存屏障(最好是 `volatile` 写入),在赋值操作之后同步该值与其他 CPU。
加载重排序
正如 Joe Duffy 所解释的,在罕见情况下(至少理论上),挂起的写入可能不会被其他处理器看到,在它对其他处理器可用之前可能会有延迟(这可能是在重负载下发生的???),因此由于这种副作用(缓存一致性并非 100% 准确,可能涉及竞态条件),加载也可能会被重排序。因此,如果您真的想确保万无一失,请使用内存屏障或 VolatileWrite
/VolatileRead
在继续操作之前将数据刷新到其他缓存。
单例模式
著名的双重检查锁定技术
if (instance == null)
{
lock (object)
{
if (instance == null)
{
instance = new Singleton();
return instance;
如上所述,上述代码在 IA64 机器上可能无法按预期工作,因为 IA64 上的存储/写入操作没有隐式的发布语义。其他 CPU 可以看到“instance
”不等于 null
,但其中可能包含垃圾值,因为存储尚未刷新到 CPU 的缓存。(在 .NET 2.0 内存模型中,上述说法不成立,因为写入操作具有发布语义,但这不符合 CLI 标准,因此如果您使用 Mono/Rotor 等,请注意并使用显式的发布语义。)
我们可以通过将“instance
”的类型设置为“volatile
”来修复上述问题,但这样可能会有性能损失,因为每次访问“instance
”变量时都会进行一次不必要的、昂贵的 `volatile` 读取。我们只需要 `volatile` 声明是为了“存储”操作,以便它可以将值刷新到所有缓存并防止重排序。
一种修复方法是在加载和存储赋值之后立即添加内存屏障:
if (instance == null)
{
lock (object)
{
if (instance == null)
{
instance = new Singleton();
Thread.MemoryBarrier();
return instance;
再次说明,完整的栅栏内存屏障同时具有读取和写入语义,通过使用 Interlocked
API 进行延迟加载实例化,可以进一步优化(有关更多详细信息,请参阅 Joe 的 博客)。.NET 4 中的新 LazyInit
类使用了类似的实现:
public T Value {
get {
if (m_value == null) {
T newValue = m_init();
if (Interlocked.CompareExchange(ref m_value, newValue, null) != null &&
newValue is IDisposable) {
((IDisposable)newValue).Dispose();
}
}
return m_value;
}
}
Interlocked
API 是原子操作,使用内存屏障实现,从而达到最佳性能(锁比 Interlocked
或内存屏障调用慢)。Interlocked.CompareExchange
要快得多,因为它直接转换为系统调用,而锁的成本很高(如果我的理解正确,它需要内核模式转换)。使用 interlocked
API,还实现了 Spinlocks
。
如果您对内存模型仍有疑问,请阅读 此文。
参考文献
- 书籍:Concurrent Programming On Windows
- 博客:http://www.bluebytesoftware.com/blog/CommentView,guid,8bb27ef2-d53a-4e9f-b4e4-cc1607d06f37.aspx
- http://blogs.msdn.com/cbrumme/archive/2003/05/17/51445.aspx