对深度不可变类型进行延迟初始化是否需要锁?
-
19-08-2019 - |
题
如果我有一个深度不可变的类型(所有成员都是只读的,如果它们是引用类型成员,那么它们也引用深度不可变的对象)。
我想在类型上实现一个延迟初始化的属性,如下所示:
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;
...是线程安全的。我不太担心两个线程同时竞争初始化,因为这种情况很少见,从逻辑角度来看,两个结果都是相同的,如果没有锁,我宁愿不使用锁到。
这行得通吗?优缺点都有什么?
编辑:感谢您的回答。我可能会继续使用锁。然而,令我惊讶的是没有人提出编译器意识到 temp 变量是不必要的,并且直接分配给 m_PropName 的可能性。如果是这种情况,那么读取线程可能会读取尚未完成构造的对象。编译器会阻止这种情况吗?
(答案似乎表明运行时不允许这种情况发生。)
编辑:因此,我决定采用 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 应该被声明为易失性的。
其他提示
你应该使用锁。否则你将面临两种情况的风险 m_PropName
存在并被不同线程使用。在许多情况下这可能不是问题;但是,如果您希望能够使用 ==
代替 .equals()
那么这就会成为一个问题。罕见的竞争条件并不是更好的错误。它们很难调试和重现。
在您的代码中,如果两个不同的线程同时获取您的属性 PropName
(例如,在多核 CPU 上),然后它们可以接收属性的不同新实例,这些实例将包含相同的数据,但不是相同的对象实例。
不可变对象的一个主要好处是 ==
相当于 .equals()
, ,允许使用性能更高的 ==
进行比较。如果您不在延迟初始化中进行同步,那么您将面临失去此优势的风险。
你也会失去不变性。您的对象将使用不同的对象(包含相同的值)初始化两次,因此已经获取属性值但再次获取它的线程可能会第二次收到不同的对象。
我很想听听其他答案,但我认为这没有问题。重复的副本将被放弃并被GCed。
你需要创建一个字段 volatile
尽管。
关于这一点:
但是,我很惊讶没有人提出编译器意识到温度变量是不必要的,而只是直接分配给m_propname的可能性。如果真是这样,那么读取线程可能会读取尚未完成的对象。编译器会阻止这种情况吗?
我考虑过提及它,但没有什么区别。在构造函数完成之前,new 运算符不会返回引用(因此不会发生对该字段的赋值) - 这是由运行时而不是编译器保证的。
然而,语言/运行时并不能真正保证其他线程不能看到部分构造的对象 - 这取决于构造函数做什么.
更新:
OP 还想知道是否 这个页面有一个有用的想法. 。他们的最终代码片段是一个实例 双重检查锁定 这是一个典型的例子,成千上万的人互相推荐一个想法,但不知道如何正确实施。问题是 SMP 机器由多个具有自己的内存缓存的 CPU 组成。如果每次内存更新时他们都必须同步缓存,这就会抵消拥有多个 CPU 的好处。因此,它们仅在“内存屏障”处进行同步,这种情况在锁被取出、发生互锁操作或发生异常时发生。 volatile
变量被访问。
通常的事件顺序是:
- 编码员发现双重检查锁定
- 编码员发现内存障碍
在这两次事件之间,他们发布了很多损坏的软件。
此外,许多人相信(正如那个人所做的那样)您可以通过使用互锁操作来“消除锁定”。但在运行时它们是内存屏障,因此它们会导致所有 CPU 停止并同步其缓存。它们比锁有一个优势,因为它们不需要调用操作系统内核(它们只是“用户代码”),但是 它们会像任何同步技术一样降低性能.
概括:线程代码看起来比实际编写起来容易 1000 倍。
当数据可能并不总是被访问并且可能需要大量资源来获取或存储数据时,我完全支持惰性初始化。
我认为这里有一个关键概念被遗忘了:根据 C# 的设计理念, 默认情况下,您不应该使实例成员成为线程安全的。 默认情况下,只有静态成员才应该是线程安全的。除非您要访问某些静态/全局数据,否则不应在代码中添加额外的锁。
从您的代码显示的情况来看,惰性初始化全部位于实例属性内,因此我不会向其添加锁。如果按照设计,它应该由多个线程同时访问,那么请继续添加锁。
顺便说一句,它可能不会减少太多代码,但我很喜欢空合并运算符。getter 的主体可以变成这样:
m_PropName = m_PropName ?? new ...();
return m_PropName;
它摆脱了多余的 "if (m_PropName == null) ..."
在我看来,它变得更加简洁和可读。
我不是C#专家,但据我所知,如果你需要创建只有一个ReadOnlyCollection的实例这只是提出了一个问题。你说,创建的对象将永远是相同的,如果两个(或更多)的线程做创建一个新的实例也没关系,所以我会说这是确定这样做没有锁。
一两件事,有可能成为一个奇怪的错误以后会是如果将比较的情况下,这有时会不一样的平等。但是,如果你记住这一点(或只是不这样做),我看不出其他问题。
不幸的是,你需要一个锁。有很多很微妙的错误,当你不正确地锁定的。对于一个艰巨的例子来看一下这个答案一>
如果该字段仅在为空或已包含其中之一时才会被写入,则可以安全地使用不带锁的延迟初始化 要写入的值 或者,在某些情况下, 相等的. 。请注意,没有两个可变对象是等效的;保存对可变对象的引用的字段只能通过对 同一个物体 (意味着写入不会产生任何效果)。
根据具体情况,可以使用三种通用模式进行延迟初始化:
- 如果计算要写入的值的成本很高,并且希望避免不必要地花费这种精力,请使用锁。双重检查锁定模式适用于内存模型支持的系统。
- 如果要存储一个不可变的值,则在必要时计算它,然后存储它。其他看不到存储的线程可能会执行冗余计算,但它们只会尝试使用已经存在的值写入字段。
- 如果要存储对生产成本低廉的可变类对象的引用,则在必要时创建一个新对象,然后在该字段仍为空时使用“Interlocked.CompareExchange”来存储它。
请注意,如果可以避免锁定线程中除第一个访问之外的任何访问,则使惰性读取器线程安全不应造成任何显着的性能成本。虽然可变类通常不是线程安全的,但所有声称不可变的类对于任何读取器操作组合都应该是 100% 线程安全的。任何不能满足此类线程安全要求的类都不应该声称是不可变的。
这无疑是一个问题。
考虑这种情况:线程“A”访问属性,和收集被初始化。之前,本地实例分配给该字段“m_PropName”线程“B”访问属性,除非它得到完成。线程“B”现在已经到该实例,这是目前存储在“m_PropName” ......直到线程“A”继续,此时“m_PropName”是由在该线程本地实例覆盖的参考。
现在有几个问题。首先,线程“B”没有正确的实例了,因为所属对象认为“m_PropName”是唯一的例子,但它泄露了一个初始化实例时线程“B”线程“A”前完成。另一种是,如果集合时,线程“A”之间变化和线程“B”得到了他们的情况。然后你有不正确的数据。它甚至可能会更糟,如果你观察或内部修改只读集合(当然,你可以它,不与ReadOnlyCollection,却苦于如果你与其他一些实施方案替换它,你可以通过事件观察或内部修改,但未在外部)。