为什么(或不是)在构造函数中设置字段是否安全?

时间:2015-03-19 13:42:33

标签: c# multithreading volatile memory-model

让我们说你有一个像这样的简单课:

class MyClass
{
    private readonly int a;
    private int b;

    public MyClass(int a, int b) { this.a = a; this.b = b; }

    public int A { get { return a; } }
    public int B { get { return b; } }
}

我可以以多线程方式使用这个类:

MyClass value = null;
Task.Run(() => {
    while (true) { value = new MyClass(1, 1); Thread.Sleep(10); }
});
while (true)
{
    MyClass result = value;
    if (result != null && (result.A != 1 || result.B != 1)) { 
        throw new Exception(); 
    }
    Thread.Sleep(10);
}

我的问题是:我会看到这个(或其他类似的多线程代码)抛出异常吗?我经常看到其他线程可能不会立即看到非易失性写入的事实。因此,似乎这可能会失败,因为写入值字段可能会在写入a和b之前发生。这是可能的,或者内存模型中是否存在使这种(非常常见)模式安全的东西?如果是这样,它是什么?为了这个目的,readonly是否重要?如果a和b是一种无法原子编写的类型(例如自定义结构)会不会很重要?

6 个答案:

答案 0 :(得分:11)

编写的代码将从CLR2.0开始工作,因为CLR2.0内存模型保证所有商店都具有发布语义

发布语义:确保围栏前没有加载或存储 围栏后会移动。之后的说明可能仍然发生在之前 围栏。(取自CPOW页面512)。

这意味着在分配类引用后无法移动构造函数初始化。

Joe duffy在他的article about the very same subject中提到了这一点。

  

规则2:所有商店都有发布语义,即没有加载或存储可能   一个接一个地移动。

Vance morrison的article here也证实了这一点(章节技巧4:懒惰的初始化)。

  

与删除读锁的所有技术一样,图7中的代码   依赖于强大的写入顺序。例如,这段代码就是   ECMA内存模型不正确,除非myValue变得易失   因为初始化LazyInitClass实例的写入可能是   延迟到写入myValue之后,允许客户端   GetValue读取未初始化状态。在.NET Framework 2.0中   模型,代码在没有volatile声明的情况下工作。

保证写入从CLR 2.0开始按顺序发生。它没有在ECMA标准中指定,它只是CLR的微软实现给出了这种保证。如果您在CLR 1.0或CLR的任何其他实现中运行此代码,您的代码可能会中断

此更改背后的故事是:(来自CPOW Page 516)

  

当CLR 2.0移植到IA64时,它的初始开发已经完成了   发生在X86处理器上,因此处理起来很差   任意商店重新排序(IA64允许)。情况也是如此   由非Microsoft开发人员编写的目标.NET的大多数代码   定位Windows

     

结果是框架中的很多代码在运行时都破了   IA64,特别是与臭名昭着的双重检查的代码   锁定模式,突然无法正常工作。我们来看看这个   在本章后面的模式中。但总的来说,   如果商店可以通过其他商店,请考虑:一个线程可能   初始化私有对象的字段,然后发布对它的引用   它在一个共享的位置;因为商店可以四处走动,另一个   线程可能能够看到对象的引用,读取它,和   然而,当他们仍然是一个未初始化的状态时,看到领域。   这不仅影响现有代码,还可能违反类型系统   属性,如initonly fields。

     

因此CLR架构师决定通过发射强化2.0   IA64上的所有商店都作为释放围栏。这给了所有CLR程序   更强的记忆模型行为。这确保了程序员不需要   不得不担心只会出现的微妙的竞争条件   在一个不起眼的,很少使用和昂贵的架构上练习。

注意Joe duffy表示他们通过在IA64上发布所有商店作为发布围栏来强化2.0 这并不意味着其他处理器可以重新排序。其他处理器本身固有地保证商店(商店后面的商店)不会被重新排序。所以CLR不需要明确保证这一点。

答案 1 :(得分:1)

上述代码是线程安全的。构造函数在被分配给"值"之前完全执行。变量。第二个循环中的本地副本将为null或完全构造的实例,因为分配实例引用是内存中的原子操作。

如果"价值"是一个结构然后它不会是线程安全的,因为值的初始化不是原子的。

答案 2 :(得分:1)

  

因此,似乎这可能会失败,因为写入值字段可能会在写入a和b之前发生。这可能吗

是的,这肯定是可能的。

您需要以某种方式同步对数据的访问,以防止此类重新排序。

答案 3 :(得分:0)

  

我会看到这个(或其他类似的多线程代码)抛出异常吗?


,在ARM(以及任何其他具有弱内存模型的硬件)上,您将观察到此类行为。

  

我经常看到非易失性写入可能没有的事实   其他线程立即看到。因此,似乎可以这样   失败,因为写入值字段可能发生在   写到a和b。这是可能的,还是有什么东西在   内存模型使这个(非常常见)模式安全吗?

易失性不是关于观察变化的瞬时性,而是关于秩序和获取/释放语义。
此外,ECMA-335表示它可能发生(并且它将发生在ARM或任何其他具有弱内存模型的硬件上)。

  

为此目的,readonly是否重要?

readonly与指令重新排序和易失性无关。

  

如果a和b是一种无法原子编写的类型(例如自定义结构),这是否重要?

在这种情况下,字段的原子性并不重要。 为了防止这种情况,您应该通过Volatile.Write编写对创建对象的引用(或者只是使该引用volatile,编译器将完成这项工作)。 Volatile.Write(ref value, new MyClass(1, 1))会做到这一点。

有关易失性语义和内存模型的更多信息,请参阅ECMA-335,第I.12.6节

答案 4 :(得分:0)

如上所述,此代码是线程安全的,因为value在构造函数执行完毕之前不会更新。换句话说,其他任何人都没有观察到正在建造的物体。

您可以编写代码,通过明确将this发布到外部世界来帮助您拍摄自己的内容,例如

class C { public C( ICObserver observer ) { observer.Observe(this); } }

当Observe()执行时,所有的赌注都会被关闭,因为它不再适用于外界没有观察到该对象。

答案 5 :(得分:-2)

  

这是错的,对不起......

     

如果您的测试在之前执行,我认为您可能会抛出错误   在另一个线程中首先分配变量。这将是一个   竞争条件......这可能是一个间歇性的问题。

     

如果while循环检查的值,您也可能会收到错误   确定新类被实例化并赋值的确切时刻,   但在设置a和b变量之前。

至于更好的方法,这取决于你的目标。有没有理由值被覆盖?我认为将新类放入需要按顺序处理的集合中更为常见。

有些集合可以解决这个问题。您可以在一个线程中向集合添加类,并在另一个线程中检查并提取它们。

有关示例,请参阅http://dotnetcodr.com/2014/01/14/thread-safe-collections-in-net-concurrentstack/