理论与实践中的 C# 内存模型
轉載自:https://msdn.microsoft.com/magazine/jj863136
這是該系列(包含兩部分內容)的第一部分,這部分將以較長的篇幅介紹 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# 內存模型是一組規則,描述允許和不允許的內存操作重新排序類型。?所有程序都應該根據在規范中定義的保證進行編寫。
但是,即使允許編譯器和處理器對內存操作進行重新排序,也不意味著它們在實際情況下會始終這樣做。?根據這個抽象 C# 內存模型而包含“錯誤”的許多程序仍會在運行特定版本 .NET Framework 的特定硬件上正確執行。?值得注意的是,x86 和 x64 處理器僅在某些范圍較窄的方案中對操作重新排序;同樣,CLR 實時 (JIT) 編譯器不會執行所允許的許多轉換。
盡管您在編寫新代碼時應該對這個抽象的 C# 內存模型已心中有數,但理解這個內存模型在不同體系結構上的實際實現方式是很有用的,特別是在嘗試理解現有代碼的行為時。
根據 ECMA-334 的 C# 內存模型
標準 ECMA-334 C# 語言規范 (bit.ly/MXMCrN) 中提供了 C# 內存模型的權威定義。?我們將介紹在該規范中定義的 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 1Console.WriteLine(_data);? // Read 2elseConsole.WriteLine("Not initialized");} }假定在一個新的 DataInit 實例上并行(即,在不同線程上)調用了 Init 和 Print。?如果您查看 Init 和 Print 的代碼,Print 似乎只能輸出“42”或“Not initialized”。但是,Print 也可以輸出“0”。
C# 內存模型允許在某一方法中對內存操作進行重新排序,只要單線程執行的行為不發生改變即可。?例如,編譯器和處理器會自行對 Init 方法操作重新排序,如下所示:
void Init() {_initialized = true;?? // Write 2_data = 42;??????????? // Write 1 }這一重新排序不會更改單線程程序中 Init 方法的行為。?但在多線程程序中,另一個線程可能會在 Init 已修改一個字段但未修改其他字段后讀取 _initialized 和 _data 字段,隨后進行重新排序可能會更改該程序的行為。因此,Print 方法最終可能會輸出“0”。
Init 的重新排序并不是在這個代碼示例中造成麻煩的唯一根源。?即使 Init 寫入沒有最終導致重新排序,也可能會改變 Print 方法中的讀取:
void Print() {int d = _data;???? // Read 2if (_initialized)? // Read 1Console.WriteLine(d);elseConsole.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 1int b = _b; // Read 2 (volatile)int c = _c; // Read 3... } }Read 1 和 Read 3 是不可變的,而 Read 2 是可變的。?Read 2 不能與 Read 3 互換順序,但可與 Read 1 互換順序。?圖 2?顯示了 Foo 正文的有效重新排序。
圖 2 AcquireSemanticsExample 中讀取的有效重新排序
| int a = _a; // Read 1 int b = _b; // Read 2 (volatile) int c = _c; // Read 3 | int b = _b; // Read 2 (volatile) int a = _a; // Read 1 int c = _c; // Read 3 | int b = _b; // Read 2 (volatile) int c = _c; // Read 3 int a = _a; // Read 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... } }Write 1 和 Write 3 是非可變的,而 Write 2 是可變的。?Write 2 不能與 Write 1 互換順序,但可與 Write 3 互換順序。?圖 3?顯示了 Foo 正文的有效重新排序。
圖 3 ReleaseSemanticsExample 中寫入的有效重新排序
| _a = 1; // Write 1 _b = 1; // Write 2 (volatile) _c = 1; // Write 3 | _a = 1; // Write 1 _c = 1; // Write 3 _b = 1; // Write 2 (volatile) | _c = 1; // Write 3 _a = 1; // Write 1 _b = 1; // Write 2 (volatile) |
在本文后面的“通過可變字段發布”部分中,我將再次討論這個獲取-釋放語義。
原子性?另一個要注意的問題是:在 C# 中,值不一定以原子方式寫入內存。?請考慮以下示例:
class AtomicityExample {Guid _value;void SetValue(Guid value) { _value = value; }Guid GetValue() { return _value; } }如果一個線程反復調用 SetValue 并且另一個線程調用 GetValue,則 getter 線程可能會觀察到 setter 線程從未寫入的值。?例如,如果 setter 線程使用 Guid 值 (0,0,0,0) 和 (5,5,5,5) 交替調用 SetValue,則 GetValue 可能會觀察到 (0,0,0,5)、(0,0,5,5) 或 (5,5,0,0), 即使從未使用 SetValue 分配上述任何值。
這一“撕裂”現象背后的原因在于,賦值“_value = value”在硬件級別并未以原子方式執行。?同樣,_value 的讀取也沒有以原子方式執行。
C# ECMA 規范確保將以原子方式寫入以下類型: 引用類型、bool、char、byte、sbyte、short、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);}} }添加 Print 和 Set 獲取的鎖提供了一個簡單的解決方法。?現在,Set 和 Print 將相互以原子方式執行。?lock 語句確保 Print 和 Set 的正文將像是以某種連續順序執行的,即使是從多個線程調用它們的。
圖 5?中的圖表顯示一個可能的連續順序,如果線程 1 調用 Print 三次,線程 2 調用 Set 一次并且線程 3 調用 Print 一次,則這個順序就可能會發生。
圖 5 使用鎖定的順序執行
在某一鎖定的代碼塊執行時,保證會看到來自在該鎖的連續順序中該塊之前的塊的所有寫入。?此外,保證不會看到來自在該鎖的連續順序中該塊之后的塊的任何寫入。
簡言之,鎖隱藏了內存模型的所有不可預測性和復雜性問題: 如果您正確使用了鎖,則不必擔心內存操作的重新排序。?但是,請注意必須正確使用鎖定。?如果只有 Print 或 Set 使用鎖(或者 Print 和 Set 獲取兩個不同的鎖),則內存操作可能會重新排序,而內存模型的復雜程度將恢復原狀。
通過線程 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”。
可能令人驚訝的是,甚至需要提及這個例子,但實際上,StartNew 的可能的實現方式將會允許輸出“0”而不是“42”,至少在理論上是允許的。?畢竟,有兩個通過非可變字段進行通信的線程,因此,可以對內存操作重新排序。?該模式顯示在圖 6?中的圖表中。
圖 6 通過非可變字段進行通信的兩個線程
StartNew 實現必須確保對線程 1 上 s_value 的寫入將不會移到 <start task t> 之后,并且確保從線程 2 上 s_value 進行的讀取將不會移到 <task t starting> 的前面。?而實際上,StartNew API 真的保證了上述要求。
.NET Framework 中的所有其他線程 API(例如 Thread.Start 和 ThreadPool.QueueUserWorkItem)也提供類似的保證。?實際上,幾乎每個線程 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”或“true”??就像在前面的示例中一樣,您得到了期望的行為: 每個線程都確保輸出“42”和“false”。
到目前為止討論的模式全都按您的預期發揮作用。?現在,我將要講一些例子,其行為可能會出乎您的預料。
通過可變字段發布?可以通過將到目前為止所論述的三個簡單模式與 .NET System.Threading 和 System.Collections.Concurrent 命名空間中的并發基元一起使用,生成許多并發程序。
我將要論述的模式十分重要,以至于可變關鍵字的語義就是圍繞這個模式設計的。?實際上,記住可變關鍵字語義的最佳方式是記住此模式,而不是嘗試記憶在本文前面介紹的抽象規則。
讓我們從圖 7?中的示例代碼開始。?圖 7?中的 DataInit 類具有兩個方法 Init 和 Print;這兩個方法都可以從多個線程調用。?如果沒有對內存操作進行重新排序,則 Print 只能輸出“Not initialized”或“42”,但有兩個 Print 可以輸出“0”的可能情形:
- Write 1 和 Write 2 已重新排序。
- Read 1 和 Read 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 1Console.WriteLine(_data);? // Read 2}else {Console.WriteLine("Not initialized");}} }如果 _initialized 未標記為可變的,則這兩種重新排序都是允許的。?但在 _initialized 標記為可變時,這兩種重新排序都不允許!?對于寫入,您在一個普通寫入后跟隨一個可變寫入,并且可變寫入不能與之前的內存操作互換順序。?對于讀取,您在一個可變讀取后跟隨一個普通讀取,并且可變讀取不能與后續的內存操作互換順序。
因此,Print 將永遠不會輸出“0”,即使使用 Init 對 DataInit 的新實例進行了并發調用。
請注意,如果 _data 字段是可變的,但 _initialized 不是,則允許這兩種重新排序。?因此,記住此示例是記住可變語義的一個很好的方法。
遲緩初始化?通過可變字段進行發布的一個常見的變化形式是遲緩初始化。?圖 8?中的示例說明了遲緩初始化。
圖 8 遲緩初始化
class BoxedInt {public int Value { get; set; } } class LazyInit {volatile BoxedInt _box;public int LazyGet(){var b = _box;? // Read 1if (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 的一個使用示例,它允許輸出“0”:
class Tester {BoxedInt2 _box = null;public void Set() {_box = new BoxedInt2();}public void Print() {var b = _box;if (b != null) b.PrintValue();} }因為該 BoxedInt 實例未正確發布(通過非可變字段 _box),所以,調用 Print 的線程可能會觀察到部分構造的對象!?同樣,使 _box 字段成為可變字段將解決這個問題。
聯鎖操作和內存屏障?聯鎖操作是原子操作,在許多情況下可用來減少多線程程序中的鎖定。?請考慮下面這個簡單的線程安全的計數器類:
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 方法密切相關的一個操作是 Thread.MemoryBarrier,該操作可被視作虛擬聯鎖操作。?與 Interlocked 方法一樣,Thread.Memory-Barrier 不能與任何之前或之后的內存操作互換順序。?但與 Interlocked 方法不同的是,Thread.MemoryBarrier 沒有負面影響;它只是約束內存重新排序。
輪詢循環?輪詢循環不是通常建議的模式,但有些遺憾的是,在實際中還會經常使用它。?圖 9?顯示一個中斷的輪詢循環。
圖 9 中斷的輪詢循環
class PollingLoopExample {private bool _loop = true;public static void Main(){PollingLoopExample test1 = new PollingLoopExample();// Set _loop to false on another threadnew Thread(() => { test1._loop = false;}).Start();// Poll the _loop field until it is set to falsewhile (test1._loop) ;// The previous loop may never terminate} }在這個示例中,主要線程循環輪詢一個特定的非可變字段。?同時,幫助器線程設置該字段,但主要線程可能永遠不會看到更新的值。
現在,如果 _loop 字段被標記為可變字段將怎么辦??這樣做是否解決該問題??一般的專家共識似乎是,不允許編譯器將可變字段讀取提升出循環,但 ECMA C# 規范是否作出這一保證有爭議。
一方面,該規范僅指定可變字段遵守獲取-釋放語義,這似乎不足以禁止提升可變字段。?另一方面,該規范中的示例代碼實際上輪詢一個可變字段,這意味著該可變字段讀取不能被提升出該循環。
在 x86 和 x64 體系結構上,PollingLoopExample.Main 通常將掛起。?JIT 編譯器將只讀取 test1._loop 字段一次,在寄存器中保存值,然后循環直至該寄存器值發生改變,這顯然將永遠不會發生。
但是,如果該循環正文包含一些語句,則 JIT 編譯器將可能出于其他一些目的而需要寄存器,這樣,每個迭代都可能最終重新讀取 test1._loop。?因此,您可能最終會在現有程序中看到循環,這些循環將輪詢非可變字段但碰巧會出現。
并發基元?大量并發代碼可以從在 .NET Framework 4 中開始提供的高級并發基元中獲益。?圖 10?列出了一些 .NET 并發基元。
圖 10 .NET Framework 4 中的并發基元
| 類型 | 說明 |
| Lazy<> | 遲緩初始化的值 |
| LazyInitializer | |
| BlockingCollection<> | 線程安全集合 |
| ConcurrentBag<> | |
| ConcurrentDictionary<,> | |
| ConcurrentQueue<> | |
| ConcurrentStack<> | |
| AutoResetEvent | 用于協調不同線程的執行的基元 |
| 屏障 | |
| CountdownEvent | |
| ManualResetEventSlim | |
| 監視 | |
| SemaphoreSlim | |
| ThreadLocal<> | 為每個線程承載單獨值的容器 |
通過使用這些基元,您常常可以避免依賴于復雜方法(通過可變等)中的內存模型的低級別代碼。
即將推出
到目前為止,我已經介紹了在 ECMA C# 規范中定義的 C# 內存模型,并且論述了定義內存模型的最重要的線程通信模式。
本文的第二部分將說明如何在不同體系結構上實際實現該內存模型,這對于理解實際真實世界中程序的行為很有幫助。
最佳實踐
- 您編寫的所有代碼都應該僅依賴于 ECMA C# 規范所作出的保證,而不依賴于在本文中說明的任何實現細節。
- 避免不必要地使用可變字段。?大多數的時間、鎖或并發集合 (System.Collections.Concurrent.*) 更適合于在線程之間交換數據。?在一些情況下,可以使用可變字段來優化并發代碼,但您應該使用性能度量來驗證所得到的利益勝過復雜性的增加。
- 應該使用 System.Lazy<T> 和 System.Threading.LazyInitializer 類型,而不是使用可變字段自己實現遲緩初始化模式。
- 避免輪詢循環。?通常,您可以使用 BlockingCollection<T>、Monitor.Wait/Pulse、事件或異步編程,而不是輪詢循環。
- 盡可能使用標準 .NET 并發基元,而不是自己實現等效的功能。
Igor Ostrovsky?是 Microsoft 的一名高級軟件開發工程師。 他從事并行 LINQ、任務并行庫以及 Microsoft .NET Framework 中的其他并行庫和基元方面的工作。 有關編程主題的 Ostrovsky 博客在?igoro.com?上提供。
衷心感謝以下技術專家對本文的審閱: Joe Duffy、Eric Eilebrecht、Joe Hoag、Emad Omara、Grant Richins、Jaroslav Sevcik 和 Stephen Toub
?
轉載于:https://www.cnblogs.com/Arlar/p/5965489.html
總結
以上是生活随笔為你收集整理的理论与实践中的 C# 内存模型的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: PHP通过OpenSSL生成证书、密钥并
- 下一篇: Visio中如何绘制黑白图像