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 中重现,但它确实会在某些特殊情况下发生。