为什么标准C#事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码怎么样?

And*_*ell 15 c# memory-model thread-safety memory-barriers mesi

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

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);
Run Code Online (Sandbox Code Playgroud)

其中,可能在另一个线程上,编译器生成的add方法用于Delegate.Combine创建新的多播委托实例,然后在编译器生成的字段上设置该实例(使用互锁比较交换).

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


在我自己的代码中,我想按照以下方式做类似的事情:

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);
Run Code Online (Sandbox Code Playgroud)

哪里this.memberFoo可以由另一个线程设置.(这只是一个线程,所以我不认为它需要联锁 - 但也许这里有副作用?)

(并且,显然,假设它Foo是"不可变的",我们在这个线程上使用它时不会主动修改它.)


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


但我不明白的是:被引用的对象实例所占用的内存如何?特别是在缓存一致性方面?如果"writer"线程在一个CPU上执行此操作:

thing.memberFoo = new Foo(1234);
Run Code Online (Sandbox Code Playgroud)

什么保证Foo分配新内存的内存不会出现在"读取器"运行的CPU的缓存中,具有未初始化的值?什么确保localFoo.baz(上面)不读取垃圾?(跨平台的保证有多好?在Mono上?在ARM上?)

如果新创建的foo恰好来自游泳池呢?

thing.memberFoo = FooPool.Get().Reset(1234);
Run Code Online (Sandbox Code Playgroud)

从内存的角度来看,这似乎没有什么不同,只是一个新的分配 - 但是.NET分配器可能会让第一个案例有效吗?


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

我的来源是维基百科,所以你要做的就是这样.

(我可能会猜测,也许在连动比较交换作家线程上无效缓存读者?或者,也许所有的读取原因失效吗?或者指针引用引起失效?我特别关注如何平台特有的这些东西的声音.)


更新:只是为了更明确地说明问题是关于CPU缓存失效以及.NET提供的保证(以及这些保证可能如何依赖于CPU架构):

  • 假设我们在字段Q(内存位置)中存储了引用.
  • 上CPU (作家)我们在初始化存储器位置的对象R,并写入到基准RQ
  • 在CPU B(阅读器)上,我们取消引用字段Q,并获取内存位置R
  • 然后,在CPU B上,我们从中读取一个值R

假设GC不会在任何时候运行.没有其他有趣的事情发生.

问题:A初始化期间修改它之前,什么阻止R进入B的缓存中,这样当B从中读取它时会得到陈旧的值,尽管它得到了一个新版本,知道首先在哪里? RQR

(可替代的措词:什么使修改R可见CPU 处或点,该改变之前Q是CPU可见).

(并且这仅适用于分配给new或任何内存的内存吗?)+


注意:我在这里发布了一个自我答案.

And*_*ell 2

我想我已经知道答案是什么了。但我不是硬件专家,所以我愿意接受更熟悉 CPU 工作原理的人的纠正。

\n\n
\n\n

.NET 2.0 内存模型保证

\n\n
\n

写入操作不能超过同一线程的其他写入操作。

\n
\n\n

这意味着写入CPU(示例中的A)永远不会将对对象的引用写入内存(to Q),直到写出正在构造的该对象的内容(to R)。到目前为止,一切都很好。不能重新排序:

\n\n
R = <data>\nQ = &R\n
Run Code Online (Sandbox Code Playgroud)\n\n
\n\n

让我们考虑读取 CPU ( B )。R在读取 from 之前如何阻止它读取Q

\n\n

R在足够 na\xc3\xafve CPU 上,人们会认为如果不先从 读取,就不可能读取Q。我们必须首先读取Q以获取 的地址R。(注意:可以安全地假设 C# 编译器和 JIT 的行为方式是这样的。)

\n\n

但是,如果读取CPU有缓存,它的缓存中是否不能有陈旧的内存R,但可以接收更新的内存Q

\n\n

答案似乎否定的。对于健全的缓存一致性协议,失效是作为队列实现的(因此称为“失效队列”)。所以在失效R之前总会先失效Q

\n\n

显然,唯一存在这种情况的硬件是 DEC Alpha(根据此处的表 1)。它是唯一列出的可以重新排序相关读取的架构。(进一步阅读。)

\n