是否可以从另一个线程观察部分构造的对象?
我经常听说在.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
(并且在大多数情况下)只是继续我的工作?
我经常听说在.NET 2.0内存模型中,写入总是使用释放围栏。 这是真的?
这取决于你所指的模型。
首先,让我们精确定义一个释放栅栏屏障。 释放语义规定,在该障碍之后,不允许在指令序列中的障碍之前出现的其他读或写。
- ECMA规范有一个宽松的模型,其中写入不提供此保证。
- 有人指出,微软提供的CLR实现通过使写入具有发布 – 围栏语义来强化模型。
- x86和x64架构通过编写释放栅栏障碍并读取获取栅栏障碍来强化模型。
因此,在深奥的体系结构(如Windows 8现在将以ARM为目标)上运行的CLI(例如Mono)的另一个实现可能不会在写入时提供释放范围语义。 请注意,我说这是可能的,但不确定。 但是,在所有正在运行的内存模型之间,例如不同的软件和硬件层,如果您希望代码真正可移植,则必须为最弱的模型编写代码。 这意味着针对ECMA模型编码而不做任何假设。
我们应该在显式中列出内存模型层。
- 编译器:C#(或VB.NET或其他)可以移动指令。
- 运行时:显然,通过JIT编译器的CLI运行时可以移动指令。
- 硬件:当然,CPU和内存架构也会发挥作用。
这是否意味着即使没有明确的内存屏障或锁定,也不可能在不同于创建它的线程上观察部分构造的对象(仅考虑引用类型)?
是(合格):如果运行应用程序的环境模糊不清,则可能会从另一个线程中观察到部分构造的实例。 这是双重检查锁定模式在不使用volatile
情况下不安全的原因之一。 但实际上,我怀疑你会遇到这种情况主要是因为微软的CLI实现不会以这种方式重新排序指令。
是否可以使用以下代码观察除“John 20”和“Jack 21”之外的任何输出,例如“null 20”或“Jack 0”?
再次,这是合格的。 但由于上述某些原因,我怀疑你会不会观察到这种行为。
虽然,我应该指出,因为person
没有被标记为volatile
所以可能根本没有打印任何内容,因为阅读线程可能总是将其视为null
。 但实际上,我认为Console.WriteLine
调用将导致C#和JIT编译器避免提升操作,否则可能会将person
的读取移到循环之外。 我怀疑你已经很清楚这种细微差别了。
这是否也意味着我可以让所有深度不可变引用类型的共享字段变为volatile(并且在大多数情况下)继续我的工作?
我不知道。 这是一个非常有问题的问题。 如果不更好地理解背后的背景,我会很自在地回答这两种方式。 我可以说的是,我通常避免使用volatile
来支持更明确的内存指令,例如Interlocked
操作, Thread.VolatileRead
, Thread.VolatileWrite
和Thread.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的人都在寻求更快的速度,密谋使情况变得更糟。 如果没有明确的指示,编译人员将以任何方式重新排序您的代码以节省纳秒。 CPU人员也在做同样的事情。 最后我读到,如果可以,单核往往会同时运行4条指令。 (也许即使它不能。)
在正常情况下,你很少会遇到这个问题。 然而,我发现,每6个月只出现一次的小问题可能是一个真正的主要问题。 而且,有趣的是,十分之一的问题每分钟可能会发生几次 – 这是更可取的。 我猜你的代码将属于后一类。
好吧,至少在IL级别,构造函数直接在堆栈上调用,生成的引用不会生成(并且能够存储),直到构造完成之后。 因此,它不能在(IL)编译器级别(对于引用类型)重新排序。
至于抖动级别,我不确定,但如果重新排序字段赋值和方法调用(这是构造函数的话)会让我感到惊讶。编译器是否真的会查看该方法及其所有可能的执行路径确保被调用的方法永远不会使用该字段?
同样在CPU级别,如果在跳转指令周围发生重新排序,我会感到惊讶,因为CPU无法知道分支是否是“子程序调用”,因此将返回到下一条指令。 执行无序操作将允许在“非常规”跳转的情况下出现严重错误的行为。