为什么标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢? [英] Why is the standard C# event invocation pattern thread-safe without a memory barrier or cache invalidation? What about similar code?

查看:28
本文介绍了为什么标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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

In C#, this is the standard code for invoking an event in a thread-safe way:

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

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

Where, potentially on another thread, the compiler-generated add method uses Delegate.Combine to create a new multicast delegate instance, which it then sets on the compiler-generated field (using interlocked compare-exchange).

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

(Note: for the purposes of this question, we don't care about code that runs in the event subscribers. Assume that it's thread-safe and robust in the face of removal.)

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

In my own code, I want to do something similar, along these lines:

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

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

Where this.memberFoo could be set by another thread. (It's just one thread, so I don't think it needs to be interlocked - but maybe there's a side-effect here?)

(而且,显然,假设 Foo 是足够不可变的",以至于我们不会在该线程上使用它时主动修改它.)

(And, obviously, assume that Foo is "immutable enough" that we're not actively modifying it while it is in use on this thread.)

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

Now I understand the obvious reason that this is thread-safe: reads from reference fields are atomic. Copying to a local ensures we don't get two different values. (Apparently only guaranteed from .NET 2.0, but I assume it's safe in any sane .NET implementation?)

但是我不明白的是:被引用的对象实例占用的内存怎么办?特别是关于缓存一致性?如果编写器"线程在一个 CPU 上执行此操作:

But what I don't understand is: What about the memory occupied by the object instance that is being referenced? Particularly in regards to cache coherency? If a "writer" thread does this on one CPU:

thing.memberFoo = new Foo(1234);

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

What guarantees that the memory where the new Foo is allocated doesn't happen to be in the cache of the CPU the "reader" is running on, with uninitialized values? What ensures that localFoo.baz (above) doesn't read garbage? (And how well guaranteed is this across platforms? On Mono? On ARM?)

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

And what if the newly created foo happens to come from a pool?

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

从内存的角度来看,这与重新分配似乎没有什么不同 - 但也许 .NET 分配器做了一些魔法来使第一种情况起作用?

This seems no different, from a memory perspective, to a fresh allocation - but maybe the .NET allocator does some magic to make the first case work?

我的想法是,在问这个问题时,需要一个内存屏障来确保 - 不是说内存访问不能移动,因为读取是相关的 - 但作为给 CPU 的信号以刷新任何缓存失效.

My thinking, in asking this, is that a memory barrier would be required to ensure - not so much that memory accesses cannot be moved around, given the read is dependent - but as a signal to the CPU to flush any cache invalidations.

我的资料来源是维基百科,所以您可以随意使用.

My source for this is Wikipedia, so make of that what you will.

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

(I might speculate that maybe the interlocked-compare-exchange on the writer thread invalidates the cache on the reader? Or maybe all reads cause invalidation? Or pointer dereferences cause invalidation? I'm particularly concerned how platform-specific these things sound.)

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

Update: Just to make it more explicit that the question is about CPU cache invalidation and what guarantees .NET provides (and how those guarantees might depend on CPU architecture):

  • 假设我们在字段 Q(一个内存位置)中存储了一个引用.
  • 在 CPU A(编写器)上,我们在内存位置 R 处初始化一个对象,并将对 R 的引用写入 Q
  • 在 CPU B(读取器)上,我们取消引用字段 Q,并取回内存位置 R
  • 然后,在 CPU B 上,我们从 R
  • 读取一个值
  • Say we have a reference stored in field Q (a memory location).
  • On CPU A (writer) we initialize an object at memory location R, and write a reference to R into Q
  • On CPU B (reader), we dereference field Q, and get back memory location R
  • Then, on CPU B, we read a value from R

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

Assume the GC does not run at any point. Nothing else interesting happens.

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

Question: What prevents R from being in B's cache, from before A has modified it during initialisation, such that when B reads from R it gets stale values, in spite of it getting a fresh version of Q to know where R is in the first place?

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

(Alternate wording: what makes the modification to R visible to CPU B at or before the point that the change to Q is visible to CPU B.)

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

(And does this only apply to memory allocated with new, or to any memory?)+

注意:我在此处发布了自我回答.

Note: I've posted a self-answer here.

推荐答案

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

I think I have figured out what the answer is. But I'm not a hardware guy, so I'm open to being corrected by someone more familiar with how CPUs work.

.NET 2.0 内存模型 保证:

The .NET 2.0 memory model guarantees:

写入不能越过来自同一线程的其他写入.

Writes cannot move past other writes from the same thread.

这意味着写入 CPU(示例中的 A)永远不会将对象的引用写入内存(到 Q),直到 之后 它已经写出正在构造的对象的内容(到 R).到现在为止还挺好.这不能重新排序:

This means that the writing CPU (A in the example), will never write a reference to an object into memory (to Q), until after it has written out contents of that object being constructed (to R). So far, so good. This cannot be re-ordered:

R = <data>
Q = &R

<小时>

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


Let's consider the reading CPU (B). What is to stop it reading from R before it reads from Q?

在一个足够简单的 CPU 上,如果不先从 Q 读取数据,就不可能从 R 读取数据.我们必须先读取Q才能得到R的地址.(注意:可以安全地假设 C# 编译器和 JIT 以这种方式运行.)

On a sufficiently naïve CPU, one would expect it to be impossible to read from R without first reading from Q. We must first read Q to get the address of R. (Note: it is safe to assume that the C# compiler and JIT behave this way.)

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

But, if the reading CPU has a cache, couldn't it have stale memory for R in its cache, but receive the updated Q?

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

The answer seems to be no. For sane cache coherency protocols, invalidation is implemented as a queue (hence "invalidation queue"). So R will always be invalidated before Q is invalidated.

显然,唯一不是的硬件是 DEC Alpha (根据表 1,此处).它是唯一列出的可以重新排序依赖读取的架构.(进一步阅读.)

Apparently the only hardware where this is not the case is the DEC Alpha (according to Table 1, here). It is the only listed architecture where dependent reads can be re-ordered. (Further reading.)

这篇关于为什么标准 C# 事件调用模式是线程安全的,没有内存屏障或缓存失效?类似的代码呢?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆