【问题标题】:Can memory reordering cause C# to access unallocated memory?内存重新排序会导致 C# 访问未分配的内存吗?
【发布时间】:2018-12-13 07:33:26
【问题描述】:

据我了解,C# 是一种安全语言,除了通过 unsafe 关键字外,不允许访问未分配的内存。但是,它的内存模型允许在线程之间存在不同步访问时重新排序。这会导致竞争危险,在实例完全初始化之前,对新实例的引用似乎可用于竞争线程,并且是双重检查锁定的广为人知的问题。 Chris Brumme(来自 CLR 团队)在他们的 Memory Model 文章中解释了这一点:

考虑标准的双重锁定协议:

if (a == null)
{
    lock(obj)
    {
        if (a == null) 
            a = new A();
    }
}

这是一种在典型情况下避免锁定读取“a”的常用技术。它在 X86 上运行良好。但它会被 ECMA CLI 规范的合法但薄弱的实现所打破。确实,根据 ECMA 规范,获取锁具有获取语义,释放锁具有释放语义。

但是,我们必须假设在“a”的构建过程中发生了一系列商店。这些存储可以任意重新排序,包括将它们延迟到将新对象分配给“a”的发布存储之后的可能性。此时,在 store.release 之前有一个小窗口,表示离开锁。在该窗口内,其他 CPU 可以浏览引用“a”并查看部分构造的实例

我一直对“部分构造的实例”的含义感到困惑。假设 .NET 运行时在分配时清除内存而不是垃圾回收 (discussion),这是否意味着其他线程可能会读取仍包含来自垃圾回收对象的数据的内存(如 what happens in unsafe languages)?

考虑以下具体示例:

byte[] buffer = new byte[2];

Parallel.Invoke(
    () => buffer = new byte[4],
    () => Console.WriteLine(BitConverter.ToString(buffer)));

上面有一个竞争条件;输出将是00-0000-00-00-00。但是,第二个线程是否有可能在数组的内存被初始化为 0 之前 读取对 buffer 的新引用,并改为输出一些其他任意字符串?

【问题讨论】:

  • relevant 在部分构造的实例上;当然还有this one,第二个引用
  • 是的。但请注意,那些其他线程将不得不忽略锁。我不会称这是一个很大的实际问题,它只会伤害“无锁”代码。当你认为你能做到这一点时,你可以处理这个小问题。这完全是关于一个假设的平台。
  • this article 建议 ECMA 不强制要求发布围栏。 ecma CLI F.4.1 含糊不清。当 Itanium 是一个 .NET 目标平台时,CLR 确实通过 ST.REL(ease) 而不是简单的 ST back 使用了释放栅栏,这是迄今为止唯一实际支持的具有弱内存模型架构的硬件。
  • 是的,他是说内存内容可能看起来不是从此类处理器上的另一个线程初始化的。 IA64 给 Microsofties 带来了一段非常艰难的时期,我记得读到他们通过使每次内存读取获取和每次写入都释放来进行下注。嗯,这就是为什么抖动停止并且处理器超出生命支持的原因。他们确实对 ARM 抖动的内存模型进行了更改,除了“我们正在研究它”之外没有其他记录。他们的工作就是让它发挥作用,他们似乎做得很好,因为我还没有在 SO 上看到关于它的问题。
  • 我应该注意到,ARM 是唯一在硬件中实现 C++11 内存模型的处理器设计。他们非常擅长让程序员开心,这是 IA64 严重错过的制胜策略。

标签: c# .net multithreading memory-barriers


【解决方案1】:

我们不要在这里埋头苦干:你的问题的答案是不,你永远不会观察到 CLR 2.0 内存模型中内存的预分配状态

我现在将谈谈你的几个非中心点。

据我了解,C# 是一种安全语言,除了通过 unsafe 关键字外,不允许访问未分配的内存。

这或多或少是正确的。有一些机制可以在不使用unsafe 的情况下访问虚假内存——显然是通过非托管代码或滥用结构布局。但总的来说,是的,C# 是内存安全的。

但是,它的内存模型允许在线程之间存在不同步访问时重新排序。

同样,这或多或少是正确的。一个更好的思考方式是,C# 允许在重新排序对单线程程序不可见的任何点进行重新排序,但受到某些约束。这些约束包括在某些情况下引入获取和释放语义,以及在某些关键点保留某些副作用。

Chris Brumme(来自 CLR 团队)...

已故伟大的 Chris 的文章是瑰宝,对早期的 CLR 提供了很多见解,但我注意到自 2003 年撰写该文章以来,内存模型已经得到了一些加强,特别是关于你提出的问题。

Chris 说得对,双重检查锁定非常危险。有一种正确的方法可以在 C# 中进行双重检查锁定,并且在您离开它的时刻,即使是轻微,您也会陷入可怕的错误的杂草中,这些错误只会重现在弱内存模型硬件上。

这是否意味着其他线程可能会读取仍然包含来自垃圾收集对象的数据的内存

我认为您的问题并不是专门针对 Chris 所描述的旧的弱 ECMA 内存模型,而是关于今天实际做出的保证。

重新排序不可能暴露对象的先前状态。保证当你读取一个新分配的对象时,它的字段都是零。

这是因为在当前内存模型中所有写入都具有释放语义的事实;详情见此:

http://joeduffyblog.com/2007/11/10/clr-20-memory-model/

将内存初始化为零的写入不会相对于稍后的读取及时向前移动。

我一直对“部分构造的对象”感到困惑

Joe 在这里讨论这个问题:http://joeduffyblog.com/2010/06/27/on-partiallyconstructed-objects/

这里关心的不是我们可能会看到一个对象的预分配状态。相反,这里关注的是一个线程可能会看到一个对象而构造函数仍在另一个线程上运行

确实,constructorfinalizer 有可能同时运行,这太奇怪了!由于这个原因,终结器很难正确编写。

换句话说:CLR 向您保证它自己的不变量将被保留。 CLR 的一个不变量是观察到新分配的内存被清零,因此将保留不变量。

但 CLR 不负责保留您的不变量!如果您有一个构造函数,当且仅当y 为非空时,该构造函数保证字段xtrue,那么 负责确保始终观察到此不变量为真.如果以某种方式this 被两个线程观察到,那么其中一个线程可能会观察到不变量被违反。

【讨论】:

  • 感谢 Eric 的详细回复,它解决了我从 CLR 内存模型的 POV 提出的所有问题。但是,我有兴趣了解其他运行时实现的状态,例如在 ARM 上运行的 CoreCLR。是否有经过修订的规范要求此类实现强制执行更强大的内存模型,满足您的保证,或者符合标准的实现仍然会导致我描述的问题?我得到的最接近的答案是 Hans's comment 模型已更改但未记录。
  • @Douglas:内存模型的历史及其文档令人困惑和神秘,不幸的是,我不是这方面的专家。我不知道任何修订的规范。也就是说,如果任何符合 CLR 的实现允许您曾经从安全子集中​​的代码中读取内存的先前内容,我会非常惊讶。从正确性和安全性的角度来看,这似乎非常危险,我希望这会被阻止。
  • @Douglas:例如,考虑 ASP.NET。是的,如果您需要,它支持进程隔离,但设计方案是某些托管公司同时托管 coke.com 和 pepsi.com,并且两者的后端代码可能在同一进程中运行。如果有一种方法可以让一个网站在安全子集中​​运行,以查看另一个网站尚未清理的垃圾,那将是头版新闻; CLR 不应该是新的 Heartbleed 向量。 :-)
猜你喜欢
  • 2012-08-10
  • 2020-04-06
  • 2015-03-08
  • 2012-11-12
  • 2021-05-01
  • 2015-08-11
  • 2022-06-15
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多