首页 > 代码库 > 理论与实践中的 C# 内存模型,第 2 部分

理论与实践中的 C# 内存模型,第 2 部分

转载自:https://msdn.microsoft.com/zh-cn/magazine/jj883956.aspx

这是介绍 C# 内存模型的系列文章的第二篇(共两篇)。 正如在 MSDN 杂志十二月刊的第一篇文章 (msdn.microsoft.com/magazine/jj863136) 中所介绍的,编译器和硬件可能会悄然改变程序的内存操作,尽管其方式不会影响单线程行为,但可能会影响多线程行为。 例如,请考虑以下方法:

 
void Init() {
  _data = http://www.mamicode.com/42;>

如果 _data 和 _initialized 是普通(即,非可变)字段,则允许编译器和处理器对这些操作重新排序,以便 Init 执行起来就像是用以下代码编写的:

 
void Init() {
  _initialized = true;
  _data = http://www.mamicode.com/42;>

在上一篇文章中,我介绍了抽象 C# 内存模型。 本文将介绍如何在 Microsoft .NET Framework 4.5 支持的不同体系结构上实际实现 C# 内存模型。

编译器优化

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

循环读取提升 请考虑下面的轮询循环模式:

 
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 编译器可能按如下所示重写循环:

 
if (_flag) { while (true); }

对于单线程而言,此项转换完全合法,并且将读取提升出循环通常是一种出色的优化方法。 但如果在另一个线程上将 _flag 设置为 false,则优化可能导致挂起。

请注意,如果 _flag 字段是可变字段,则 JIT 编译器不会将读取提升出循环。 (有关对此模式更详细的介绍,请参见我在十二月发表的文章中的“轮询循环”部分。)

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

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

此类包含两个非可变字段:_A 和 _B。 方法 Foo 先读取字段 _A,然后读取字段 _B。 但由于这两个字段是非可变字段,因此编译器可自由地对两个读取进行重新排序。 因此,如果算法的正确与否取决于读取顺序,则程序将包含错误。

很难想象编译器通过交换读取顺序将获得什么结果。 根据 Foo 的编写方式,编译器可能不会交换读取顺序。

但如果我在 Foo 方法的顶部再添加一个无关紧要的语句,则确实会进行重新排序:

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

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

实际上,编译器将按如下所示重写 Foo 方法:

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

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

 
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 版已修复此问题。

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

要了解读取引入,请考虑以下示例:

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

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

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

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

x86-x64 上的 C# 内存模型实现

由于 x86 和 x64 在内存模型方面的行为相同,因此我将这两个处理器版本放在一起进行考虑。

与某些体系结构不同,x86-x64 处理器在内存操作方面提供了非常有力的排序保证。 实际上,JIT 编译器无需在 x86-x64 上使用任何特殊的指令便可以实现可变语义;普通的内存操作已经提供了这些语义。 即便如此,在某些特定的情况下,x86-x64 处理器仍会对内存操作进行重新排序。

x86-x64 内存重新排序 即使 x86-x64 处理器提供了非常有力的排序保证,特定类型的硬件重新排序仍会发生。

x86-x64 处理器既不会对两个写入进行重新排序,也不会对两个读取进行重新排序。 唯一可能的重新排序效果就是,当处理器写入值时,该值不会立即可用于其他处理器。 图 1 显示了一个展示此行为的示例。

图 1 StoreBufferExample

 
class StoreBufferExample
{
  // On x86 .NET Framework 4.5, it makes no difference
  // whether these fields are volatile or not
  volatile int A = 0;
  volatile int B = 0;
  volatile bool A_Won = false;
  volatile bool B_Won = false;
  public void ThreadA()
  {
    A = true;
    if (!B) A_Won = true;
  }
  public void ThreadB()
  {
    B = true;
    if (!A) B_Won = true;
  }
}

考虑一下,当从 StoreBufferExample 新实例的不同线程上调用方法 ThreadA 和 ThreadB(如图 2 所示)时,将出现什么情况。 如果您思考一下图 2 中的程序可能产生的结果,则似乎可能得出三个结论:

  1. 线程 1 在线程 2 开始之前完成。 结果是 A_Won=true,B_Won=false。
  2. 线程 2 在线程 1 开始之前完成。 结果是 A_Won=false,B_Won=true。
  3. 线程交错。 结果是 A_Won=false,B_Won=false。

技术分享
图 2 从不同的线程中调用 ThreadA 和 ThreadB 方法

但出乎意料的是,竟然还会出现第四种情况: 在这段代码运行完毕后,A_Won 和 B_Won 字段的值可能同时为 true! 存储缓冲区的存在可能导致存储“延迟”,从而最终导致与后续的加载互换顺序。 尽管此结果与线程 1 和线程 2 的任何交错执行不一致,但它仍会发生。

该示例很有趣,这是因为尽管我们的处理器 (x86-x64) 具有相对较强的排序能力,并且所有字段均为可变字段,但我们仍观察到内存操作的重新排序。 尽管向 A 的写入是可变的,并且从 A_Won 进行的读取也是可变的,但防护却都是单向的,并且实际上允许这一重新排序。 因此,ThreadA 方法可能会高效执行,就好像它是用以下代码编写的:

 
public void ThreadA()
{
  bool tmp = B;
  A = true;
  if (!tmp) A_Won = 1;
}

一种可能的修复方法是在 ThreadA 和 ThreadB 中均插入内存屏障。 更新后的 ThreadA 方法将如下所示:

 
public void ThreadA()
{
  A = true;
  Thread.MemoryBarrier();
  if (!B) aWon = 1;
}

CLR JIT 将插入“lock or”指令来代替内存屏障。 锁定的 x86 指令会产生副作用,即刷新存储缓冲区:

 
mov         byte ptr [ecx+4],1
lock or     dword ptr [esp],0
cmp         byte ptr [ecx+5],0
jne         00000013
mov         byte ptr [ecx+6],1
ret

有一点需要指出,Java 编程语言采用不同的方法。 Java 内存模型对于“可变”的定义更严格一些,此定义不允许“存储-加载”重新排序,因此 x86 上的 Java 编译器通常会在可变写入之后发出锁定指令。

x86-x64 备注:x86 处理器有一个非常强大的内存模型,硬件级别的唯一重新排序源是存储缓冲区。 存储缓冲区可导致写入与后续的读取互换顺序(存储-加载重新排序)。

此外,某些编译器优化可导致内存操作重新排序。 需要注意的是,如果多个读取操作访问相同的内存位置,编译器可能选择只执行读取一次,并将值存储在寄存器中以供后续读取使用。

值得一提的是,C# 可变语义与 x86-x64 硬件做出的硬件重新排序保证非常相符。 因此,可变字段的读取和写入不需要 x86 上的特殊指令: 普通读取和写入(例如,使用 MOV 指令)足以满足需求。 当然,您的代码不应依赖这些实现细节,因为不同的硬件体系结构以及可能的 .NET 版本具有不同的细节。

Itanium 体系结构上的 C# 内存模型实现

Itanium 硬件体系结构的内存模型弱于 x86-x64 的内存模型。 Itanium 由 .NET Framework 版本 4 以及早期版本提供支持。

即使 .NET Framework 4.5 不再支持 Itanium,但当您阅读有关 .NET 内存模型的旧文章并且必须维护采纳了这些文章中的建议的代码时,了解 Itanium 内存模型仍很有用。

Itanium 重新排序 Itanium 的指令集不同于 x86-x64,并且内存模型概念显示在指令集中。 Itanium 对普通加载 (LD) 和加载-获取 (LD.ACQ) 以及普通存储 (ST) 和存储-释放 (ST.REL) 加以区分。

只要单线程行为保持不变,硬件便可以自由地对普通加载和存储进行重新排序。 例如,请看下面的代码:

 
class ReorderingExample
{
  int _a = 0, _b = 0;
  void PrintAB()
  {
    int a = _a;
    int b = _b;
    Console.WriteLine("A:{0} B:{1}", a, b);
  }
  ...
}

考虑 PrintAB 方法中 _a 和 _b 的两个读取。 由于读取操作访问普通的非可变字段,因此编译器将使用普通 LD(而非 LD.ACQ)来实现读取。 因此,这两个读取可能会有效地在硬件中进行重新排序,从而使 PrintAB 执行起来就像是用以下代码编写的:

 
void PrintAB()
{
  int b = _b;
  int a = _a;
  Console.WriteLine("A:{0} B:{1}", a, b);
}

在实际情况下,重新排序是否发生取决于各种不可预知的因素 — 处理器缓存中的内容、处理器管道的繁忙程度,等等。 然而,如果两个读取通过数据依赖性而彼此相关,则处理器不会对其进行重新排序。 如果内存读取返回的值决定后续读取的读取位置,则说明这两个读取之间存在数据依赖性。

以下示例说明了数据依赖性:

 
class Counter { public int _value; }
class Test
{
  private Counter _counter = new Counter();
  void Do()
  {
    Counter c = _counter; // Read 1
    int value = http://www.mamicode.com/c._value; // Read 2>

在 Do 方法中,Itanium 始终都不会对 Read 1 和 Read 2 进行重新排序,即便 Read 1 是普通加载而非加载-获取也不例外。 有一点似乎是显而易见的,那就是这两个读取无法重新排序: 第一个读取将决定第二个读取应访问的内存位置! 然而,除 Itanium 以外的某些其他处理器实际上可能会对读取进行重新排序。 处理器可能猜测 Read 1 将返回的值,并推测性地执行 Read 2,甚至会在 Read 1 已经完成之前执行。 不过,需要再次指出的是,Itanium 不会执行此项操作。

我将回过头来再简要介绍一下 Itanium 中的数据依赖性,以便更加清晰地阐明它与 C# 内存模型的相关性。

此外,如果两个普通读取通过控制依赖性而彼此相关,则 Itanium 将不会对其进行重新排序。 如果读取返回的值决定后续指令能否执行,则说明存在控制依赖性。

因此,在以下示例中,_initialized 和 _data 的读取通过控制依赖性相关:

 
void Print() {
  if (_initialized)            // Read 1
    Console.WriteLine(_data);  // Read 2
  else
    Console.WriteLine("Not initialized");
}

即使 _initialized 和 _data 是普通(非可变)读取,Itanium 处理器也不会对其进行重新排序。 请注意,JIT 编译器仍可自由地对两个读取进行重新排序,并且在某些情况下会执行此操作。

此外,需要指出的是,与 x86-x64 处理器一样,Itanium 也使用存储缓冲区,因此图 1 中显示的 StoreBufferExample 就像在 x86-x64 上那样在 Itanium 中进行相同类型的重新排序。 比较有趣的一点是,如果您在 Itanium 上对所有读取使用 LD.ACQ 并对所有写入使用 ST.REL,那么您基本上实现了 x86-x64 内存模型,其中的存储缓冲区将是唯一的重新排序源。

Itanium 上的编译器行为 CLR JIT 编译器在 Itanium 上有一个令人吃惊的行为: 所有写入均作为 ST.REL 而非 ST 发出。 因此,可变写入和非可变写入通常会在 Itanium 上发出相同的指令。 但普通读取将作为 LD 发出;只有可变字段中的读取作为 LD.ACQ 发出。

此行为的出现可能会令人感到惊讶,这是因为编译器没必要对非可变写入发出 ST.REL。 就欧洲计算机厂家协会 (ECMA) C# 规范而言,编译器可以发出普通的 ST 指令。 发出 ST.REL 只是编译器选择执行的额外操作,目的是确保特定的通用(但在理论上是错误的)模式将按预期的方式工作。

一个对于写入必须使用 ST.RE 而对读取使用 LD 即可满足需要的重要模式究竟是什么样子是很难想象的。 在此部分的前面所展示的 PrintAB 示例中,仅仅限制写入不会有任何帮助,原因是读取仍被重新排序。

有一个非常重要的方案(在此方案中将 ST.REL 与普通 LD 一起使用即可满足要求): 当加载本身使用数据依赖性进行排序时。 此模式以迟缓初始化的方式呈现,后者是一个非常重要的模式。 图 3 显示了一个迟缓初始化示例。

图 3 迟缓初始化

 
// Warning: Might not work on future architectures and .NET versions;
// do not use
class LazyExample
{
  private BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      lock(this)
      {
        if (_boxedInt == null)
        {
          b = new BoxedInt();
          b._value = http://www.mamicode.com/42;  // Write 1>

为了让这段代码始终返回 42(即使从多个线程中同时调用 GetInt 时也不例外),Read 1 不得与 Read 2 交换顺序,且 Write 1 不得与 Write 2 交换顺序。 由于这两个读取通过数据依赖性而彼此相关,因此 Itanium 处理器不会对其进行重新排序。 同时,这两个写入也不会被重新排序,因为 CLR JIT 会将其作为 ST.REL 发出。

请注意,如果 _boxedInt 字段是可变字段,则根据 ECMA C# 规范,此代码将是正确的。 这种正确的编码方式不但效果最佳,而且注定是唯一一种比较切合实际的方式。 然而,即使 _boxed 不是可变字段,编译器的当前版本也会确保代码在实际情况下仍可以在 Itanium 上正常运行。

当然,正如在 x86-x64 上那样,Itanium 上的 CLR JIT 可能会执行循环读取提升、读取消除和读取引入。

Itanium 备注 Itanium 之所以引人注意,是因为它是第一个提供了运行 .NET Framework 的弱内存模型的体系结构。

因此,在某些介绍 C# 内存模型、可变关键字以及 C# 的文章中,作者通常都会想到 Itanium。 不管怎么说,在 .NET Framework 4.5 推出之前,Itanium 是除 x86-x64 以外唯一运行 .NET Framework 的体系结构。

因此,作者可能会说类似“在 .NET 2.0 内存模型中,所有写入都是可变的 — 即使是那些针对非可变字段的写入也不例外”这样的话。作者想要表达的思想是,在 Itanium 上,CLR 会将所有写入作为 ST.REL 发出。 此行为不受 ECMA C# 规范的保证,因此在未来版本的 .NET Framework 以及未来的体系结构中可能不复存在(实际上,在 ARM 上的 .NET Framework 4.5 中已经不存在)。

与此类似,某些人会认为迟缓初始化在 .NET Framework 中是正确的,即使所在字段是非可变的也是如此,而其他人可能会认为该字段必须是可变的。

当然,开发人员会针对这些(有时是对立的)假设编写代码。 因此,当您尝试理解由其他人编写的并发代码、阅读旧文章甚至是与其他开发人员交谈时,了解 Itanium 的相关功能可能会很有帮助。

ARM 上的 C# 内存模型实现

ARM 体系结构是 .NET Framework 支持的体系结构列表中最新加入的体系结构。 与 Itanium 一样,ARM 的内存模型也弱于 x86-x64。

ARM 重新排序 与 Itanium 一样,ARM 也可以自由地对普通读取和写入进行重新排序。 但 ARM 提供的用于控制读写移动的解决方案与 Itanium 的相应解决方案略有不同。 ARM 公开了一个指令 — DMB,该指令用作完全的内存屏障。 任何内存操作都不会在任一方向传递 DMB。

除了 DMB 指令施加的限制以外,ARM 还支持数据依赖性,但不支持控制依赖性。 有关数据依赖性和控制依赖性的介绍,请参见本文前面的“Itanium 重新排序”部分。

ARM 上的编译器行为 DMB 指令用于实现 C# 中的可变语义。 在 ARM 上,CLR JIT 使用后跟 DMB 指令的普通读取(例如 LDR)实现从可变字段中进行的读取。 由于 DMB 指令将禁止可变读取与任何后续操作交换顺序,因此该解决方案将正确实现获取语义。

向可变字段的写入使用后跟普通写入(例如 STR)的 DMB 指令实现。 由于 DMB 指令禁止可变写入与之前的任何操作交换顺序,因此该解决方案将正确实现释放语义。

与 Itanium 处理器一样,超越 ECMA C# 规范并保持迟缓初始化模式正常工作将是一个不错的方法,因为很多现有代码都依赖于该模式。 但使所有写入都有效地成为可变写入并不是 ARM 上的一个良好解决方案,这是因为 DBM 指令的开销很高。

在 .NET Framework 4.5 中,CLR JIT 使用一种略有不同的方法确保迟缓初始化正常工作。 下列写入被视为“释放”屏障:

  1. 向垃圾收集器 (GC) 堆上的引用类型字段的写入
  2. 向引用类型静态字段的写入

因此,任何可能发布对象的写入均被视为释放屏障。

以下是 LazyExample 的相关部分(需要重申的是,任何字段都不是可变字段):

 
b = new BoxedInt();
b._value = http://www.mamicode.com/42;  // Write 1>

由于 CLR JIT 在将对象发布到 _boxedInt 字段中之前发出 DMB 指令,因此 Write 1 和 Write 2 将不会交换顺序。 同时,由于 ARM 支持数据依赖性,因此迟缓初始化模式下的读取也不会交换顺序,并且代码将在 ARM 上正常工作。

因此,CLR JIT 将执行额外的工作(超出 ECMA C# 规范中强制要求的内容)以使迟缓初始化的最常见变体在 ARM 上正常工作。

对于 ARM,最后需要说明的是,就 CLR JIT 而言,循环读取提升、读取消除和读取引入均为合法优化,这一点与在 x86-x64 和 Itanium 上一样。

示例: 迟缓初始化

了解迟缓初始化模式的几个不同变体并思考一下它们在不同体系结构上的行为方式可能很有指导意义。

正确实现 根据由 ECMA C# 规范定义的 C# 内存模型,图 4 中迟缓初始化的实现是正确的,因此可以保证它能够在当前和未来版本的 .NET Framework 所支持的所有体系结构上正常运行。

图 4 迟缓初始化的正确实现

 
class BoxedInt
{
  public int _value;
  public BoxedInt() { }
  public BoxedInt(int value) { _value = http://www.mamicode.com/value; }>

请注意,即使此代码示例正确,在实际情况下最好仍使用 Lazy<T> 或 LazyInitializer 类型。

第一个错误 实现 图 5 显示了一个不符合 C# 内存模型要求的实现。 尽管不符合要求,该实现仍有可能在 .NET Framework 中的 x86-x64、Itanium 以及 ARM 上正常工作。 此版本的代码不正确。 由于 _boxedInt 不是可变的,因此允许 C# 编译器将 Read 1 与 Read 2 交换顺序,或将 Write 1 与 Write 2 交换顺序。 任一重新排序都有可能导致 GetInt 返回 0。

图 5 迟缓初始化的错误实现

 
// Warning: Bad code
class LazyExample
{
  private BoxedInt _boxedInt; // Note: This field is not volatile
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      b = new BoxedInt(42); // Write 1 (inside constructor)
      _boxedInt = b;        // Write 2
    }
    return b._value;        // Read 2
  }
}

然而,此代码将在 .NET Framework 版本 4 和 4.5 中的所有体系结构上正常运行(即,始终返回 42):

  • x86-x64:
    • 写入和读取不会重新排序。 代码中没有存储-加载模式,编译器也没有理由将值缓存在寄存器中。
  • Itanium:
    • 由于写入是 ST.REL,因此不会被重新排序。
    • 由于存在数据依赖性,因此读取不会重新排序。
  • ARM:
    • 由于 DMB 在“_boxedInt = b”之前发出,因此写入不会重新排序。
    • 由于存在数据依赖性,因此读取不会重新排序。

当然,您应仅使用此信息来尝试了解现有代码的行为。 不要在编写新代码时使用此模式。

第二个错误实现 图 6 中的错误实现可能在 ARM 和 Itanium 上均告失败。

图 6 迟缓初始化的第二个错误实现

 
// Warning: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (!_initialized) // Read 1
    {
      _value = http://www.mamicode.com/42;>

此版本的迟缓初始化使用两个单独字段来跟踪数据 (_value) 以及字段是否已初始化 (_initialized)。 因此,Read 1 和 Read 2 这两个读取将不再通过数据依赖性相关。 此外,与下一个错误实现(第三个实现)的原因一样,在 ARM 上,写入也可能重新排序 

因此,此版本可能失败,并在实际情况下在 ARM 和 Itanium 中返回 0。 当然,GetInt 可以在 x86-x64 上返回 0(这也是因为 JIT 优化的缘故),但在 .NET Framework 4.5 中似乎不会出现此行为。

第三个错误实现 最后,此示例甚至可能在 x86-x64 上失败。 我必须添加一个看似无关紧要的读取,如图 7中所示。

图 7 迟缓初始化的第三个错误实现

 
// WARNING: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (_value < 0) throw new 
      Exception(); // Note: extra reads to get _value
                          // pre-loaded into a register
    if (!_initialized)      // Read 1
    {
      _value = http://www.mamicode.com/42;>

检查 _value 是否小于 0 的额外读取现在导致编译器将值缓存在寄存器中。 因此,Read 2 将从寄存器中获得服务,因此可以有效地与 Read 1 交换顺序。 结果是,此版本的 GetInt 在实际情况下甚至可能在 x86-x64 上返回 0。

总结

编写新的多线程代码时,通常最好完全避免 C# 内存模型的复杂性,方法是使用锁、并发集合、任务和并行循环等高级并发基元。 编写占用大量 CPU 资源的代码时,有时最好使用可变字段,前提是您只依赖 ECMA C# 规范保证,而非特定于体系结构的实现细节。

 

Igor Ostrovsky 是 Microsoft 的一名高级软件开发工程师。 他从事并行 LINQ、任务并行库以及 .NET Framework 中的其他并行库和基元方面的工作。 有关编程主题的 Ostrovsky 博客在 igoro.com 上提供。

衷心感谢以下技术专家对本文的审阅: Joe Duffy、Eric Eilebrecht、Joe Hoag、Emad Omara、Grant Richins、Jaroslav Sevcik 和 Stephen Toub

 

理论与实践中的 C# 内存模型,第 2 部分