本文章是由機器翻譯。

C#

理論與實踐中的 C# 記憶體模型,第 2 部分

Igor Ostrovsky

 

這是介紹 C# 記憶體模型的系列文章的第二篇(共兩篇)。 正如在 MSDN 雜誌十二月刊的第一篇文章 (msdn.microsoft.com/magazine/jj863136) 中所介紹的,編譯器和硬體可能會悄然改變程式的記憶體操作,儘管其方式不會影響單線程行為,但可能會影響多執行緒行為。 例如,請考慮以下方法:

void Init() {
  _data = 42;
  _initialized = true;
}

如果 _data 和 _initialized 是普通(即,非可變)欄位,則允許編譯器和處理器對這些操作重新排序,以便 Init 執行起來就像是用以下代碼編寫的:

void Init() {
  _initialized = true;
  _data = 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。

Calling ThreadA and ThreadB Methods from Different Threads
圖 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 = 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 = 42;  // Write 1
          _boxedInt = b; // Write 2
        }
      }
    }
    int value = b._value; // Read 2
    return value;
  }
}

為了讓這段代碼始終返回 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 的記憶體模型中,所有的寫操作是揮發性 — — 甚至那些非易失性欄位。"作者的意思是安騰,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 = 42;  // Write 1
// DMB will be emitted here
_boxedInt = b; // Write 2

由於 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 = value; }
}
class LazyExample
{
  private volatile BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt;
    if (b == null)
    {
      b = new BoxedInt(42);
      _boxedInt = b;
    }
    return b._value;
  }
}

請注意,即使此代碼示例正確,在實際情況下最好仍使用 Lazy<T> 或 LazyInitializer 類型。

第一個錯誤 1 圖 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):

  • x 86-x 64:
    • 寫入和讀取不會重新排序。 代碼中沒有存儲-載入模式,編譯器也沒有理由將值緩存在寄存器中。
  • 安騰:
    • 由於寫入是 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 = 42;
      _initialized = true;
    }
    return _value;     // Read 2
  }
}

此版本的遲緩初始化使用兩個單獨欄位來跟蹤資料 (_value) 以及欄位是否已初始化 (_initialized)。 因此,Read 1 和 Read 2 這兩個讀取將不再通過資料依賴性相關。 此外,與下一個錯誤實現(第三個實現)的原因一樣,在 ARM 上,寫入也可能重新排序 3).

因此,此版本可能失敗,並在實際情況下在 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 = 42;
      _initialized = true;
      return _value;
    }
    return _value;          // Read 2
  }
}

檢查 _value 是否小於 0 的額外讀取現在導致編譯器將值緩存在寄存器中。 因此,Read 2 將從寄存器中獲得服務,因此可以有效地與 Read 1 交換順序。 結果是,此版本的 GetInt 在實際情況下甚至可能在 x86-x64 上返回 0。

總結

編寫新的多執行緒代碼時,通常最好完全避免 C# 記憶體模型的複雜性,方法是使用鎖、併發集合、任務和並行迴圈等高級併發基元。 編寫佔用大量 CPU 資源的代碼時,有時最好使用可變欄位,前提是您只依賴 ECMA C# 規範保證,而非特定于體系結構的實現細節。

Igor Ostrovsky 是 Microsoft 的一名高級軟體發展工程師。他從事並行 LINQ、任務並行庫以及 .NET Framework 中的其他並行庫和基元方面的工作。有關程式設計主題的 Ostrovsky 博客在 igoro.com 上提供。

衷心感謝以下技術專家對本文的審閱:喬達菲、 Eric Eilebrecht、 喬 Hoag、 伊馬德 Omara、 授予 Richins、 亞羅斯拉夫舍夫契克和斯蒂芬 Toub