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

Why is the standard C# event invocation pattern thread-safe without a memory barrier or cache invalidation? What about similar code?

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

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

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

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


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

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);

其中 this.memberFoo 可以由另一个线程设置。 (这只是一个线程,所以我认为它不需要互锁 - 但这里可能会有副作用?)

(而且,很明显,假设 Foo 是 "immutable enough",我们不会在该线程上使用它时主动修改它。)


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


但是我不明白的是:被引用的对象实例占用的内存怎么办?特别是关于缓存一致性?如果 "writer" 线程在一个 CPU:

上执行此操作
thing.memberFoo = new Foo(1234);

什么保证分配新 Foo 的内存不会碰巧在 CPU 的缓存中 "reader" 是 运行ning ,具有未初始化的值?是什么确保 localFoo.baz(上图)不读取垃圾? (跨平台的保证程度如何?在 Mono 上?在 ARM 上?)

如果新创建的 foo 恰好来自池怎么办?

thing.memberFoo = FooPool.Get().Reset(1234);

从内存的角度来看,这似乎与全新的分配没有什么不同 - 但也许 .NET 分配器做了一些神奇的事情来使第一个案例起作用?


我的想法是,在提出这个问题时,需要一个内存屏障来确保——考虑到读取是相关的,与其说内存访问不能四处移动——而是作为一个信号给 CPU 刷新任何缓存失效。

我的来源是 Wikipedia,所以请随心所欲。

(我可能推测 writer 线程上的互锁比较交换可能使 reader[=103= 上的缓存无效]?或者 all 读取会导致失效?或者指针取消引用会导致失效?我特别关心这些东西听起来是如何特定于平台的。)


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

  • 假设我们在字段 Q(内存位置)中存储了一个引用。
  • 在 CPU A(编写器)上,我们在内存位置 R 初始化一个对象,并将对 R 的引用写入 Q
  • 在 CPU B (reader),我们取消引用字段 Q,并取回内存位置 R
  • 然后,在CPUB,我们从R
  • 读取一个值

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

问题: 是什么阻止了 RB 的缓存中,从 before A 在初始化期间对其进行了修改,这样当 BR 读取时,它会得到陈旧的值,尽管它得到一个新版本的 Q 以首先知道 R 在哪里?

(替代措辞:是什么使得对 R 的修改对 CPU BQ 对 CPU B 可见。)

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


注意:我已经发布了

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

事件处理程序列表是 immutable,因此线程安全足以捕获对当前值的引用。整个对象将是一致的,因为它在初始创建后永远不会改变。

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

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

评估时:

thing.memberFoo = new Foo(1234);

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

对象池也会发生同样的事情。右边的所有内容都在赋值发生之前完成计算。

在您的示例中,B 永远不会在 R 的构造函数具有 运行 之前获得对 R 的引用,因为 A 不写RQ 直到 A 完成构建 R。如果 B 在此之前读取 Q,它将获得 Q 中已经存在的任何值。如果 R 的构造函数抛出异常,那么 Q 将永远不会被写入。

C# order of operations 保证会以这种方式发生。赋值运算符的优先级最低,new 和函数调用运算符的优先级最高。这保证了 new 将在评估分配之前评估。这对于异常之类的事情是必需的——如果构造函数抛出异常,那么被分配的对象将处于无效状态,并且无论您是否是多线程的,您都不希望发生该分​​配。

在我看来,您应该在这种情况下使用 see this article。这确保了编译器不会执行假定由单个线程访问的优化。

事件过去使用锁,但从 C# 4 开始使用无锁同步 - 我不确定具体是什么 (see this article)。

编辑: Interlocked 方法使用内存屏障,它将 ensure all threads read the updated value (在任何健全的系统上)。只要您使用 Interlocked 执行所有更新,您就可以安全地从任何线程读取值,而无需内存屏障。这是 System.Collections.Concurrent 类.

中使用的模式

这真是个好问题。让我们考虑您的第一个示例。

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

为什么这样安全?要回答这个问题,您首先必须定义 "safe" 的含义。 NullReferenceException 是否安全?是的,很容易看到在本地缓存委托引用消除了空检查和调用之间令人讨厌的竞争。让多个线程接触委托是否安全?是的,委托是不可变的,因此一个线程不可能导致委托进入半生不熟的状态。前两个很明显。但是,线程 A 在循环中执行此调用而线程 B 在稍后的某个时间点分配第一个事件处理程序的情况又如何呢?从线程 A 最终会看到委托的非空值的意义上说,这是否安全?对此有些令人惊讶的答案是可能。原因是事件的 addremove 访问器的默认实现创建了内存屏障。我相信 CLR 的早期版本采用了明确的 lock,而后来的版本使用了 Interlocked.CompareExchange。如果您实现了自己的访问器并省略了内存屏障,那么答案可能是否定的。我认为实际上这在很大程度上取决于微软是否为多播委托本身的构造添加了内存障碍。

关于第二个更有趣的例子。

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 的赋值是如何在构造函数 运行 之前发生的。这是有效的,因为从执行它的线程的角度来看,它没有任何意外的副作用。但是,它可能会对其他线程产生重大影响。如果在写入线程刚刚完成指令 #4 时对读取线程进行 memberFoo 的空检查,会发生什么情况? reader 将看到一个非空值,然后在 daz 变量设置为 10 之前尝试调用 Bardaz 仍将保持其默认值 0,因此导致除以零错误。当然,这主要是理论上的,因为 Microsoft 的 CLR 实现在写入时创建了一个释放栅栏,可以防止这种情况发生。但是,规范在技术上允许这样做。相关内容见this question

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


.NET 2.0 内存模型guarantees:

Writes cannot move past other writes from the same thread.

这意味着写入CPU(示例中的A),永远不会将对对象的引用写入内存(到Q) ,直到 after 它已经写出正在构造的对象的内容(到 R)。到目前为止,一切都很好。无法重新订购:

R = <data>
Q = &R

让我们考虑阅读 CPU (B)。什么是阻止它在从 Q 读取之前从 R 读取?

在一个足够天真的 CPU 上,人们会认为如果不首先阅读 Q 就不可能从 R 中阅读。我们必须先读取Q得到R的地址。 (注意:可以安全地假设 C# 编译器和 JIT 以这种方式运行。)

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

答案似乎。对于理智的缓存一致性协议,失效实现为 queue(因此 "invalidation queue")。所以 R 总是会在 Q 失效之前失效。

显然,唯一不是的硬件是 DEC Alpha (according to Table 1, here). It is the only listed architecture where dependent reads can be re-ordered. (Further reading.)