且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

事件和多线程再次

更新时间:2023-02-01 08:48:00

根据您提供的资源以及过去的其他资源,它可以细分为:

According to the sources you provided and a few others in the past, it breaks down to this:

  • With the Microsoft implementation, you can rely on not having read introduction [1][2][3]

对于任何其他实现,除非另有说明,否则可能已阅读介绍

For any other implementation, it may have read introduction unless it states otherwise

仔细地重新阅读了ECMA CLI规范之后,可以进行介绍,但是受到了限制.从分区I,12.6.4优化:

Having re-read the ECMA CLI specification carefully, read introductions are possible, but constrained. From Partition I, 12.6.4 Optimization:

CLI的符合性实现可以***地使用任何技术来执行程序,这些技术可以保证在单个执行线程内,线程产生的副作用和异常按CIL指定的顺序可见.为此,仅易失性操作(包括易失性读取)构成可见的副作用. (请注意,虽然只有易失性操作才构成可见的副作用,但易失性操作也会影响非易失性引用的可见性.)

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.)

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

A very important part of this paragraph is in parentheses:

请注意,虽然只有易失性操作会构成可见的副作用,但易失性操作也会影响非易失性引用的可见性.

因此,如果生成的CIL仅读取一次字段,则实现的行为必须相同.如果它引入了读取,那是因为它可以证明后续读取将产生相同的结果,甚至面临其他线程的副作用.如果无法证明这一点并且仍然引入读取,那就是一个错误.

So, if the generated CIL reads a field only once, the implementation must behave the same. If it introduces reads, it's because it can prove that the subsequent reads will yield the same result, even facing side effects from other threads. If it cannot prove that and it still introduces reads, it's a bug.

以同样的方式,C#语言还在C#到CIL级别上限制了阅读介绍.根据C#语言规范版本5.0、3.10执行顺序:

In the same manner, C# the language also constrains read introduction at the C#-to-CIL level. From the C# Language Specification Version 5.0, 3.10 Execution Order:

继续执行C#程序,以使每个执行线程的副作用都保留在关键执行点上. 副作用 定义为对易失性字段的读取或写入,对非易失性变量的写入,对外部资源的写入以及对例外.必须保留这些副作用的顺序的关键执行点是对易失字段(§10.5.3),lock语句(§8.12)以及线程创建和终止的引用.执行环境可以***更改C#程序的执行顺序,但要遵守以下约束:

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.

保留初始化顺序规则(第10.5.4节和第10.5.5节).

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

关于易失性读写,保留了副作用的顺序(第10.5.3节).此外,如果执行环境可以推断出未使用该表达式的值并且不会产生所需的副作用(包括由调用方法或访问volatile字段引起的副作用),则无需评估该表达式的一部分.当程序执行被异步事件(例如,另一个线程引发的异常)中断时,不能保证可观察到的副作用在原始程序顺序中是可见的.

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.

关于数据依赖的观点是我要强调的一点:

The point about data dependence is the one I want to emphasize:

数据依赖关系保留在执行线程中.也就是说,每个变量的值的计算就像是线程中的所有语句都是按原始程序顺序执行的.

这样,看您的示例(类似于Igor Ostrovsky给出的示例 [4 ] ):

As such, looking at your example (similar to the one given by Igor Ostrovsky [4]):

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

C#编译器永远不应执行阅读介绍.即使可以证明没有干扰的访问,底层的CLI也无法保证SomeEvent上的两个连续的非易失性读取将具有相同的结果.

The C# compiler should not perform read introduction, ever. Even if it can prove that there are no interfering accesses, there's no guarantee from the underlying CLI that two sequential non-volatile reads on SomeEvent will have the same result.

或者,从C#6.0开始使用等效的空条件运算符:

Or, using the equivalent null conditional operator since C# 6.0:

SomeEvent?.Invoke(this, args);

C#编译器应始终扩展到先前的代码(确保唯一的无冲突变量名),而无需执行读取介绍,否则会导致竞争状态.

The C# compiler should always expand to the previous code (guaranteeing a unique non-conflicting variable name) without performing read introduction, as that would leave the race condition.

JIT编译器仅在可以证明不存在干扰访问的情况下才执行读取介绍,具体取决于底层硬件平台,从而使SomeEvent上的两个顺序非易失性读取实际上具有相同的结果.例如,如果该值未保存在寄存器中,并且两次读取之间可能会刷新高速缓存,则情况可能并非如此.

The JIT compiler should only perform the read introduction if it can prove that there are no interfering accesses, depending on the underlying hardware platform, such that the two sequential non-volatile reads on SomeEvent will in fact have the same result. This may not be the case if, for instance, the value is not kept in a register and if the cache may be flushed between reads.

这种优化(如果是局部的)只能在普通(非参考和非输出)参数和未捕获的局部变量上执行.通过方法间或整个程序的优化,可以对共享字段,ref或out参数以及捕获的局部变量执行这些操作,这些事实可以证明它们从未受到其他线程的明显影响.

Such optimization, if local, can only be performed on plain (non-ref and non-out) parameters and non-captured local variables. With inter-method or whole program optimizations, it can be performed on shared fields, ref or out parameters and captured local variables that can be proven they are never visibly affected by other threads.

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

So, there's a big difference whether it's you writing the following code or the C# compiler generating the following code, versus the JIT compiler generating machine code equivalent to the following code, as the JIT compiler is the only one capable of proving if the introduced read is consistent with the single thread execution, even facing potential side-effects caused by other threads:

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

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

An introduced read that may yield a different result is a bug, even according to the standard, as there's an observable difference were the code executed in program order without the introduced read.

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

As such, if the comment in Igor Ostrovsky's example [4] is true, I say it's a bug.

[1]:评论者埃里克·利珀特(Eric Lippert);引用:

要解决您对ECMA CLI规范和C#规范的观点:CLR 2.0提出的更强大的内存模型承诺是 Microsoft 做出的承诺.决定使用自己的C#实现生成可在自己的CLI实现上运行的代码的第三方,可以选择较弱的内存模型,但仍符合规范.我不知道莫诺团队是否这样做.您必须要问他们.

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内存模型乔·达菲(Joe Duffy)重申了下一个链接;引用相关部分:

[2]: CLR 2.0 memory model by Joe Duffy, reiterating the next link; quoting the relevant part:

  • 规则1:永远不会违反加载和存储之间的数据依赖性.
  • 规则2:所有商店都具有发布语义,即没有负载或一个商店可能会移动.
  • 规则3:获取所有易失性负载,即任何负载或存储都不得移动.
  • 规则4:任何加载和存储都不可能越过完整屏障(例如Thread.MemoryBarrier,锁获取,Interlocked.Exchange,Interlocked.CompareExchange等).
  • 规则5:可能永远不会引入到堆中的加载和存储.
  • 规则6:只有在从/到同一位置合并相邻的负载和存储时,才可以删除负载和存储.
  • 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]:了解低锁技术在多线程应用程序中的影响,作者是万斯·莫里森(Vance Morrison),这是我可以从Internet档案库中获得的最新快照;引用相关部分:

[3]: Understand the Impact of Low-Lock Techniques in Multithreaded Apps by Vance Morrison, the latest snapshot I could get on the Internet Archive; quoting the relevant portion:

强大的模型2:.NET Framework 2.0

(...)

  1. ECMA模型中包含的所有规则,尤其是三个基本内存模型规则以及volatile的ECMA规则.
  2. 无法进行读写操作.
  3. 仅当读取与从同一线程到同一位置的另一个读取相邻时,才能将其删除.如果写入与从同一线程到同一位置的另一写入相邻,则只能删除该写入.在应用此规则之前,可以使用规则5进行相邻的读取或写入.
  4. 写入不能从同一线程移至其他写入.
  5. 读操作只能在更早的时间内移动,而永远不能超过从同一线程到同一内存位置的写操作.
  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#-理论和实践中的C#内存模型,第2部分在Igor Ostrovsky的书中,他展示了一个阅读介绍示例,据他说,JIT可能会执行两次这样的后续阅读可能会产生不同结果的例子;引用相关部分:

[4]: C# - The C# Memory Model in Theory and Practice, Part 2 by Igor Ostrovsky, where he shows a read introduction example that, according to him, the JIT may perform such that two consequent reads may have different results; quoting the relevant part:

阅读介绍正如我刚刚解释的那样,编译器有时会将多个读取合并为一个.编译器还可以将单个读取拆分为多个读取.在.NET Framework 4.5中,读简介比读消除更不常见,并且仅在非常罕见的特定情况下发生.但是,有时确实会发生.

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;
  }
}

如果您检查PrintObj方法,则看起来obj.ToString表达式中的obj值永远不会为null.但是,该行代码实际上可能抛出NullReferenceException. CLR JIT可能会编译PrintObj方法,就像它是这样编写的:

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());
  }
}

由于_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.

请注意,在x86-x64上的.NET Framework 4.5中,您将无法使用此代码示例来重现NullReferenceException.阅读简介很难在.NET Framework 4.5中重现,但是在某些特殊情况下确实会发生.

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.