在 C# 中,这是以线程安全的方式调用事件的标准代码:
var handler = SomethingHappened;
if(handler != null)
handler(this, e);
其中,可能在另一个线程上,编译器生成的 add 方法使用
Delegate.Combine
创建一个新的多播委托(delegate)实例,然后它在编译器生成的字段上设置(使用互锁比较交换)。(注意:对于这个问题,我们不关心在事件订阅者中运行的代码。假设它在删除时是线程安全和健壮的。)
在我自己的代码中,我想做一些类似的事情:
var localFoo = this.memberFoo;
if(localFoo != null)
localFoo.Bar(localFoo.baz);
哪里
this.memberFoo
可以由另一个线程设置。 (这只是一个线程,所以我认为它不需要互锁 - 但也许这里有副作用?)(而且,显然,假设
Foo
是“足够不可变的”,因此我们不会在该线程上使用它时主动修改它。)现在 我明白这是线程安全的明显原因 :从引用字段读取是原子的。复制到本地确保我们不会得到两个不同的值。 ( Apparently 仅从 .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 ,所以你会怎么做。
(我可能推测可能是写线程上的互锁比较交换使读取器上的缓存无效?或者可能所有读取都导致无效?或者指针取消引用导致无效?我特别担心这些东西听起来是如何特定于平台的。)
更新:只是为了更明确地说明问题是关于 CPU 缓存失效以及 .NET 提供的保证(以及这些保证如何取决于 CPU 架构):
Q
中存储了一个引用(内存位置)。 R
,并写入对 R
的引用进入 Q
Q
,并取回内存位置 R
R
中读取一个值假设 GC 在任何时候都不运行。没有其他有趣的事情发生。
问题:什么阻止了
R
来自 乙 的缓存,来自之前 一个 在初始化期间修改了它,这样当 乙 来自 R
尽管它获得了 Q
的新版本,但它仍然获得了陈旧的值。哪里知道R
是第一位的?(替代措辞:是什么使对
R
的修改对 CPU 可见 B 在对 Q
的更改对 CPU B 可见时或之前)(这是否仅适用于使用
new
分配的内存,或任何内存?)+注意:我已经发布了 a self-answer here .
最佳答案
这是一个很好的问题。让我们考虑你的第一个例子。
var handler = SomethingHappened;
if(handler != null)
handler(this, e);
为什么这是安全的?要回答这个问题,您首先必须定义“安全”的含义。 NullReferenceException 是否安全?是的,很容易看到在本地缓存委托(delegate)引用消除了空检查和调用之间的烦人竞争。让多个线程接触委托(delegate)是否安全?是的,委托(delegate)是不可变的,因此一个线程不可能导致委托(delegate)进入半生不熟的状态。前两个是显而易见的。但是,如果线程 A 在循环中执行此调用并且线程 B 在稍后的某个时间点分配第一个事件处理程序,那么情况如何呢?从线程 A 最终会看到委托(delegate)的非空值的意义上说,这是否安全?对此有些令人惊讶的答案可能是。原因是
add
的默认实现和 remove
事件的访问器会创建内存屏障。我相信 CLR 的早期版本采用了明确的 lock
及更高版本使用 Interlocked.CompareExchange
.如果您实现了自己的访问器并省略了内存屏障,那么答案可能是否定的。我认为实际上这在很大程度上取决于 Microsoft 是否为多播委托(delegate)本身的构建添加了内存障碍。关于第二个更有趣的例子。
var localFoo = this.memberFoo;
if(localFoo != null)
localFoo.Bar(localFoo.baz);
不。抱歉,这实际上并不安全。让我们假设
memberFoo
类型为 Foo
其定义如下。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();
尽管有些人可能认为,只要在逻辑上保留程序员的意图,就没有任何指令必须按照它们在代码中定义的顺序执行。 C# 或 JIT 编译器实际上可以制定以下指令序列。
/* 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
注意如何分配给
memberFoo
在构造函数运行之前发生。这是有效的,因为从执行它的线程的角度来看,它没有任何意外的副作用。但是,它可能会对其他线程产生重大影响。如果您对 memberFoo
进行空检查会发生什么当写入线程刚刚完成指令#4 时,在读取线程上发生了什么?读者将看到一个非空值,然后尝试调用 Bar
之前daz
变量设置为 10。daz
仍将保持其默认值 0,从而导致除以零错误。当然,这主要是理论上的,因为 Microsoft 的 CLR 实现在写入时创建了一个可以防止这种情况的发布栅栏。但是,该规范在技术上允许这样做。见 this question对于相关内容。
关于c# - 为什么标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/30759836/