【问题标题】:Is it possible to observe a partially-constructed object from another thread?是否可以从另一个线程观察部分构造的对象?
【发布时间】:2012-01-11 14:56:54
【问题描述】:

我经常听说在 .NET 2.0 内存模型中,写入总是使用释放栅栏。这是真的?这是否意味着即使没有显式的内存屏障或锁,也不可能在与创建对象不同的线程上观察到部分构造的对象(仅考虑引用类型)?我显然排除了构造函数泄漏this 引用的情况。

例如,假设我们有不可变的引用类型:

public class Person
{
    public string Name { get; private set; }
    public int Age { get; private set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

是否可以使用以下代码观察除“John 20”和“Jack 21”以外的任何输出,例如“null 20”或“Jack 0”?

// We could make this volatile to freshen the read, but I don't want
// to complicate the core of the question.
private Person person;

private void Thread1()
{
    while (true)
    {
        var personCopy = person;

        if (personCopy != null)
            Console.WriteLine(personCopy.Name + " " + personCopy.Age);
    }
}

private void Thread2()
{
    var random = new Random();

    while (true)
    {
        person = random.Next(2) == 0
            ? new Person("John", 20)
            : new Person("Jack", 21);
    }
}

这是否也意味着我可以将所有共享字段设为深度不可变引用类型volatile 并且(在大多数情况下)继续我的工作?

【问题讨论】:

  • 我看不出内存模型对这个问题有什么影响。线程 2 只分配完全构造的 Person 对象,那么你怎么能观察到部分构造的对象呢?
  • @Peter Ruderman 因为内存模型说你可以(或不能)
  • 因为如果构造函数在构造完成之前返回一个指向正在构造的对象的指针,您可以看到一半生成的对象。如果 cpu 无序执行,则很有可能在没有内存屏障的情况下发生这种情况。我很确定 .NET 保证不会,但我没有证据 :)
  • @Peter Ruderman 您假设数据已分配并且代码按照您在源代码中看到的顺序执行,这在现代 CPU 上可能不是这种情况(当然也不是在非缓存一致的系统,尽管 .NET 尚未在此类系统上运行 afaik)
  • @Boris:引用类型分配的原子性只能防止引用本身(它只是一个托管指针)的读/写撕裂。

标签: c# .net multithreading thread-safety memory-model


【解决方案1】:

我经常听说在 .NET 2.0 内存模型中,写入总是使用 释放围栏。这是真的吗?

这取决于您所指的型号。

首先,让我们精确定义一个释放栅栏。释放语义规定指令序列中出现在屏障之前的任何其他读取或写入都不允许在该屏障之后移动。

  • ECMA 规范有一个宽松的模型,其中写入不提供此保证。
  • 在某处引用过,Microsoft 提供的 CLR 实现通过使写入具有释放栅栏语义来增强模型。
  • x86 和 x64 架构通过设置写入释放栅栏和读取获取栅栏来加强模型。

因此,在深奥的体系结构(例如 Windows 8 现在将针对的 ARM)上运行的 CLI 的另一个实现(例如 Mono)可能在写入时提供释放栅栏语义。请注意,我说这是可能的,但不确定。但是,在所有正在使用的内存模型之间,例如不同的软件和硬件层,如果您希望您的代码真正可移植,则必须针对最弱的模型进行编码。这意味着针对 ECMA 模型进行编码而不做任何假设。

我们应该明确列出正在使用的内存模型层。

  • 编译器:C#(或 VB.NET 或其他)可以移动指令。
  • 运行时:显然通过 JIT 编译器的 CLI 运行时可以移动指令。
  • 硬件:当然,CPU 和内存架构也会发挥作用。

这是否意味着即使没有显式的内存屏障或锁,它 不可能观察到一个部分构造的物体(考虑到 仅引用类型)在与其所在线程不同的线程上 被创建了吗?

是(合格):如果应用程序运行的环境足够模糊,那么可能会从另一个线程观察到部分构造的实例。这就是不使用volatile 的情况下双重检查锁定模式不安全的原因之一。但实际上,我怀疑您是否会遇到这种情况,主要是因为 Microsoft 的 CLI 实现不会以这种方式重新排序指令。

是否可以使用以下代码观察任何输出 除了“John 20”和“Jack 21”,说“null 20”或“Jack 0”?

再次,这是合格的。但由于上述某些原因,我怀疑您是否会观察到这种行为。

不过,我应该指出,因为person 没有标记为volatile,所以可能根本没有打印任何内容,因为阅读线程可能总是将其视为null。然而,实际上,我敢打赌Console.WriteLine 调用将导致C# 和JIT 编译器避免提升操作,否则可能会将person 的读取移到循环之外。我怀疑你已经很清楚这种细微差别了。

这是否也意味着我可以将所有共享字段 深度不可变的引用类型 volatile 并且(在大多数情况下)继续 我的工作?

我不知道。这是一个非常重要的问题。如果没有更好地理解其背后的背景,我会不舒服地回答任何一种方式。我可以说的是,我通常避免使用volatile,而是使用更明确的内存指令,例如Interlocked 操作、Thread.VolatileReadThread.VolatileWriteThread.MemoryBarrier。再说一次,我也尝试完全避免无锁代码,转而支持更高级别的同步机制,例如lock

更新:

我喜欢将事物可视化的一种方式是假设 C# 编译器、JITer 等将尽可能积极地优化。这意味着Person.ctor 可能是内联的候选对象(因为它很简单),这将产生以下伪代码。

Person ref = allocate space for Person
ref.Name = name;
ref.Age = age;
person = instance;
DoSomething(person);

并且由于在 ECMA 规范中写入没有释放栅栏语义,因此其他读取和写入可能会“浮动”到分配给 person 的下方,从而产生以下有效的指令序列。

Person ref = allocate space for Person
person = ref;
person.Name = name;
person.Age = age;
DoSomething(person);

所以在这种情况下,您可以看到 person 在初始化之前被分配。这是有效的,因为从执行线程的角度来看,逻辑顺序与物理顺序保持一致。没有意外的副作用。但是,出于显而易见的原因,这个序列对另一个线程来说是灾难性的。

【讨论】:

  • 你说得对,在我的示例中我意识到 C#/JIT '提升'的可能性(在实践中似乎不会发生,至少在 x86 上的 C# 4 + .NET 4 上不会发生),但我不想引起volatile 的任何额外影响。但是让字段volatile 强制执行“没有部分构造的对象”保证(我的猜测是肯定的——您在更新中提到的重新排序不会在任何级别发生)?
  • @Ani:是的。 volatile 的规范说写入具有释放栅栏语义,因此根据规范,对NameAge 字段的写入不能向下浮动到person 的分配。换句话说,如果使用volatile,对person 的赋值必须最后发生。这就是为什么volatile 确实使双重检查锁定模式在 C# 中起作用的原因之一。
  • 谢谢。那么我是否可以推断出我关于“使不可变类的字段易变并忘记大多数相关的线程安全问题”的最后陈述对于大多数情况是正确的?我确实同意你的观点,其他同步原语可能更合适。
【解决方案2】:

你没有希望。用错误检查替换您的控制台写入,设置十几个 Thread1() 副本,使用具有 4 个内核的机器,您一定会找到一些部分构造的 Person 实例。使用其他答案和 cmets 中提到的保证技术来确保您的程序安全。

编写编译器的人和创建 CPU 的人都为了追求更高的速度而密谋使情况变得更糟。如果没有明确的指示,编译器人员将尽可能地重新排序您的代码以节省一纳秒。 CPU人员也在做同样的事情。最后我读到,如果可以的话,单个内核往往会同时运行 4 条指令。 (也许即使它不能。)

在正常情况下,您很少会遇到此问题。然而,我发现,每 6 个月才出现一次的小问题可能是真正的大问题。而且,有趣的是,一个十亿分之一的问题可能在一分钟内发生几次——这是更可取的。我猜你的代码会属于后者。

【讨论】:

    【解决方案3】:

    好吧,至少在 IL 级别,构造函数是直接在堆栈上调用的,直到构造完成后才会生成(并且能够存储)结果引用。因此,它不能在 (IL) 编译器级别重新排序(对于引用类型。)

    至于抖动级别,我不确定,但如果它重新排序字段分配和方法调用(这就是构造函数),我会感到惊讶。编译器真的会查看方法及其所有内容吗?可能的执行路径以确保被调用的方法永远不会使用该字段?

    同样在 CPU 级别,如果在跳转指令周围发生重新排序,我会感到惊讶,因为 CPU 无法知道分支是否是“子程序调用”,因此会返回到下一条指令。在“非常规”跳转的情况下,乱序执行将导致严重错误的行为。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2017-07-12
      • 2019-02-18
      • 1970-01-01
      • 2022-08-19
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多