本文章是由機器翻譯。

C#

C# 記憶體模型理論與實務

Igor Ostrovsky

 

這是故事的 C# 記憶體模型長的兩部分系列的第一。 第一部分介紹的保障的 C# 記憶體模型使並顯示代碼模式,激勵保障措施 ; 第二部分將詳細介紹如何在 Microsoft.NET Framework 4.5 中不同的硬體體系結構上實現目標的保證。

多執行緒程式設計中的來源之一是複雜性的編譯器和硬體可以巧妙地變換程式記憶體操作不會影響是複雜性的單線程的行為,但可能影響的多執行緒的行為的方式。 請考慮下面的方法:

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

如果 _data 和 _initialized 都是普通 (即,非易失) 允許欄位、 編譯器和處理器重新排序操作,以便 Init 執行,如果它這樣寫:

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

有各種優化的編譯器和處理器,可以導致這種重新排序,如第 2 部分中,我將討論。

在單線程的程式中,Init 中語句的重新排序都在該程式的含義沒有區別。 只要在方法返回之前更新 _initialized 和 _data 的分配的順序並不重要。 在單線程的程式中,沒有第二個執行緒,可以觀察之間的更新狀態。

但是,通過在多執行緒程式中,工作分配的順序可能問題因為另一個執行緒可能會讀取欄位,同時正在執行 Init。 因此,在重新排序後的 Init 版本,另一個執行緒可能會觀察 _initialized = true 和 _data = 0。

C# 記憶體模型是描述什麼種類的記憶體操作重新排序以及不允許使用的一套規則。 所有的程式應編寫針對規範中定義的保證。

然而,即使允許編譯器和處理器對記憶體操作重新排序,這並不意味著他們總是這樣在實踐中。 對特定硬體運行特定版本的.NET 框架,包含"bug"抽象的 C# 記憶體模型的許多程式將仍然正確執行。 值得注意的是,x x86 和 x64 處理器重新排序操作只在某些狹隘的方案中,與同樣 CLR 中即時 (JIT) 編譯器並不執行許多與它對允許的轉換。

儘管抽象的 C# 記憶體模型是你應該有什麼想法編寫新代碼時,它可以有助於您瞭解實際執行的記憶體模型上不同的體系結構,尤其是當試圖瞭解現有代碼的行為。

C# 記憶體模型根據 ECMA 334

在標準 ECMA 334 C# 語言規範是 C# 記憶體模型的權威定義 (bit.ly/MXMCrN)。 讓我們討論一下 C# 記憶體模型作為規範中定義。

記憶體操作重新排序根據 ECMA-334 時執行緒讀取記憶體位置在 C# 中,由不同的執行緒,寫給讀者可能會看到一個陳舊的值。 這個問題所示圖 1

圖 1 代碼的記憶體操作重新排序的風險

public class DataInit {
  private int _data = 0;
  private bool _initialized = false;
  void Init() {
    _data = 42;            // Write 1
    _initialized = true;   // Write 2
  }
  void Print() {
    if (_initialized)            // Read 1
      Console.WriteLine(_data);  // Read 2
    else
      Console.WriteLine("Not initialized");
  }
}

假設 Init 和列印稱為並行 (也就是說,在不同執行緒) 上 DataInit 的一個新實例。 如果您檢查的 Init 和列印代碼,它可能看起來列印可以只輸出"42"未初始化"。但是,列印也可以輸出"0"。

C# 記憶體模型允許在方法中,記憶體操作重新排序,只要單線程執行的行為不會改變。 例如,編譯器和處理器是自由重新排序的 Init 方法操作,如下所示:

void Init() {
  _initialized = true;   // Write 2
  _data = 42;            // Write 1
}

該重新排序不會改變在單線程程式中的 Init 方法的行為。 在多執行緒程式中,但是,另一個執行緒可能會讀取 _initialized 和 _data 欄位後 Init 已修改一個欄位,但不是在其他,並進行重新排序,然後可以改變程式的行為。 因此,Print 方法可能最終輸出"0"。

Init 的重新排序並不是麻煩的唯一可能在此代碼示例來源。 即使 Init 寫入不最終重新排序,可轉化在 Print 方法讀取:

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

與一樣的寫操作進行重新排序,這種轉變在單線程的程式中,沒有任何影響,但可能會更改多執行緒程式的行為。 一樣的寫操作進行重新排序,讀取的重新排序也會導致列印到輸出為 0。

這篇文章的第 2 部分,您將看到如何和為什麼這些轉換發生在實踐中當我看不同的硬體體系結構的詳細資訊。

易失欄位 C# 程式設計語言提供約束如何可以重新排序記憶體操作的易失欄位。 易失欄位提供獲取 ECMA 規範國家-­釋放語義 (bit.ly/NArSlt)。

易失欄位的讀取已獲得語義,這意味著它不能與隨後的操作重新排序。 可變讀形式單向籬笆:前面操作可以傳遞它,但隨後的操作不能。 請看以下範例:

class AcquireSemanticsExample {
  int _a;
  volatile int _b;
  int _c;
  void Foo() {
    int a = _a; // Read 1
    int b = _b; // Read 2 (volatile)
    int c = _c; // Read 3
    ...
}
}

讀 1 和讀 3 在非易失性,不揮發性讀 2 時。 讀 2 不能一起讀 3、 重新排序,但它可以讀取 1 重新排序。 圖 2 顯示有效 reorderings 美孚身體。

圖 2 對 AcquireSemanticsExample 中的讀取有效進行重新排序

int = △ ; / / 讀取 1

int b = _b; / / 讀取 2 (揮發)

int c = _c; / / 讀 3

int b = _b; / / 讀取 2 (揮發)

int = △ ; / / 讀取 1

int c = _c; / / 讀 3

int b = _b; / / 讀取 2 (揮發)

int c = _c; / / 讀 3

int = △ ; / / 讀取 1

寫的易失欄位,另一方面,釋放語義,,所以它無法重新排序與預先的行動。 易失寫入形式單向的柵欄,如本示例所示:

class ReleaseSemanticsExample
{
  int _a;
  volatile int _b;
  int _c;
  void Foo()
  {
    _a = 1; // Write 1
    _b = 1; // Write 2 (volatile)
    _c = 1; // Write 3
    ...
}
}

寫 1 和寫 3 在非易失的不寫 2 時揮發性。 寫 2 無法重新排序與寫 1,但它可以與寫 3 重新排序。 圖 3 顯示有效 reorderings 美孚身體。

圖 3 有效的在 ReleaseSemanticsExample 中寫入的重新排序

△ = 1 ; / / 寫 1

_b = 1 ; / / 寫 2 (揮發)

_c = 1 ; / / 寫 3

△ = 1 ; / / 寫 1

_c = 1 ; / / 寫 3

_b = 1 ; / / 寫 2 (揮發)

_c = 1 ; / / 寫 3

△ = 1 ; / / 寫 1

_b = 1 ; / / 寫 2 (揮發)

我就回來向在本文後面的"出版物通過可變欄位"部分中的獲取釋放語義。

原子性另一個問題,需要注意的是,在 C#、 值一定不以原子方式寫入到記憶體中。 請看以下範例:

class AtomicityExample {
  Guid _value;
  void SetValue(Guid value) { _value = value; }
  Guid GetValue() { return _value; }
}

如果一個執行緒反復調用 SetValue 和另一個執行緒調用 GetValue,getter 執行緒可能會注意到一個值,永遠不會寫的 setter 的執行緒。 例如,如果 setter 執行緒交替調用 SetValue 與 Guid 值 (0,0,0,0) 和 (5,555),GetValue 可以觀察 (0,0,0,5) 或 (0,0,5,5) 或 (5,500),即使沒有這些值從未賦值使用 SetValue。

背後的原因"撕裂"是,分配"_value = 值"並不在硬體級以原子方式執行。 同樣,_value 的讀取也不能以原子方式執行。

ECMA C# 規範保證將以原子方式寫以下類型:參考型別、 bool、 char、 位元組、 sbyte、 短、 ushort、 uint、 int 和 float。 其他類型的值 — — 包括使用者定義的數值型別 — — 可以寫到記憶體在多個原子的寫入操作。 因此,讀執行緒可以觀察殘缺的值,其中包含件的不同的值。

一點是即使正常讀取和寫入以原子方式 (如 int) 的類型上可以讀取或寫入非以原子方式如果在記憶體中未正確對齊值。 通常情況下,C# 將確保正確地對齊值,但使用者就能夠重寫使用 StructLayoutAttribute 類的對齊方式 (bit.ly/Tqa0MZ)。

非進行重新排序優化編譯器的某些優化可能引入或消除某些記憶體操作。 例如,編譯器可能會重複的讀取的欄位替換單個讀。 同樣,如果代碼讀取某個欄位和存儲的本地變數中的值,然後反復讀取該變數,編譯器可能選擇反復閱讀領域相反。

因為 ECMA C# 規範並不排除非重新排序優化,他們大概被允許。 事實上,正如我在第 2 部分將討論,JIT 編譯器不會執行這些類型的優化。

執行緒通訊模式

記憶體模型的目的是使執行緒通信。 當一個執行緒將值寫入到記憶體和從記憶體中讀取另一個執行緒時,記憶體模型將指示讀取執行緒可能會看到什麼值。

鎖定鎖定通常是最簡單的方法的執行緒間共用資料。 如果您正確地使用鎖,你基本上不必擔心任何的記憶體模型混亂局面。

每當執行緒獲得鎖,CLR 將確保該執行緒會看到前面的鎖的執行緒所做的所有更新。 讓我們添加從這篇文章,一開始鎖定到該示例中所示圖 4

圖 4 帶鎖的執行緒通信

public class Test {
  private int _a = 0;
  private int _b = 0;
  private object _lock = new object();
  void Set() {
    lock (_lock) {
      _a = 1;
      _b = 1;
    }
  }
  void Print() {
    lock (_lock) {
      int b = _b;
      int a = _a;
      Console.WriteLine("{0} {1}", a, b);
    }
  }
}

添加一個鎖,列印並設置獲取提供一個簡單的解決方案。 現在,集和列印執行以原子方式彼此。 Lock 語句保證列印和集的機構將似乎執行一些按順序,即使他們叫做從多個執行緒。

中的關係圖圖 5 顯示順序的一個可能會發生執行緒 1 調用列印三倍,如果調用執行緒 2 的順序設置一次和執行緒 3 調用列印一次。

Sequential Execution with Locking
圖 5 帶鎖的循序執行

當一個鎖定的代碼塊執行時,它提供了保證看到所有寫操作都從鎖定的先後順序的前面,塊的塊。 此外,它保證了不是來看從跟隨它的鎖定順序的塊寫入的任何。

簡而言之,鎖隱藏所有的不可預測性和複雜性的記憶體模型的古怪:您不必擔心如果你正確使用鎖的記憶體操作重新排序。 但是,請注意使用鎖定已是正確的。 如果只列印或一組使用鎖 — — 或列印和集獲得兩個不同鎖 — — 可以成為記憶體操作重新排序和記憶體模型的複雜性回來。

通過執行緒 API 出版物鎖定是共用狀態的執行緒之間非常一般和功能強大的機制。 通過執行緒 API 出版物是併發程式設計的另一個常用的模式。

最簡單的方法來說明通過執行緒 API 出版物是舉個例子:

class Test2 {
  static int s_value;
  static void Run() {
    s_value = 42;
    Task t = Task.Factory.StartNew(() => {
      Console.WriteLine(s_value);
    });
    t.Wait();
  }
}

當您檢查上述代碼示例時,您可能期望"42"要列印到螢幕上。 ,事實上,你的直覺會正確。 此代碼示例被保證列印"42"。

它可能有點奇怪這種情況下甚至需要提及,但事實上有可能的實現將使"0"的 StartNew 要列印而不是"42",至少在理論。 畢竟,有兩個執行緒通信通過非易失的欄位,所以記憶體操作可以重新排序。 圖中顯示模式圖 6

Two Threads Communicating via a Non-Volatile Field
圖 6 兩個執行緒通信通過一個非易失性欄位

S_value 對執行緒 1 上的寫入不會移動後 < 開始任務 t >,和從執行緒 2 s_value 讀取不會移動 < 啟動任務 t > 之前,必須確保 StartNew 執行。 而且,事實上,StartNew API 真的不會保證這。

在.NET 框架中,如 Thread.Start 和 ThreadPool.QueueUserWorkItem,所有其他執行緒 Api 也作出類似的保證。 事實上,幾乎每個執行緒的 API 必須有一些屏障語義,才能正常運行。 這些都幾乎從來不會有記錄,但通常可以僅僅通過保障便要要有用的 API 的順序思考推斷出來。

通過類型初始化出版物另一種方式安全地發佈到多個執行緒的值是寫入靜態初始值設定項或靜態建構函式中的靜態欄位的值。 請看以下範例:

class Test3
{
  static int s_value = 42;
  static object s_obj = new object();
  static void PrintValue()
  {
    Console.WriteLine(s_value);
    Console.WriteLine(s_obj == null);
  }
}

如果 Test3.PrintValue 從多個執行緒同時調用,它保證每個 PrintValue 調用將列印"42"和"false"嗎? 或者,其中一個需要還可以列印"0"真正的"嗎? 正如以前的情況,你有你所期望的行為:保證每個執行緒是列印"42"和"false"。

到目前為止討論的模式所有的行為像預期的那樣。 現在我要拿到其行為可能會令人驚訝的情況下。

易失欄位通過出版物許多併發程式可以生成使用的三種簡單模式,到目前為止,討論與併發原語.NET System.Threading 和 System.Collections.Concurrent 的命名空間中使用。

我將要討論的模式是如此重要的 volatile 關鍵字的語義都圍繞它而設計的。 事實上,最好記住 volatile 關鍵字語義是要記住這種模式,而不是試圖背誦的抽象規則解釋本文中前面。

讓我們開始中的示例代碼圖 7。 中的 DataInit 類圖 7 有兩個方法,Init 和列印 ; 兩者都可以從多個執行緒調用。 如果沒有記憶體操作進行重新排序,列印只能列印"未初始化"或"42,"但列印可以列印"0"時,有兩種可能情況:

  • 寫 1 和寫 2 被重新排序。
  • 讀 1 和讀取 2 被重新排序。

圖 7 使用 Volatile 關鍵字

public class DataInit {
  private int _data = 0;
  private volatile bool _initialized = false;
  void Init() {
    _data = 42;            // Write 1
    _initialized = true;   // Write 2
  }
  void Print() {
    if (_initialized) {          // Read 1
      Console.WriteLine(_data);  // Read 2
    }
    else {
      Console.WriteLine("Not initialized");
    }
  }
}

如果 _initialized 不被標記為易失,將允許兩個 reorderings。 然而,當 _initialized 被標記為易失性,既不重新排序被允許 ! 在寫入,您已跟易失寫入,普通寫和易失寫入不能與以前的記憶體操作重新排序。 在讀的情況下你有跟普通的讀取、 易失讀取和易失讀取不能與隨後的記憶體操作重新排序。

這樣,列印將永遠不會列印"0,"即使同時上 DataInit 的一個新實例初始化調用。

注意是否 _data 欄位是易變的但不是 _initialized,將允許兩個 reorderings。 因此,記住此示例是記住的揮發性語義的好方法。

延遲初始化出版物通過可變欄位的一個常見變種是遲緩初始化。 中的示例圖 8 闡釋了延遲初始化。

圖 8 延遲初始化

class BoxedInt
{
  public int Value { get; set; }
}
class LazyInit
{
  volatile BoxedInt _box;
  public int LazyGet()
  {
    var b = _box;  // Read 1
    if (b == null)
    {
      lock(this)
      {
        b = new BoxedInt();
        b.Value = 42; // Write 1
        _box = b;     // Write 2
      }
    }
    return b.Value; // Read 2
  }
}

在此示例中,LazyGet 始終保證返回"42"。但是,如果不揮發性的 _box 欄位,LazyGet 將獲准返回"0",原因有兩個:讀取可獲得重新排序,或寫操作可以得到重新排序。

要進一步強調這一點,請考慮此類:

class BoxedInt2
{
  public readonly int _value = 42;
  void PrintValue()
  {
    Console.WriteLine(_value);
  }
}

現在,它是可能 — — 至少理論上 — — PrintValue 將列印"0",因為記憶體模型問題。 這裡是 BoxedInt,允許它的用法示例:

class Tester
{
  BoxedInt2 _box = null;
  public void Set() {
    _box = new BoxedInt2();
  }
  public void Print() {
    var b = _box;
    if (b != null) b.PrintValue();
  }
}

因為不正確 (通過非易失的欄位,_box) 出版了 BoxedInt 實例,調用列印的執行緒可能觀察部分構造的物件 ! 再次,製作的 _box 欄位揮發性會修復該問題。

聯鎖操作和記憶體屏障 Interlocked 操作是原子操作可以有時會使用,以減少在多執行緒程式鎖定。 請考慮此執行緒安全的簡單計數器類:

class Counter
{
  private int _value = 0;
  private object _lock = new object();
  public int Increment()
  {
    lock (_lock)
    {
      _value++;
      return _value;
    }
  }
}

使用 Interlocked.Increment,您可以重寫這樣的程式:

class Counter
{
  private int _value = 0;
  public int Increment()
  {
    return Interlocked.Increment(ref _value);
  }
}

作為改寫為 Interlocked.Increment,方法應執行速度更快,至少在一些體系結構上。 除了增量操作,Interlocked 類 (bit.ly/RksCMF) 公開各種原子操作的方法:添加值,有條件地替換值,更換一個值並返回原始值,等等。

所有 Interlocked 方法都有一個非常有趣的屬性:他們不能與其他的記憶體操作重新排序。 所以沒有記憶體操作,是否之前或之後 Interlocked 操作,可以通過 Interlocked 操作。

操作密切相關的 Interlocked 方法是 Thread.MemoryBarrier,它可以被認為是一個虛擬的 Interlocked 操作。 就像 Interlocked 方法,該方法是 Thread.Memory­屏障不能與任何事前或事後的記憶體操作重新排序。 不過,與不同的 Interlocked 方法,Thread.MemoryBarrier 已無副作用 ; 它只是限制了記憶體 reorderings。

輪詢迴圈輪詢迴圈是一種模式,通常不推薦,但 — — 有點遺憾的是 — — 在實踐中經常使用。 圖 9 顯示一個破碎的輪詢迴圈。

圖 9 打破輪詢迴圈

class PollingLoopExample
{
  private bool _loop = true;
  public static void Main()
  {
    PollingLoopExample test1 = new PollingLoopExample();
    // Set _loop to false on another thread
    new Thread(() => { test1._loop = false;}).Start();
    // Poll the _loop field until it is set to false
    while (test1._loop) ;
    // The previous loop may never terminate
  }
}

在此示例中,主執行緒迴圈,輪詢非易失性的特定欄位。説明器執行緒設置的欄位在此期間,但主執行緒可能永遠不會看到更新後的值。

現在,如果 _loop 于標記欄位揮發性嗎?這將修復程式嗎?一般的專家共識似乎是編譯器不允許葫蘆可變欄位讀取迴圈,但很值得商榷 ECMA C# 規範是否使這一保證。

一方面,規範僅指出易失欄位服從獲取釋放語義,這似乎不足以防止吊裝的可變欄位。另一方面,在規範中的示例代碼事實上不會輪詢易失欄位,這意味著不能退出迴圈懸掛可變欄位讀取。

X x86 和 x64 體系結構,PollingLoopExample.Main 通常會掛起。JIT 編譯器將讀取 test1._loop 欄位值只需一次,保存在登記冊內,然後迴圈直到寄存器值更改,這顯然不會發生。

如果在迴圈主體中包含某些語句,但是,JIT 編譯器可能需要登記冊為一些其他目的,所以每個反覆運算可能最終會重讀 test1._loop。因此,您可能會看到在輪詢非現有程式中的環路-­揮發性欄位並尚未發生工作。

併發原語多併發代碼可以受益于成為可用在.NET Framework 4 的高級別併發原語。圖 10 列出了一些.NET 併發原語。

在.NET Framework 4 圖 10 併發原語

通過使用這些原語,往往可以避免低級別的代碼,取決於記憶體模型中錯綜複雜的方式 (通過揮發和類似)。

即將推出

到目前為止,我已經介紹了 C# 記憶體模型所界定的 ECMA C# 規範,並討論的最重要的執行緒通訊模式定義的記憶體模型。

這篇文章的第二部分將解釋的記憶體模型如何實際執行上不同的體系結構,這是有助於瞭解現實世界中的程式的行為。

最佳實務

  • 本文仲介紹的您編寫應依據只 ECMA C# 規範,所做的保證而不是對任何的所有代碼的實現詳細資訊。
  • 避免不必要的使用易失欄位。大部分的時間、 鎖或併發集合 (System.Collections.Concurrent.*) 是更適當的執行緒之間交換資料。在某些情況下,易失欄位可用於優化併發代碼,但您應使用性能測量驗證,益處大於額外的複雜性。
  • 而不是使用可變欄位自己實施的延遲初始化模式,使用 <T> System.Lazy 和 System.Threading.LazyInitializer 類型。
  • 避免輪詢迴圈。通常,您可以使用 BlockingCollection <T>、 Monitor.Wait/Pulse、 事件或非同步程式設計而不是輪詢迴圈。
  • 只要有可能,使用標準的.NET 併發原語而不是你自己執行等效的功能。

Igor Ostrovsky 是在微軟高級軟體發展工程師。他曾對並行 LINQ、 任務並行庫,和其他並行庫和 Microsoft.NET 框架中的基元。關於程式設計主題在奧斯特羅夫斯基博客 igoro.com

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