是否可以从另一个线程中观察到一个部分构造的对象

本文关键字:一个 对象 另一个 线程 观察 是否 | 更新日期: 2023-09-27 18:16:23

我经常听说在.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"answers"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的共享字段(在大多数情况下(继续我的工作?

是否可以从另一个线程中观察到一个部分构造的对象

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

这取决于你所指的型号。

首先,让我们准确地定义一个隔离墙屏障。发布语义规定,指令序列中出现在屏障之前的任何其他读取或写入都不允许在该屏障之后移动。

  • ECMA规范有一个宽松的模型,其中的写入不提供这种保证
  • 有人提到,微软提供的CLR实现通过使写入具有发布围栏语义来增强模型
  • x86和x64体系结构通过使写入释放围栏屏障和读取获取围栏屏障来增强模型

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

我们应该列出正在运行的内存模型层,只是要显式。

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

这是否意味着即使没有明确的内存障碍或锁不可能观察到部分构建的对象(考虑仅引用类型(是否已创建?

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

使用以下代码是否可以观察任何输出除了"John 20"answers"Jack 21",说"null 20"还是"Jack 0"?

同样,这是限定的是。但由于上述原因,我怀疑你们是否会观察到这种行为。

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

这是否也意味着我可以将深度不可变的引用类型volatile和(在大多数情况下(get我的工作?

我不知道。这是一个很重的问题。如果不更好地理解其背后的上下文,我不愿意回答任何一种方式。我可以说的是,我通常避免使用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在初始化之前就被赋值了。这是有效的,因为从执行线程的角度来看,逻辑序列与物理序列保持一致。没有意外的副作用。但是,由于显而易见的原因,这个序列对另一个线程来说将是灾难性的。

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

编写编译器的人和创建CPU的人都在追求更高的速度,密谋让情况变得更糟。如果没有明确的指令,编译器人员将以任何方式对代码进行重新排序,以节省一纳秒的时间。中央处理器的人也在做同样的事情。上次我读到,如果可以的话,一个内核倾向于同时运行4条指令。(即使不能。(

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

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

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

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