在 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 发出信号以刷新任何缓存失效。
我的资料来源是Wikipedia,所以你可以随心所欲。
(我可能推测可能是编写器线程上的 interlocked-compare-exchange使读取器上的缓存无效?或者所有读取都会导致无效?或者指针取消引用会导致无效?我特别关心平台特定的这些东西听起来如何。)
更新:只是为了更明确地说明问题是关于 CPU 缓存失效以及 .NET 提供了什么保证(以及这些保证如何取决于 CPU 架构):
- 假设我们有一个存储在字段
Q
(内存位置)中的引用。 - 在 CPU A(写入器)上,我们在内存位置初始化一个对象
R
,并将引用写入R
到Q
- 在 CPU B(阅读器)上,我们取消引用字段
Q
,并取回内存位置R
- 然后,在 CPU B上,我们从
R
假设 GC 在任何时候都没有运行。没有其他有趣的事情发生。
问题:在A在初始化期间对其进行修改之前,是什么阻止R
了B的缓存,这样当B从它读取时,它会得到陈旧的值,尽管它得到了一个新版本的to know where is in place? R
Q
R
(替代措辞:是什么使修改对R
CPU B 在更改对 CPU B可见时或之前对CPU BQ
可见。)
(这是否仅适用于用 分配的内存new
或任何内存?)+
注意:我在这里发布了一个自我回答。