C# 编译器优化

 

正如在第一篇文章中提到的,编译器可能通过对内存操作进行重新排序来优化代码。 在 .NET Framework 4.5 中,将 C# 编译为 IL 的 csc.exe 编译器并不执行大量的优化操作,因此该编译器不会对内存操作进行重新排序。 但将 IL 编译为机器码的实时 (JIT) 编译器实际上将执行一些对内存操作进行重新排序的优化,我将在下文对此予以介绍。


1、循环读取提升

请考虑下面的轮询循环模式:
C#

class Test
{
  private bool _flag = true;
  public void Run()
  {
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate!
}
}

在这个示例中,.NET 4.5 JIT 编译器可能按如下所示重写循环:
C#

if (_flag) { while (true); }

对于单线程而言,此项转换完全合法,并且将读取提升出循环通常是一种出色的优化方法。 但如果在另一个线程上将 _flag 设置为 false,则优化可能导致挂起。
请注意,如果 _flag 字段是可变字段,则 JIT 编译器不会将读取提升出循环。 (有关对此模式更详细的介绍,请参见我在十二月发表的文章中的“轮询循环”部分。)


2、读取消除

以下示例说明了另一个可能导致多线程代码出现错误的编译器优化:
C#

class Test
{
  private int _A, _B;
  public void Foo()
  {
    int a = _A;
    int b = _B;
    ...
}
}


此类包含两个非可变字段:_A 和 _B。 方法 Foo 先读取字段 _A,然后读取字段 _B。 但由于这两个字段是非可变字段,因此编译器可自由地对两个读取进行重新排序。 因此,如果算法的正确与否取决于读取顺序,则程序将包含错误。
很难想象编译器通过交换读取顺序将获得什么结果。 根据 Foo 的编写方式,编译器可能不会交换读取顺序。
但如果我在 Foo 方法的顶部再添加一个无关紧要的语句,则确实会进行重新排序:
C#

public bool Foo()
{
  if (_B == -1) throw new Exception(); // Extra read
  int a = _A;
  int b = _B;
  return a > b;
}


在 Foo 方法的第一行上,编译器将 _B 的值加载到寄存器中。 然后,_B 的第二次加载将仅使用寄存器中已有的值,而不发出实际的加载指令。
实际上,编译器将按如下所示重写 Foo 方法:
C#

public bool Foo()
{
  int b = _B;
  if (b == -1) throw new Exception(); // Extra read
  int a = _A;
  return a > b;
}


尽管此代码示例大体上比较接近编译器优化代码的方式,但了解一下此代码的反汇编也很有指导意义:
C#

if (_B == -1) throw new Exception();
  push        eax
  mov         edx,dword ptr [ecx+8]
  // Load field _B into EDX register
  cmp         edx,0FFFFFFFFh
  je          00000016
int a = _A;
  mov         eax,dword ptr [ecx+4]
  // Load field _A into EAX register
return a > b;
  cmp         eax,edx
  // Compare registers EAX and EDX
...


即使您不了解汇编,也很容易理解以上代码中所执行的操作。 在计算条件 _B == -1 的过程中,编译器将字段 _B 加载到 EDX 寄存器中。 此后再次读取字段 _B 时,编译器仅重用 EDX 中已有的值,而不发出实际的内存读取指令。 因此,_A 和 _B 的读取被重新排序。

在此示例中,正确的解决方案是将字段 _A 标记为可变字段。 如果完成此项标记,编译器便不会对 _A 和 _B 的读取进行重新排序,因为 _A 的加载具有加载-获取语义。 但需要指出的是,.NET Framework(版本 4 以及早期的版本)不会正确地处理此示例,实际上将字段 _A 标记为可变字段不会禁止读取重新排序。 .NET Framework 4.5 版已修复此问题。


3、读取引入

正如我刚刚介绍的,编译器有时会将多个读取融合为一个读取。 编译器还可以将单个读取拆分为多个读取。 在 .NET Framework 4.5 中,读取引入与读取消除相比并不常用,并仅在极少数的特定情况下才会发生。 但它有时确实会发生。
要了解读取引入,请考虑以下示例:
C#

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 方法进行编译,就好像它是用以下代码编写的:
C#

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

由于 _obj 字段的读取已经拆分为该字段的两个读取,因此 ToString 方法现在可能在一个值为 null 的目标上被调用。

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

posted @ 2022-01-08 17:16  小林野夫  阅读(294)  评论(1编辑  收藏  举报
原文链接:https://www.cnblogs.com/cdaniu/