在深度不可变类型上进行延迟初始化是否需要锁定?

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

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

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

据我所知:

 m_PropName = temp; 

……是线程安全的。 我并不担心两个线程同时竞争初始化,因为它很少见,从逻辑角度来看两个结果都是相同的,如果我没有,我宁愿不使用锁至。

这会有用吗? 优缺点都有什么?

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

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

编辑:所以我决定使用由Joe Duffy撰写的这篇文章启发的Interlocked CompareExchange方法。

基本上:

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

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

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

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

Jon Skeet有一个关于在C#中实现singeltons的很好的页面 。

关于像这样的小优化的一般建议不是这样做,除非探查器告诉你这个代码是一个热点。 此外,您应该警惕编写大多数程序员无法完全理解的代码而不检查规范。

编辑:正如评论中所指出的那样,即使你说你不介意创建对象的2个版本,这种情况也是如此违反直觉,以至于永远不应该使用这种方法。

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

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

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

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

我有兴趣听到其他答案,但我没有看到它的问题。 副本将被放弃并获得GCed。

你需要让字段变得volatile

关于这个:

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

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

但是,语言/运行时并不能确保其他线程无法看到部分构造的对象 – 它取决于构造函数的作用 。

更新:

OP还想知道这个页面是否有一个有用的想法 。 他们的最终代码片段是Double checked locking的一个实例,这是一个经典的例子,成千上万的人建议彼此不知道如何做对。 问题是SMP机器由几个带有自己的内存缓存的CPU组成。 如果每次有内存更新时都必须同步它们的缓存,这将取消拥有多个CPU的好处。 因此,它们仅在“内​​存屏障”处同步,这发生在取出锁定或发生互锁操作或访问volatile变量时。

通常的事件顺序是:

  • 编码器发现双重检查锁定
  • 编码器发现了内存障碍

在这两个事件之间,他们发布了许多破碎的软件。

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

总结:线程代码看起来比它更容易编写1000 x。

当数据可能无法始终被访问时,我全都是懒惰的初始化,并且可能需要大量资源来获取或存储数据。

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

从你的代码显示的内容来看,lazy init都在一个实例属性中,所以我不会给它添加锁。 如果按照设计,它意味着同时由多个线程访问,那么继续添加锁。

顺便说一句,它可能不会减少代码,但我是null-coalesce运算符的粉丝。 吸气剂的主体可能会变成这样:

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

它摆脱了额外的"if (m_PropName == null) ..."并且在我看来它使它更简洁和可读。

我不是C#专家,但据我所知,如果你只需要创建一个ReadOnlyCollection实例,这只会造成问题。 你说创建的对象将始终是相同的,如果两个(或更多)线程确实创建了一个新实例并不重要,所以我想说没有锁就可以做到这一点。

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

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

如果字段只是空白或者已经包含要写入的值,或者在某些情况下是等效字段,则可以安全地使用延迟初始化而不使用锁定。 请注意,没有两个可变对象是等价的; 保存对可变对象的引用的字段只能写入对同一对象的引用(意味着写入不起作用)。

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

  1. 如果计算要写入的值会很昂贵,请使用锁定,并且希望避免不必要地花费这些努力。 双重检查锁定模式适用于内存模型支持的系统。
  2. 如果存储不可变值,则在必要时进行计算,然后将其存储起来。 其他没有看到商店的线程可能会执行冗余计算,但他们只会尝试使用已存在的值来编写字段。
  3. 如果存储对廉价生成可变类对象的引用,则在需要时创建一个新对象,然后如果该字段仍为空,则使用`Interlocked.CompareExchange`来存储它。

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

这绝对是个问题。

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

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