事件和多线程再次 [英] Events and multithreading once again

查看:92
本文介绍了事件和多线程再次的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我担心看似标准的C#6之前版本触发事件的正确性:

I'm worried about the correctness of the seemingly-standard pre-C#6 pattern for firing an event:

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

我已经阅读了埃里克·利珀特(Eric Lippert)的> >事件和种族 ,知道调用过时的事件处理程序还有一个问题,但我担心的是是否允许编译器/JITter优化本地副本,从而有效地将代码重写为

I've read Eric Lippert's Events and races and know that there is a remaining issue of calling a stale event handler, but my worry is whether the compiler/JITter is allowed to optimize away the local copy, effectively rewriting the code as

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

(可能为NullReferenceException.

根据C#语言规范§3.10

According to the C# Language Specification, §3.10,

必须保留这些副作用的顺序的关键执行点是对易失性字段(第10.5.3节),锁语句(第8.12节)以及线程创建和终止的引用.

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.

-因此,在上述模式中没有关键的执行点,并且优化器不受此约束.

— so there are no critical execution points are in the mentioned pattern, and the optimizer is not constrained by that.

Jon Skeet的相关答案(2009年)指出

The related answer by Jon Skeet (year 2009) states

由于条件的原因,不允许JIT执行您在第一部分中提到的优化.我知道这是在不久前提出来的,但这是无效的. (我之前与Joe Duffy或Vance Morrison进行了检查;我不记得是哪个.)

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

-但评论指向此博客文章(2008年): 事件和线程(第4部分) ,它基本上说CLR 2.0的JITter(以及可能的后续版本?)一定不能引入读写功能,因此在Microsoft .NET下一定没有问题. .但这似乎并没有说明其他.NET实现.

— but comments refer to this blog post (year 2008): Events and Threads (Part 4), which basically says that CLR 2.0's JITter (and probably subsequent versions?) must not introduce reads or writes, so there must be no problem under Microsoft .NET. But this seems to say nothing about other .NET implementations.

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

[Side note: I don't see how non-introducing of reads proves the correctness of the said pattern. Couldn't JITter just see some stale value of SomeEvent in some other local variable and optimize out one of the reads, but not the other? Perfectly legitimate, right?]

此外,此MSDN文章(2012年):伊戈尔·奥斯特罗夫斯基(Igor Ostrovsky)的理论与实践指出:

Moreover, this MSDN article (year 2012): The C# Memory Model in Theory and Practice by Igor Ostrovsky states the following:

非重排序优化:某些编译器优化可能会引入或消除某些内存操作.例如,编译器可能用单个读取替换对字段的重复读取.同样,如果代码读取一个字段并将其值存储在本地变量中,然后重复读取该变量,则编译器可以选择重复读取该字段.

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.

由于ECMA C#规范并未排除非重新排序优化,因此可以允许它们.实际上,正如我将在第2部分中讨论的那样,JIT编译器确实执行了这些类型的优化.

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)的回答相矛盾.

This seems to contradict the Jon Skeet's answer.

由于现在C#不再是仅Windows的语言,因此出现了一个问题,即模式的有效性是由于当前CLR实现中有限的JITter优化导致的,还是该语言的预期属性.

As now C# is not a Windows-only language any more, the question arises whether the validity of the pattern is a consequence of limited JITter optimizations in the current CLR implementation, or it is expected property of the language.

因此,问题如下:所讨论的模式从C#语言的角度来看是否有效?(这意味着是否需要使用语言编译器/运行时来禁止某种类型的语言?的优化.)

So, the question is following: is the pattern being discussed valid from the point of view of C#-the-language? (That implies whether a language compiler/runtime is required to prohibit certain kind of optimizations.)

当然,欢迎对该主题进行规范性引用.

Of course, normative references on the topic are welcome.

推荐答案

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

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.

这篇关于事件和多线程再次的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

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