11

如果我有一个非常不可变的类型(所有成员都是只读的,如果它们是引用类型成员,那么它们也指的是非常不可变的对象)。

我想在类型上实现一个惰性初始化属性,如下所示:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            m_PropName = temp;
        }
        return m_PropName;
    }
}

据我所知:

m_PropName = temp; 

...是线程安全的。我不太担心两个线程都竞相同时初始化,因为这种情况很少见,从逻辑角度来看,两个结果都是相同的,如果我没有,我宁愿不使用锁到。

这行得通吗?优缺点都有什么?

编辑: 感谢您的回答。我可能会继续使用锁。然而,令我惊讶的是,没有人提出编译器意识到临时变量是不必要的,只是直接分配给 m_PropName 的可能性。如果是这种情况,那么读取线程可能会读取尚未完成构造的对象。编译器会阻止这种情况吗?

(答案似乎表明运行时不允许这种情况发生。)

编辑: 所以我决定采用受 Joe Duffy 的这篇文章启发的 Interlocked CompareExchange 方法。

基本上:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            System.Threading.Interlocked(ref m_PropName, temp, null);
        }
        return m_PropName;
    }
}

这应该确保在此对象实例上调用此方法的所有线程都将获得对同一对象的引用,因此 == 运算符将起作用。可能会浪费工作,这很好 - 它只是使它成为一个乐观的算法。

正如下面的一些评论所指出的,这取决于 .NET 2.0 内存模型的工作。否则,应将 m_PropName 声明为 volatile。

4

8 回答 8

6

那可行。如规范5.5 节所述,在 C# 中写入引用保证是原子的。这可能仍然不是一个好方法,因为您的代码在调试和阅读时会更加混乱,以换取对性能的影响可能很小。

Jon Skeet 有一个关于在 C# 中实现单例的精彩页面

关于此类小型优化的一般建议是不要执行它们,除非分析器告诉您此代码是热点。此外,如果不检查规范,您应该警惕大多数程序员无法完全理解的代码。

编辑:正如评论中所指出的,即使您说您不介意创建对象的 2 个版本,但这种情况非常违反直觉,因此永远不应该使用这种方法。

于 2009-03-16T21:33:57.310 回答
5

我很想听听对此的其他答案,但我认为这没有问题。重复的副本将被放弃并获得 GCed。

不过,您需要制作该字段volatile

关于这一点:

然而,令我惊讶的是,没有人提出编译器意识到临时变量是不必要的,只是直接分配给 m_PropName 的可能性。如果是这种情况,那么读取线程可能会读取尚未完成构造的对象。编译器会阻止这种情况吗?

我考虑过提及它,但没有任何区别。在构造函数完成之前,new 运算符不会返回引用(因此不会发生对字段的赋值)——这是由运行时保证的,而不是编译器。

但是,语言/运行时并不能真正保证其他线程看不到部分构造的对象——这取决于构造函数的作用

更新:

OP 还想知道这个页面是否有一个有用的想法。他们最终的代码片段是双重检查锁定的一个实例,这是一个经典的例子,成千上万的人互相推荐,却不知道如何正确地做到这一点。问题是 SMP 机器由多个具有自己内存缓存的 CPU 组成。如果他们必须在每次内存更新时同步缓存,这将抵消拥有多个 CPU 的好处。因此它们只在“内存屏障”处同步,这发生在取出锁、发生互锁操作或volatile访问变量时。

通常的事件顺序是:

  • Coder 发现双重检查锁定
  • Coder 发现内存障碍

在这两个事件之间,他们发布了很多损坏的软件。

此外,许多人相信(就像那个人那样)您可以通过使用互锁操作来“消除锁定”。但是在运行时它们是一个内存屏障,因此它们会导致所有 CPU 停止并同步它们的缓存。与锁相比,它们的优势在于它们不需要调用操作系统内核(它们只是“用户代码”),但它们可以像任何同步技术一样降低性能

摘要:线程代码看起来比实际编写要容易 1000 倍。

于 2009-03-16T21:26:29.293 回答
5

你应该使用锁。否则,您将面临两个m_PropName现有实例和不同线程正在使用的风险。在许多情况下,这可能不是问题;但是,如果您希望能够使用==而不是.equals()那么这将是一个问题。罕见的竞争条件并不是更好的错误。它们很难调试和重现。

在您的代码中,如果两个不同的线程同时获取您的属性PropName(例如,在多核 CPU 上),那么它们可以接收将包含相同数据但不是相同对象实例的属性的不同新实例。

不可变对象的一个​​关键好处==是等同于.equals(),允许使用性能更高的对象==进行比较。如果您不在延迟初始化中进行同步,那么您可能会失去这个好处。

你也失去了不变性。您的对象将使用不同的对象(包含相同的值)初始化两次,因此已经获得您的属性值但再次获得它的线程可能会第二次收到不同的对象。

于 2009-03-16T21:36:09.067 回答
1

当数据可能并不总是被访问并且可能需要大量资源来获取或存储数据时,我完全支持惰性初始化。

我认为这里忘记了一个关键概念:根据 C# 设计概念,默认情况下不应使实例成员线程安全。 默认情况下,只有静态成员应该是线程安全的。除非您正在访问一些静态/全局数据,否则不应在代码中添加额外的锁。

从您的代码显示的内容来看,惰性初始化都在实例属性中,因此我不会为其添加锁。如果按照设计,它应该被多个线程同时访问,那么继续添加锁。

顺便说一句,它可能不会减少多少代码,但我是 null-coalesce 运算符的粉丝。你的 getter 的主体可以变成这样: 它摆脱了额外的东西,在我看来使它更简洁和可读。

m_PropName = m_PropName ?? new ...();
return m_PropName;


"if (m_PropName == null) ..."

于 2009-03-18T03:41:05.667 回答
0

我不是 C# 专家,但据我所知,如果您要求只创建一个 ReadOnlyCollection 实例,这只会造成问题。您说创建的对象将始终是相同的,两个(或更多)线程是否确实创建了一个新实例并不重要,所以我会说可以在没有锁的情况下执行此操作。

稍后可能会成为一个奇怪的错误的一件事是,如果要比较实例的相等性,这有时会不一样。但是,如果您牢记这一点(或者只是不这样做),我认为没有其他问题。

于 2009-03-16T21:29:32.563 回答
0

不幸的是,你需要一把锁。当您没有正确锁定时,会有很多非常微妙的错误。对于一个令人生畏的例子,看看这个答案

于 2009-03-16T22:02:56.447 回答
0

如果仅在字段为空白或已包含要写入的值或在某些情况下等效的. 请注意,没有两个可变对象是等价的;包含对可变对象的引用的字段只能与对同一对象的引用一起写入(这意味着写入无效)。

根据情况,可以使用三种通用模式进行延迟初始化:

  1. 如果计算要写入的值会很昂贵,并且希望避免不必要地花费这种努力,请使用锁。双重检查锁定模式在内存模型支持它的系统上很好。
  2. 如果一个人正在存储一个不可变的值,如果它看起来有必要就计算它,然后存储它。其他看不到存储的线程可能会执行冗余计算,但它们会简单地尝试使用已经存在的值写入字段。
  3. 如果要存储对生产成本低廉的可变类对象的引用,则在必要时创建一个新对象,然后在该字段仍为空白时使用“Interlocked.CompareExchange”来存储它。

请注意,如果可以避免锁定线程中除第一个访问之外的任何访问,则使惰性读取器线程安全不应带来任何显着的性能成本。虽然可变类通常不是线程安全的,但所有声称不可变的类对于读取器操作的任何组合都应该是 100% 线程安全的。任何不能满足这种线程安全要求的类都不应声称是不可变的。

于 2012-10-10T17:16:52.650 回答
-1

这绝对是个问题。

考虑这种情况:线程“A”访问属性,并且集合被初始化。在将本地实例分配给字段“m_PropName”之前,线程“B”访问该属性,除非它完成。线程“B”现在具有对该实例的引用,该实例当前存储在“m_PropName”中......直到线程“A”继续,此时“m_PropName”被该线程中的本地实例覆盖。

现在有几个问题。首先,线程“B”不再有正确的实例,因为拥有对象认为“m_PropName”是唯一的实例,但是当线程“B”在线程“A”之前完成时,它泄露了一个初始化的实例。另一个是如果集合在线程“A”和线程“B”获得它们的实例之间发生了变化。那么你有不正确的数据。如果您在内部观察或修改只读集合,情况可能会更糟(当然,您不能使用 ReadOnlyCollection,但如果您将其替换为可以通过事件观察或在内部修改的其他实现,则可以,但是不是外部的)。

于 2009-03-16T21:48:50.207 回答