对 Interlocked.CompareExchange 延迟初始化的字段执行常规读取是否正确?
Is it correct to perform regular reads on a field lazily-initialized by Interlocked.CompareExchange?
假设您有一个要延迟初始化的 属性 public Foo Bar { get; }
。一种这样的方法可能是使用 Interlocked
class,它保证某些操作序列(例如递增、添加、比较交换)的原子性。你可以这样做:
private Foo _bar;
public Foo Bar
{
get
{
// Initial check, necessary so we don't call new Foo() every time when invoking the method
if (_bar == null)
{
// In the unlikely case 2 threads come in here
// at the same time, new Foo() will simply be called
// twice, but only one of them will be set to _bar
Interlocked.CompareExchange(ref _bar, new Foo(), null);
}
return _bar;
}
}
有很多地方展示了这种惰性初始化方法,例如this blog and places in the .NET Framework itself.
我的问题是,_bar
的读取不应该是不稳定的吗?例如,线程 1 可以调用 CompareExchange
,设置 _bar
的值,但该更改对线程 2 不可见,因为(如果我正确理解 this question)它可能已经缓存_bar
的值为 null,它可能最终会再次调用 Interlocked.CompareExchange
,尽管事实上线程 1 已经设置了 _bar
。那么 _bar
不应该被标记为 volatile 以防止这种情况发生吗?
简而言之,this approach or this approach延迟初始化是否正确?为什么在一种情况下使用 Volatile.Read
(与将变量标记为 volatile 并从中读取具有相同的效果),而在另一种情况下不使用?
edit TL;DR: 如果一个线程通过 Interlocked.CompareExchange
更新一个字段的值,那么更新后的值是否会立即对执行非易失性的其他线程可见读取字段?
我的第一个想法是 "who cares?" :)
我的意思是:仔细检查初始化模式几乎总是矫枉过正,而且很容易出错。大多数时候,一个简单的 lock
是最好的:它易于编写,性能足够,并且清楚地表达了代码的意图。此外,我们现在有 Lazy<T>
class 来抽象惰性初始化,进一步消除了我们手动实现代码来完成它的任何需要。
因此,双重检查模式的细节并不是那么重要,因为无论如何我们都不应该使用它。
也就是说,我同意您的看法,即读取应该是易失性读取。否则,Interlocked.CompareExchange()
提供的内存屏障不一定有帮助。
尽管有两件事可以缓解这种情况:
- 无论如何都不能保证双重检查模式。即使您有易失性读取,也会存在竞争条件,因此必须安全地初始化两次。因此,即使内存位置陈旧,也不会发生真正糟糕的事情。您将调用
Foo
构造函数两次,这不是很好,但不会是致命问题,因为无论如何都可能发生。
- 在 x86 上,默认情况下内存访问是易变的。所以这实际上只是在其他平台上才成为一个问题。
在这种特殊情况下,进行非易失性读取不会使代码不正确,因为即使第二个线程错过了 _bar
的更新,它也会在 CompareExchange
上观察到它。易失性读取(可能)允许更早地查看更新值,而无需执行重量级操作 CompareExchange
.
在其他情况下,通过 Interlocked
、Volatile.Write
或 lock
区域写入的内存位置必须读取为易失性。
假设您有一个要延迟初始化的 属性 public Foo Bar { get; }
。一种这样的方法可能是使用 Interlocked
class,它保证某些操作序列(例如递增、添加、比较交换)的原子性。你可以这样做:
private Foo _bar;
public Foo Bar
{
get
{
// Initial check, necessary so we don't call new Foo() every time when invoking the method
if (_bar == null)
{
// In the unlikely case 2 threads come in here
// at the same time, new Foo() will simply be called
// twice, but only one of them will be set to _bar
Interlocked.CompareExchange(ref _bar, new Foo(), null);
}
return _bar;
}
}
有很多地方展示了这种惰性初始化方法,例如this blog and places in the .NET Framework itself.
我的问题是,_bar
的读取不应该是不稳定的吗?例如,线程 1 可以调用 CompareExchange
,设置 _bar
的值,但该更改对线程 2 不可见,因为(如果我正确理解 this question)它可能已经缓存_bar
的值为 null,它可能最终会再次调用 Interlocked.CompareExchange
,尽管事实上线程 1 已经设置了 _bar
。那么 _bar
不应该被标记为 volatile 以防止这种情况发生吗?
简而言之,this approach or this approach延迟初始化是否正确?为什么在一种情况下使用 Volatile.Read
(与将变量标记为 volatile 并从中读取具有相同的效果),而在另一种情况下不使用?
edit TL;DR: 如果一个线程通过 Interlocked.CompareExchange
更新一个字段的值,那么更新后的值是否会立即对执行非易失性的其他线程可见读取字段?
我的第一个想法是 "who cares?" :)
我的意思是:仔细检查初始化模式几乎总是矫枉过正,而且很容易出错。大多数时候,一个简单的 lock
是最好的:它易于编写,性能足够,并且清楚地表达了代码的意图。此外,我们现在有 Lazy<T>
class 来抽象惰性初始化,进一步消除了我们手动实现代码来完成它的任何需要。
因此,双重检查模式的细节并不是那么重要,因为无论如何我们都不应该使用它。
也就是说,我同意您的看法,即读取应该是易失性读取。否则,Interlocked.CompareExchange()
提供的内存屏障不一定有帮助。
尽管有两件事可以缓解这种情况:
- 无论如何都不能保证双重检查模式。即使您有易失性读取,也会存在竞争条件,因此必须安全地初始化两次。因此,即使内存位置陈旧,也不会发生真正糟糕的事情。您将调用
Foo
构造函数两次,这不是很好,但不会是致命问题,因为无论如何都可能发生。 - 在 x86 上,默认情况下内存访问是易变的。所以这实际上只是在其他平台上才成为一个问题。
在这种特殊情况下,进行非易失性读取不会使代码不正确,因为即使第二个线程错过了 _bar
的更新,它也会在 CompareExchange
上观察到它。易失性读取(可能)允许更早地查看更新值,而无需执行重量级操作 CompareExchange
.
在其他情况下,通过 Interlocked
、Volatile.Write
或 lock
区域写入的内存位置必须读取为易失性。