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

Posted

技术标签:

【中文标题】为啥标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢?【英文标题】:Why is the standard C# event invocation pattern thread-safe without a memory barrier or cache invalidation? What about similar code?为什么标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢? 【发布时间】:2015-08-25 21:52:51 【问题描述】:

在 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 是“足够不可变的”,以至于我们在此线程上使用它时不会主动修改它。)


现在我明白这是线程安全的显而易见的原因:从引用字段读取是原子的。复制到本地确保我们不会得到两个不同的值。 (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,所以随你的便吧。

(我可能推测 writer 线程上的 interlocked-compare-exchange 可能会使 reader 上的缓存无效?或者可能 all读取会导致失效?还是指针取消引用会导致失效?我特别担心这些东西听起来如何特定于平台。)


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

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

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

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

(替代措辞:是什么使对R 的修改对 CPU B 可见,在 Q 的更改对 CPU 可见时或之前可见 B .)

(而且这只适用于使用new分配的内存,还是任何内存?)+


注意:我已经发布了a self-answer here。

【问题讨论】:

我不明白你对原子线程安全的期望。我的理解是 atomic 只意味着不会发生撕裂的读/写,这与缓存一致性无关。 @ChrisO 没错。 memberFoo(或Q)的读取是原子的。但是localFoo.baz(或R)的读取是一个单独的步骤(就像写入一样,在初始化中)。那么是什么保证这两个东西的顺序在不同的 CPU 之间是可见的呢? x86 / x86-64 内存模型不是禁止看到乱序的写入吗? 相关:The C# Memory Model in Theory and Practice。简短的回答是 x86 和 x86-64 比 CLR 内存模型更强大,ARM 和 Itanium 不是,但 JIT 尽其所能提供帮助。 【参考方案1】:

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

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

为什么这样安全?要回答这个问题,您首先必须定义“安全”的含义。 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 进行了空检查,会发生什么情况?读者将看到一个非空值,然后尝试在 daz 变量设置为 10 之前调用 Bardaz 仍将保持其默认值 0,从而导致除以零错误。当然,这主要是理论上的,因为 Microsoft 的 CLR 实现在写入时创建了一个释放栅栏,可以防止这种情况发生。但是,规范在技术上允许这样做。相关内容见this question。

【讨论】:

我不确定您的重新订购示例是否正确。 According to this,.NET 2.0 内存模型保证“写入不能超过来自同一线程的其他写入。” @AndrewRussell:没错。 Microsoft 的 CLI 实现在写入时具有释放栅栏语义。但是,ECMA 规范并没有强制要求(链接的文章暗示了这一点)。尽管极不可能,但其他实现(如 Mono)可能会允许它。 这是重要的一点:ECMA 并不强制要求这是安全的。实际的实现使这很安全,否则很多代码会被破坏。【参考方案2】:

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


.NET 2.0 内存模型guarantees:

写入不能超过来自同一线程的其他写入。

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

R = <data>
Q = &R

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

在足够幼稚的 CPU 上,如果不先读取 Q,就不可能读取 R。我们必须先读取Q才能得到R的地址。 (注意:假设 C# 编译器和 JIT 的行为方式是安全的。)

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

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

显然,唯一不是这种情况的硬件是 DEC Alpha (according to Table 1, here)。它是唯一列出的架构,其中依赖读取可以重新排序。 (Further reading.)

【讨论】:

“.NET 内存模型”是微软的模型。 ECMA 并不能保证这个安全。但是,唯一重要的是微软的内存模型。其他实现(单声道)必须遵循。出于兼容性原因,Microsoft 自己无法打破这一点。【参考方案3】:

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

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

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

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

【讨论】:

不变性这件事并不真正相关(我已经修改了问题以澄清这一点)。我不确定关于 CPU 缓存的事情是否能回答这个问题。我了解 GC 压缩的工作原理。我会再做一次编辑,希望能让事情更清楚。 @AndrewRussell 我并没有真正关注您的编辑 - 基本上听起来像“如果事件列表占用的事件内存永远不会以托管代码可见的方式更改,但如果我们违反了这一点,随机改变内存可能会发生坏事”。 @AndrewRussell 还注意到,Hans Passant 的回答涵盖了问题的“一致与最新”部分,明显优于我的一句话。 所以,从 P 和 Q 开始填充垃圾。 CPU B 看到:“R 已初始化,Q 已初始化”。我们希望 CPU A 看到“R 已初始化,Q 已初始化”。但也许它可以看到“Q 已初始化...... R 已初始化”。我们不希望后一种顺序发生——但是 什么 阻止它发生?这显然不是魔法 - 编译器、JIT,尤其是 CPU 必须做一些事情来强制执行,对吗?【参考方案4】:

评估时:

thing.memberFoo = new Foo(1234);

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

对象池也会发生同样的事情。在分配发生之前,右边的所有内容都会完全评估。

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

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

【讨论】:

好的,所以“在R 的构造函数运行之前,B 永远不会获得对R 的引用” --- 但是什么确保“R 的构造函数已运行”的结果对 CPU B 可见,在Q 的修改对 CPU B 可见之前或之时?没有明确的内存屏障来确保排序。所以它一定是别的东西。 关于链接到operator precedence table 的编辑 - 这仅与单个执行线程中的代码相关。观察代码执行的另一个线程可以看到在显着different order 中发生的事情。【参考方案5】:

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

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

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

【讨论】:

volatile 还插入内存屏障(以确保线程之间的更改可见)。但我的问题是为什么给定的代码是安全的没有内存屏障。 // 您所指的事件使用的无锁同步仅在+=-= 运算符上(不是在访问上,这是我的问题所在)。它使用Interlocked.CompareExchange

以上是关于为啥标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢?的主要内容,如果未能解决你的问题,请参考以下文章

C# 线程中怎么调用有方法的窗体 窗体一闪就没有了 为啥线程中调用MessageBox.Show("");没事 紧急

为啥 C++ 标准库中没有线程池?

为啥用C#在磁盘上写入文件后内存没有释放

C# 单例模式

为啥 C++ 标准没有像 C# 一样添加“属性”?

.NET 中是不是可以使用 C# 实现基于事件的异步模式而无需多线程?