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

标签 c# thread-safety memory-model memory-barriers mesi

在 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 中存储了一个引用(内存位置)。
  • 在 CPU 上 一个 (writer) 我们在内存位置初始化一个对象 R ,并写入对 R 的引用进入 Q
  • 在 CPU 上 (读者),我们取消引用字段 Q ,并取回内存位置 R
  • 然后,在 CPU 上乙 ,我们从 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/

    相关文章:

    c# - 通过 c# .net 使用 gmail 帐户登录

    Qt 使用 QueuedConnection 将两个信号连接在一起

    Java Enum 值子集线程安全

    java - 关于Java内存模型的一个问题

    c# - 在 Linq 的 GroupBy 中取 1

    c# - 许多 else 和 if 语句?

    java - ThreadPoolExecutor.execute() 的内存可见性保证

    c++ - c++11 中的内存建模测试,对 memory_order_relaxed 感到好奇

    c++ - 对 std::atomic 变量的更改(读/写)如何跨线程传播

    c# - 新的 onvif 事件 wsdl 仍未修复?