对由互锁.比较交换延迟初始化的字段执行常规读取是否正确?

本文关键字:读取 常规 执行 是否 字段 比较 交换 初始化 延迟 | 更新日期: 2023-09-27 17:56:11

假设您有一个要延迟初始化的属性public Foo Bar { get; }。一种这样的方法可能是使用 Interlocked 类,它保证某些操作序列(例如递增、添加、比较交换)的原子性。你可以这样做:

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;
    }
}

有很多地方演示了这种延迟初始化方法,例如这个博客和.NET Framework本身。

我的问题是,从_bar读取不应该是不稳定的吗?例如,线程 1 本可以调用 CompareExchange ,设置 _bar 的值,但该更改对线程 2 不可见,因为(如果我正确理解这个问题)它可能已将 _bar 的值缓存为 null,并且它最终可能会再次调用Interlocked.CompareExchange,尽管线程 1 已经设置了_bar。那么,_bar不应该被标记为不稳定,以防止这种情况发生吗?

简而言之,这种方法或这种延迟初始化方法是否正确?为什么在一种情况下使用 Volatile.Read(与标记变量 volatile 并从中读取具有相同的效果),而在另一种情况下不使用?

编辑 TL;DR:如果一个线程通过 Interlocked.CompareExchange 更新字段的值,该更新的值是否会立即对执行该字段的非易失性读取的其他线程可见?

对由互锁.比较交换延迟初始化的字段执行常规读取是否正确?

我的第一个想法是"谁在乎? :)

我的意思是:双重检查初始化模式几乎总是矫枉过正,很容易出错。大多数情况下,一个简单的lock是最好的:它易于编写,性能足够高,并且清楚地表达了代码的意图。此外,我们现在有了Lazy<T>类来抽象惰性初始化,进一步消除了我们手动实现代码来执行此操作的需要。

因此,双重检查模式的细节并不是那么重要,因为我们无论如何都不应该使用它。

也就是说,我同意你的看法,即阅读应该是一个不稳定的阅读。没有这一点,Interlocked.CompareExchange()提供的内存屏障不一定有帮助。

不过,这可以通过两件事来缓解:

  1. 无论如何,不能保证双重检查模式。即使您有一个易失性读取,也存在争用条件,因此初始化两次必须是安全的。因此,即使内存位置过时,也不会发生任何真正糟糕的事情。您将调用 Foo 构造函数两次,这不是很好,但它不会是一个致命的问题,因为无论如何都可能发生。
  2. 在 x86 上,内存访问默认为易失性。因此,无论如何,这实际上只有在其他平台上才会成为一个问题。

在这种特殊情况下,执行非易失性读取不会使代码不正确,因为即使第二个线程错过了_bar更新,它也会在CompareExchange上观察到它。易失性读取(可能)允许更早地看到更新的值,而无需执行重量级CompareExchange

在其他情况下,通过InterlockedVolatile.Write lock或区域内写入的内存位置必须被读取为易失性。