15

在 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,并将引用写入RQ
  • 在 CPU B(阅读器)上,我们取消引用字段Q,并取回内存位置R
  • 然后,在 CPU B上,我们从R

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

问题:在A在初始化期间对其进行修改之前,是什么阻止RB的缓存,这样当B从它读取时,它会得到陈旧的值,尽管它得到了一个新版本的to know where is in place? RQR

(替代措辞:是什么使修改对RCPU B 在更改对 CPU B可见时或之前对CPU BQ可见。)

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


注意:我在这里发布了一个自我回答

4

5 回答 5

2

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

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

于 2015-08-16T03:17:21.070 回答
2

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


.NET 2.0 内存模型保证

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

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

R = <data>
Q = &R

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

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

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

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

显然,情况并非如此的唯一硬件是 DEC Alpha(根据此处的表 1)。它是唯一列出的架构,其中依赖读取可以重新排序。(进一步阅读。)

于 2015-08-21T03:26:41.050 回答
0

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

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

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

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

于 2015-06-10T15:29:13.810 回答
0

评估时:

thing.memberFoo = new Foo(1234);

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

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

在您的示例中,B将永远不会获得对RbeforeR的构造函数已运行的引用,因为A不会写入R直到QA完成构造R。如果在此之前B读取Q,它将获得Q. 如果R的构造函数抛出异常,则Q永远不会被写入。

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

于 2015-06-10T16:26:51.287 回答
0

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

事件曾经使用锁,但从 C# 4 开始使用无锁同步 - 我不确定具体是什么(请参阅这篇文章)。

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

于 2015-07-20T23:13:26.007 回答