【问题标题】:How thread safe are immutable objects?不可变对象的线程安全性如何?
【发布时间】:2011-03-25 14:10:14
【问题描述】:

每个人都说不可变对象是线程安全的,但这是为什么呢?

以在多核 CPU 上运行以下场景为例:

  • Core 1 读取内存位置0x100 的对象,并缓存在Core 1 的L1/L2 缓存中;
  • GC 在该内存位置收集该对象,因为它已符合条件并且0x100 可用于新对象;
  • Core 2 分配一个(不可变)对象,该对象位于地址0x100
  • Core 1 获取对这个新对象的引用并在内存位置0x100 读取它。

在这种情况下,当核心 1 请求位置 0x100 的值时,它是否有可能从其 L1/L2 缓存中读取过时数据?我的直觉说这里仍然需要一个内存门来确保 Core 1 读取正确的数据。

上述分析是否正确,是否需要记忆门,还是我遗漏了什么?

更新:

我在这里描述的情况是每次 GC 执行收集时发生的更复杂的版本。当 GC 收集时,内存被重新排序。这意味着对象所在的物理位置发生了变化,L1/L2 必须失效。上面的例子大致相同。

由于可以合理地期望 .NET 确保在重新排序内存后,不同的内核看到正确的内存状态,因此上述情况也不会成为问题。

【问题讨论】:

  • 对象的不变性和对它的引用的不变性(或缺乏)是有区别的。
  • @Pieter:不知道 .NET 中的 GC 是如何工作的,但是为什么它会释放内存 0x100,因为已经有对对象的引用?
  • 我同意其他一些评论者的观点。在这种情况下,实际的对象是不可变的,reference 不是——核心 2 没有改变一个不可变的对象,它创建了一个,并将其放在 0x100。 Core 1 的引用就是这样——一个引用,它实际上并不是不可变对象的一部分——它更像是一个指针。所以是的——我想你会想要一个内存门,我可能会选择在核心 2 交换对象的 Interlocked Exchange 中。
  • 也许我误解了一些东西,但这句话对我来说似乎是假的:“据我所知,Core 1 在这种情况下完全有可能从其 L1/ 读取位置 0x100 的内存L2 缓存,因此它可以读取过时的数据。”如果我只是更改了位置 0x100 的内容,那不会使包含该地址的缓存行无效吗?
  • @JMarsch 如你所说;这听起来不仅合理,而且是一项基本要求。

标签: .net multithreading concurrency immutability


【解决方案1】:

在您的场景中,对象的不变性并不是真正的问题。相反,您的描述问题围绕着指向该对象的引用、列表或其他系统。它当然需要某种技术来确保旧对象不再可用于可能试图访问它的线程。

不可变对象线程安全的真正意义在于不需要编写一堆代码来产生线程安全。而是框架、操作系统、CPU(以及其他任何东西)为您完成工作。

【讨论】:

  • 我想我的问题并不清楚。我已经对其进行了一些更新,这应该可以更好地解释我的问题。
  • @Pieter:也许你误解了我的回答?任何所需的内存门都已经到位——是的,它很可能是必需的。但是,它深入到您正在编程的系统中,因此您可以编写代码,就好像不需要内存门一样。
  • @John Fisher - 你是什么意思内存门已经到位?
  • @Pieter:您用来管理对象的垃圾收集器、操作系统、框架和/或类中已经有线程同步。作为开发人员,您无需关心它。
  • @John Fisher - 我错过了重要的一点,因此问错了问题。由于 GC 收集而重新排序内存,也会发生同样的事情。当内存被重新排序时,旧内存位置的数据变得无效,当然,在这种情况下也必须有一种机制来使 L1/L2 缓存无效。这同样适用于我的示例,尽管我的示例没有清楚地提出问题。谢谢你把我送到正确的方向。
【解决方案2】:

我认为您要问的是,在创建对象后,构造函数是否返回,并且对它的引用存储在某处,另一个处理器上的线程是否仍有可能看到旧数据。您提供了一种可能性,即保存对象实例数据的缓存行以前用于其他目的。

在异常弱的内存模型下,这样的事情是可能的,但我希望任何有用的内存模型,即使是相对弱的内存模型,都会确保解除对不可变对象的引用是安全的,即使这种安全性需要填充对象足以在对象实例之间不共享缓存行(GC 完成后几乎肯定会使所有缓存失效,但是如果没有这样的填充,由核心 #2 创建的不可变对象可能会与一个对象共享一个缓存行核心#1 之前已阅读)。如果没有至少这种级别的安全性,编写健壮的代码将需要如此多的锁和内存屏障,以至于很难编写不比单处理器代码慢的多处理器代码。

流行的 x86 和 x64 内存模型提供了您所寻求的保证,并且走得更远。处理器协调缓存行的“所有权”;如果多个处理器想要读取相同的高速缓存行,它们可以毫无障碍地这样做。当一个处理器想要写入一个高速缓存行时,它会与其他处理器协商所有权。一旦获得所有权,处理器将执行写入。在拥有高速缓存行的处理器放弃之前,其他处理器将无法读取或写入高速缓存行。请注意,如果多个处理器想要同时写入同一个高速缓存行,它们可能会花费大部分时间来协商高速缓存行的所有权,而不是执行实际工作,但语义正确性将得到保留。

【讨论】:

  • 感谢您的解释。是的,这就是我一直在寻找的答案:)。
【解决方案3】:

您错过了让这种事情发生的确实是一个糟糕的垃圾收集器。核心 1 上的引用应该阻止了该对象被 GCd。

【讨论】:

  • 我想我的问题并不清楚。这里的想法是 Core 1 想要读取新对象,但 L1/L2 中的数据是 Core 1 之前可以访问但同时被收集的对象。
  • 这不是垃圾收集器的工作方式。要给线程一个不可变对象的引用,需要将其引用保存在某个地方。所以你可以把它交给另一个线程。这会自动确保它可以被收集。陈旧的数据当然是很有可能的,这从来都不是问题。
【解决方案4】:

我不确定内存门是否会改变这种情况,因为那肯定只会影响后续读取...然后问题就变成了从哪里读取?如果它来自一个字段(它必须至少是静态的,或者某个实例的实例字段仍在堆栈上或以其他方式可访问),或局部变量 - 那么根据定义,它不适用于收藏。

该引用现在在寄存器中的场景......这要复杂得多。直觉上我想说“不,这不是问题”,但需要详细查看内存模型来证明这一点。但是处理引用是这样一个简单的常见场景:这必须工作。

【讨论】:

  • 抱歉,我的问题并不清楚。这里的想法是 Core 1 在最后一步读取的对象是正确的对象,问题不在于引用是否正确(请参阅 cmets 和更新的问题)。 Core 1 实际上想要读取 Core 2 创建的对象。我的问题是它是否保证它实际上读取了它期望读取的新数据。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2013-06-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-07-11
  • 2015-11-13
相关资源
最近更新 更多