为什么标准C#事件调用模式在没有内存障碍或缓存失效的情况下是线程安全的?类似的代码呢?

为什么标准C#事件调用模式在没有内存障碍或缓存失效的情况下是线程安全的?类似的代码呢?,c#,thread-safety,memory-model,memory-barriers,mesi,C#,Thread Safety,Memory Model,Memory Barriers,Mesi,在C#中,这是以线程安全方式调用事件的标准代码: var handler = SomethingHappened; if(handler != null) handler(this, e); 其中,编译器生成的add方法可能在另一个线程上使用Delegate.Combine创建新的多播委托实例,然后在编译器生成的字段上设置该实例(使用联锁比较交换) (注意:出于这个问题的目的,我们不关心在事件订阅服务器中运行的代码。假设它在删除时是线程安全和健壮的。) 在我自己的代码中,我想做一些类

在C#中,这是以线程安全方式调用事件的标准代码:

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);
其中,编译器生成的add方法可能在另一个线程上使用
Delegate.Combine
创建新的多播委托实例,然后在编译器生成的字段上设置该实例(使用联锁比较交换)

(注意:出于这个问题的目的,我们不关心在事件订阅服务器中运行的代码。假设它在删除时是线程安全和健壮的。)


在我自己的代码中,我想做一些类似的事情,大致如下:

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);
其中,
this.memberFoo
可以由另一个线程设置。(这只是一个线程,所以我认为它不需要联锁——但可能有副作用?)

(而且,很明显,假设
Foo
是“足够不可变的”,当它在这个线程上使用时,我们不会主动修改它。)


现在我明白了这是线程安全的明显原因:从引用字段读取是原子的。复制到本地可确保不会得到两个不同的值。(仅在.NET 2.0中有保证,但我认为它在任何理智的.NET实现中都是安全的?)


但我不明白的是:被引用的对象实例占用的内存是什么?特别是在缓存一致性方面?如果“编写器”线程在一个CPU上执行此操作:

thing.memberFoo = new Foo(1234);
如何保证分配新
Foo
的内存不会恰好位于运行“读卡器”的CPU的缓存中,并且没有初始化值?如何确保
localFoo.baz
(上图)不读取垃圾?(这在多大程度上跨平台保证?在Mono上?在ARM上?)

如果新创建的foo恰好来自一个池呢

thing.memberFoo = FooPool.Get().Reset(1234);
从内存的角度来看,这似乎与新的分配没有什么不同,但也许.NET分配程序能让第一个案例起作用


在提出这个问题时,我的想法是,需要一个内存屏障来确保——鉴于读取是依赖的,所以内存访问不能被移动——而是作为CPU清除任何缓存失效的信号

我的消息来源是,你要怎么做就怎么做

(我可能推测,可能是写入线程上的联锁比较交换导致读卡器上的缓存无效?或者可能是所有读取导致无效?或者是指针取消引用导致无效?我特别关心这些特定于平台的事情听起来如何。)


更新:只是为了更明确地说明问题在于CPU缓存失效以及.NET提供了什么保证(以及这些保证如何取决于CPU体系结构):

  • 假设我们在字段
    Q
    (内存位置)中存储了一个引用
  • 在CPUA(writer)上,我们在内存位置
    R
    初始化一个对象,并将对
    R
    的引用写入
    Q
  • 在CPUB(读卡器)上,我们取消引用字段
    Q
    ,并返回内存位置
    R
  • 然后,在CPUB上,我们从
    R
假设GC在任何点都不运行。没有其他有趣的事情发生

问题:是什么阻止
R
A在初始化过程中对其进行修改之前进入B的缓存中,这样当B
R
读取时,它会得到过时的值,尽管有了新版本的
Q
知道
R
首先在哪里

(备选措辞:是什么使得对
R
的修改在对
Q
的更改对CPUB可见时或之前对
R
的修改对CPUB可见)

(这是否仅适用于分配了
的内存,或适用于任何内存?)+



注意:我已经发布了。

捕获对不可变对象的引用可以保证线程安全(从一致性的意义上讲,它并不保证您获得最新的值)

事件处理程序的列表是可用的,因此线程安全性捕获对当前值的引用就足够了。整个对象将是一致的,因为它在初始创建后不会更改

示例代码没有明确说明
Foo
是否是不可变的,因此在确定对象是否可以更改(即直接通过设置属性)时会遇到各种问题。请注意,即使在单线程情况下,代码也是“不安全的”,因为您不能保证
Foo
的特定实例不会更改


在CPU缓存等方面:对于真正的不可变对象,唯一可以使内存中实际位置的数据无效的更改是GC的压缩。该代码确保了所有必要的锁/缓存一致性—因此托管代码永远不会观察到指向不可变对象的缓存指针所引用的字节的变化。

对此进行评估时:

thing.memberFoo = new Foo(1234);
首先计算
newfoo(1234)
,这意味着
Foo
构造函数执行到完成。然后将
thing.memberFoo
赋值。这意味着从
thing.memberFoo
读取的任何其他线程都不会读取不完整的对象。它要么读取旧值,要么在构造函数完成后读取对新
Foo
对象的引用。这个新对象是否在缓存中无关紧要;在构造函数完成之前,正在读取的引用不会指向新对象

对象池也会发生同样的情况。右边的所有内容在任务发生之前都会进行完整的评估

在您的示例中,
B
永远不会获得对
R的引用
var handler = SomethingHappened;
if(handler != null)
    handler(this, e);
var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);
public class Foo
{
  public int baz = 0;
  public int daz = 0;

  public Foo()
  {
    baz = 5;
    daz = 10;
  }

  public void Bar(int x)
  {
    x / daz;
  }
}
this.memberFoo = new Foo();
/* 1 */ set register = alloc-memory-and-return-reference(typeof(Foo));
/* 2 */ set register.baz = 0;
/* 3 */ set register.daz = 0;
/* 4 */ set this.memberFoo = register;
/* 5 */ set register.baz = 5;  // Foo.ctor
/* 6 */ set register.daz = 10; // Foo.ctor
R = <data>
Q = &R