事件和多线程再一次

Events and multithreading once again

我担心用于触发事件的看似标准的 C#6 之前模式的正确性:

EventHandler localCopy = SomeEvent;
if (localCopy != null)
    localCopy(this, args);

我读过 Eric Lippert 的 Events and races 并且知道调用陈旧的事件处理程序仍然存在问题,但我担心是否允许 compiler/JITter 优化掉本地副本,有效地将代码重写为

if (SomeEvent != null)
    SomeEvent(this, args);

有可能 NullReferenceException.

根据 C# 语言规范,§3.10,

The critical execution points at which the order of these side effects must be preserved are references to volatile fields (§10.5.3), lock statements (§8.12), and thread creation and termination.

——所以在上述模式中没有关键执行点,优化器不受此约束。

相关 answer by Jon Skeet(2009 年)状态

The JIT isn't allowed to perform the optimization you're talking about in the first part, because of the condition. I know this was raised as a spectre a while ago, but it's not valid. (I checked it with either Joe Duffy or Vance Morrison a while ago; I can't remember which.)

— 但评论参考了这个博客 post(2008 年):Events and Threads (Part 4),它基本上说 CLR 2.0 的 JITter(可能还有后续版本?)不得引入读取或写入,所以在Microsoft .NET下肯定没有问题。但这似乎与其他 .NET 实现无关。

[旁注:我看不出不引入读取如何证明上述模式的正确性。难道 JITter 不能只在其他局部变量中看到 SomeEvent 的一些陈旧值并优化其中一个读取,而不是另一个读取吗?完全合法,对吧?]

此外,这篇 MSDN 文章(2012 年):The C# Memory Model in Theory and Practice 作者 Igor Ostrovsky 指出以下内容:

Non-Reordering Optimizations Some compiler optimizations may introduce or eliminate certain memory operations. For example, the compiler might replace repeated reads of a field with a single read. Similarly, if code reads a field and stores the value in a local variable and then repeatedly reads the variable, the compiler could choose to repeatedly read the field instead.

Because the ECMA C# spec doesn’t rule out the non-reordering optimizations, they’re presumably allowed. In fact, as I’ll discuss in Part 2, the JIT compiler does perform these types of optimizations.

这似乎与 Jon Skeet 的回答相矛盾。

因为现在 C# 不再是 Windows-only 语言,问题是模式的有效性是当前 CLR 实现中有限的 JITter 优化的结果,还是预期 属性 的语言。

因此,问题如下:从 C# 语言的角度来看,所讨论的模式是否有效?(这意味着一种语言是否 compiler/runtime 需要禁止某些类型的优化。)

当然,欢迎对该主题的规范性参考。

不允许优化器将存储在稍后使用的局部变量中的代码模式转换为对该变量的所有使用都只是用于初始化它的原始表达式。这不是要进行的有效转换,因此它不是 "optimization"。表达式可能会导致或依赖于副作用,因此表达式需要 运行,存储在某处,然后在指定时使用。当您的代码只完成一次时,运行时间将事件解析为委托两次将是无效转换。

就重新排序而言;操作的重新排序非常复杂 相对于多线程,但是这个模式的全部要点是你现在正在做相关的逻辑 线程上下文。事件的值存储在本地变量中,并且相对于其他线程中的任何代码运行,可以或多或少地任意排序该读取,但无法重新读取该值到本地变量的读取针对同一线程的后续操作重新排序,即if检查或该委托的调用。

鉴于此,该模式确实做了它打算做的事情,即拍摄事件的快照并调用所有处理程序(如果有),而不会因为没有任何处理程序而抛出 NRE。

根据您提供的来源和过去的其他一些来源,它分解为:

  • 使用 Microsoft 实现,您可以依靠 而不是 阅读介绍 [1] [2] [3]

  • 对于任何其他实现,它可能已阅读简介,除非另有说明

编辑:仔细阅读 ECMA CLI 规范后,可以阅读介绍,但有限制。来自分区 I,12.6.4 优化:

Conforming implementations of the CLI are free to execute programs using any technology that guarantees, within a single thread of execution, that side-effects and exceptions generated by a thread are visible in the order specified by the CIL. For this purpose only volatile operations (including volatile reads) constitute visible side-effects. (Note that while only volatile operations constitute visible side-effects, volatile operations also affect the visibility of non-volatile references.)

本段非常重要的部分在括号中:

Note that while only volatile operations constitute visible side-effects, volatile operations also affect the visibility of non-volatile references.

因此,如果生成的 CIL 只读取一个字段一次,则实现的行为必须相同。如果它引入读,那是因为它可以证明后续的读会产生相同的结果,即使面临来自其他线程的副作用。如果它不能证明这一点并且仍然引入读取,那就是一个错误。

以同样的方式,C#语言也在C#-to-CIL级别限制了阅读介绍。来自 C# Language Specification Version 5.0, 3.10 Execution Order:

Execution of a C# program proceeds such that the side effects of each executing thread are preserved at critical execution points. A side effect is defined as a read or write of a volatile field, a write to a non-volatile variable, a write to an external resource, and the throwing of an exception. The critical execution points at which the order of these side effects must be preserved are references to volatile fields (§10.5.3), lock statements (§8.12), and thread creation and termination. The execution environment is free to change the order of execution of a C# program, subject to the following constraints:

  • Data dependence is preserved within a thread of execution. That is, the value of each variable is computed as if all statements in the thread were executed in original program order.

  • Initialization ordering rules are preserved (§10.5.4 and §10.5.5).

  • The ordering of side effects is preserved with respect to volatile reads and writes (§10.5.3). Additionally, the execution environment need not evaluate part of an expression if it can deduce that that expression’s value is not used and that no needed side effects are produced (including any caused by calling a method or accessing a volatile field). When program execution is interrupted by an asynchronous event (such as an exception thrown by another thread), it is not guaranteed that the observable side effects are visible in the original program order.

数据依赖这一点是我想强调的:

Data dependence is preserved within a thread of execution. That is, the value of each variable is computed as if all statements in the thread were executed in original program order.

因此,看看你的例子(类似于 Igor Ostrovsky [4] 给出的例子):

EventHandler localCopy = SomeEvent;
if (localCopy != null)
    localCopy(this, args);

C# 编译器永远不应执行读取引入。即使它可以证明没有干扰访问,底层 CLI 也不能保证 SomeEvent 上的两次连续非易失性读取会产生相同的结果。

或者,使用自 C# 6.0 起等效的 null 条件运算符:

SomeEvent?.Invoke(this, args);

C# 编译器应始终在不执行读取引入的情况下扩展到以前的代码(保证唯一的非冲突变量名),因为那样会导致竞争条件。

如果 JIT 编译器可以证明不存在干扰访问,则只应执行读取引入,具体取决于底层硬件平台,这样 SomeEvent 上的两个顺序非易失性读取实际上将有相同的结果。例如,如果值未保存在寄存器中并且缓存可能在读取之间刷新,则情况可能并非如此。

这种优化,如果是局部的,只能在普通(非引用和非输出)参数和非捕获的局部变量上执行。通过方法间或整个程序优化,它可以在共享字段、ref 或 out 参数和捕获的局部变量上执行,这些局部变量可以证明它们永远不会受到其他线程的明显影响。

因此,无论是您编写以下代码还是 C# 编译器生成以下代码,与 JIT 编译器生成与以下代码等效的机器代码,都有很大的区别,因为 JIT 编译器是唯一能够证明引入的读取是否与单线程执行一致,即使面临其他线程引起的潜在副作用:

if (SomeEvent != null)
    SomeEvent(this, args);

可能产生不同结果的引入读取是 错误,即使根据标准也是如此,因为在没有引入读取的情况下按程序顺序执行的代码存在明显差异.

因此,如果 Igor Ostrovsky 的例子 [4] 中的评论是真的,我说这是一个错误。


[1]: A comment by Eric Lippert;引用:

To address your point about the ECMA CLI spec and the C# spec: the stronger memory model promises made by CLR 2.0 are promises made by Microsoft. A third party that decided to make their own implementation of C# that generates code that runs on their own implementation of CLI could choose a weaker memory model and still be compliant with the specifications. Whether the Mono team has done so, I do not know; you'll have to ask them.

[2]: CLR 2.0 memory model by Joe Duffy,重申下一个 link;引用相关部分:

  • Rule 1: Data dependence among loads and stores is never violated.
  • Rule 2: All stores have release semantics, i.e. no load or store may move after one.
  • Rule 3: All volatile loads are acquire, i.e. no load or store may move before one.
  • Rule 4: No loads and stores may ever cross a full-barrier (e.g. Thread.MemoryBarrier, lock acquire, Interlocked.Exchange, Interlocked.CompareExchange, etc.).
  • Rule 5: Loads and stores to the heap may never be introduced.
  • Rule 6: Loads and stores may only be deleted when coalescing adjacent loads and stores from/to the same location.

[3]: Understand the Impact of Low-Lock Techniques in Multithreaded Apps 作者 Vance Morrison,这是我在 Internet Archive 上可以获得的最新快照;引用相关部分:

Strong Model 2: .NET Framework 2.0

(...)

  1. All the rules that are contained in the ECMA model, in particular the three fundamental memory model rules as well as the ECMA rules for volatile.
  2. Reads and writes cannot be introduced.
  3. A read can only be removed if it is adjacent to another read to the same location from the same thread. A write can only be removed if it is adjacent to another write to the same location from the same thread. Rule 5 can be used to make reads or writes adjacent before applying this rule.
  4. Writes cannot move past other writes from the same thread.
  5. Reads can only move earlier in time, but never past a write to the same memory location from the same thread.

[4]: C# - The C# Memory Model in Theory and Practice, Part 2 作者 Igor Ostrovsky,他在其中展示了一个读取介绍示例,根据他的说法,JIT 可能会执行这样的操作,因此两个后续读取可能会产生不同的结果;引用相关部分:

Read Introduction As I just explained, the compiler sometimes fuses multiple reads into one. The compiler can also split a single read into multiple reads. In the .NET Framework 4.5, read introduction is much less common than read elimination and occurs only in very rare, specific circumstances. However, it does sometimes happen.

To understand read introduction, consider the following example:

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString());
    // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

If you examine the PrintObj method, it looks like the obj value will never be null in the obj.ToString expression. However, that line of code could in fact throw a NullReferenceException. The CLR JIT might compile the PrintObj method as if it were written like this:

void PrintObj() {
  if (_obj != null) {
    Console.WriteLine(_obj.ToString());
  }
}

Because the read of the _obj field has been split into two reads of the field, the ToString method may now be called on a null target.

Note that you won’t be able to reproduce the NullReferenceException using this code sample in the .NET Framework 4.5 on x86-x64. Read introduction is very difficult to reproduce in the .NET Framework 4.5, but it does nevertheless occur in certain special circumstances.