在InfoQ上看了几篇关于Java内存模型的文章 ,想起前段写C#多线程的有些不知所以然的地方,专门找了找C#的内存模型的文章。
发现Igor Ostrovsky写的两篇文章,专门找了翻译后的转摘如下,译文转自这里。
原文在这里
第一部分说明 C# 内存模型所做出的保证,并介绍促使其保证这些内容的代码模式;第二部分将详细说明如何在 Microsoft .NET Framework 4.5 的不同硬件体系结构上实现这些保证。
请考虑以下方法:
1 void Init() { 2 _data = 42; 3 _initialized = true; 4 }
如果 _data 和 _initialized 是普通(即,非可变)字段,则允许编译器和处理器对这些操作重新排序,以便 Init 执行起来就像是用以下代码编写的:
1 2 void Init() { 3 _initialized = true; 4 _data = 42; 5 } 6
在编译器和处理器中存在可导致此类型重新排序的不同优化,我将在第 2 部分中讨论这些情况。
在单线程程序中,没有可以观察更新之间状态的第二个线程。
因此,在 Init 的重新排序后的版本中,另一个线程可能会遵守 _initialized=true 和 _data=0 的条件。
所有程序都应该根据在规范中定义的保证进行编写。
值得注意的是,x86 和 x64 处理器仅在某些范围较窄的方案中对操作重新排序;同样,CLR 实时 (JIT) 编译器不会执行所允许的许多转换。
尽管您在编写新代码时应该对这个抽象的 C# 内存模型已心中有数,但理解这个内存模型在不同体系结构上的实际实现方式是很有用的,特别是在尝试理解现有代码的行为时。
根据 ECMA-334 的 C# 内存模型
我们将介绍在该规范中定义的 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 和 Print 的代码,Print 似乎只能输出“42”或“Not initialized”。但是,Print 也可以输出“0”。
例如,编译器和处理器会自行对 Init 方法操作重新排序,如下所示:
1 2 void Init() { 3 _initialized = true; // Write 2 4 _data = 42; // Write 1 5 } 6
因此,Print 方法最终可能会输出“0”。
即使 Init 写入没有最终导致重新排序,也可能会改变 Print 方法中的读取:
1 2 void Print() { 3 int d = _data; // Read 2 4 if (_initialized) // Read 1 5 Console.WriteLine(d); 6 else 7 Console.WriteLine("Not initialized"); 8 } 9
并且,就像写入的重新排序一样,读取的重新排序也可以导致 0 作为输出结果输出。
在本文的第 2 部分,我将详细介绍在不同硬件体系结构上时这些变化在实际中是如何发生以及为什么发生的。
bit.ly/NArSlt)。
请考虑以下示例:
1 2 class AcquireSemanticsExample { 3 int _a; 4 volatile int _b; 5 int _c; 6 void Foo() { 7 int a = _a; // Read 1 8 int b = _b; // Read 2 (volatile) 9 int c = _c; // Read 3 10 ... 11 } 12 } 13
图 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 |
可变写入构成单向的防护,如下面的示例所示:
1 2 class ReleaseSemanticsExample 3 { 4 int _a; 5 volatile int _b; 6 int _c; 7 void Foo() 8 { 9 _a = 1; // Write 1 10 _b = 1; // Write 2 (volatile) 11 _c = 1; // Write 3 12 ... 13 } 14 } 15
图 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) |
在本文后面的“通过可变字段发布”部分中,我将再次讨论这个获取-释放语义。
请考虑以下示例:
2 class AtomicityExample { 3 Guid _value; 4 void SetValue(Guid value) { _value = value; } 5 Guid GetValue() { return _value; } 6 }
例如,如果 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 的读取也没有以原子方式执行。
因此,读取线程可能会观察到由含不同值的多个部分构成的撕裂值。
bit.ly/Tqa0MZ)。
同样,如果代码读取某个字段并且将值存储于一个本地变量中,然后反复读取该变量,则编译器可能会改为选择反复读取该字段。
实际上,如我在第 2 部分中所述,JIT 编译器确实会执行这些类型的优化。
线程通信模式
在一个线程将值写入内存而另一个线程从内存进行读取时,内存模型将会指示读取线程可看到的值。
如果您正确使用了锁,则基本上不必担心任何内存模型方面的麻烦。
接下来,我们将向本文开头的示例添加锁定,如图 4 中所示。
图 4 使用锁定的线程通信
1 2 public class Test { 3 private int _a = 0; 4 private int _b = 0; 5 private object _lock = new object(); 6 void Set() { 7 lock (_lock) { 8 _a = 1; 9 _b = 1; 10 } 11 } 12 void Print() { 13 lock (_lock) { 14 int b = _b; 15 int a = _a; 16 Console.WriteLine("{0} {1}", a, b); 17 } 18 } 19 } 20
lock 语句确保 Print 和 Set 的正文将像是以某种连续顺序执行的,即使是从多个线程调用它们的。
图 5 中的图表显示一个可能的连续顺序,如果线程 1 调用 Print 三次,线程 2 调用 Set 一次并且线程 3 调用 Print 一次,则这个顺序就可能会发生。
图 5 使用锁定的顺序执行
此外,保证不会看到来自在该锁的连续顺序中该块之后的块的任何写入。
如果只有 Print 或 Set 使用锁(或者 Print 和 Set 获取两个不同的锁),则内存操作可能会重新排序,而内存模型的复杂程度将恢复原状。
通过线程 API 发布是针对并发编程的另一种常用模式。
阐释通过线程 API 进行发布的最简单方法是举例:
1 2 class Test2 { 3 static int s_value; 4 static void Run() { 5 s_value = 42; 6 Task t = Task.Factory.StartNew(() => { 7 Console.WriteLine(s_value); 8 }); 9 t.Wait(); 10 } 11 }
该代码示例确保输出“42”。
该模式显示在图 6 中的图表中。
图 6 通过非可变字段进行通信的两个线程
而实际上,StartNew API 真的保证了上述要求。
它们几乎从来不会记录下来,但通常只要考虑需要作出哪些保证以使该 API 发挥作用,就可以推断出它们。(转注:Java不保证这一点,看看Java的双检锁)
请考虑以下示例:
1 class Test3 2 { 3 static int s_value = 42; 4 static object s_obj = new object(); 5 static void PrintValue() 6 { 7 Console.WriteLine(s_value); 8 Console.WriteLine(s_obj == null); 9 } 10 } 11
就像在前面的示例中一样,您得到了期望的行为: 每个线程都确保输出“42”和“false”。
现在,我将要讲一些例子,其行为可能会出乎您的预料。
通过可变字段发布 可以通过将到目前为止所论述的三个简单模式与 .NET System.Threading 和 System.Collections.Concurrent 命名空间中的并发基元一起使用,生成许多并发程序。
实际上,记住可变关键字语义的最佳方式是记住此模式,而不是尝试记忆在本文前面介绍的抽象规则。
如果没有对内存操作进行重新排序,则 Print 只能输出“Not initialized”或“42”,但有两个 Print 可以输出“0”的可能情形:
- Write 1 和 Write 2 已重新排序。
- Read 1 和 Read 2 已重新排序。
图 7 使用 Volatile 关键字
1 public class DataInit { 2 private int _data = 0; 3 private volatile bool _initialized = false; 4 void Init() { 5 _data = 42; // Write 1 6 _initialized = true; // Write 2 7 } 8 void Print() { 9 if (_initialized) { // Read 1 10 Console.WriteLine(_data); // Read 2 11 } 12 else { 13 Console.WriteLine("Not initialized"); 14 } 15 } 16 }
对于读取,您在一个可变读取后跟随一个普通读取,并且可变读取不能与后续的内存操作互换顺序。
因此,Print 将永远不会输出“0”,即使使用 Init 对 DataInit 的新实例进行了并发调用。
因此,记住此示例是记住可变语义的一个很好的方法。
图 8 中的示例说明了迟缓初始化。
图 8 迟缓初始化
1 2 class BoxedInt 3 { 4 public int Value { get; set; } 5 } 6 class LazyInit 7 { 8 volatile BoxedInt _box; 9 public int LazyGet() 10 { 11 var b = _box; // Read 1 12 if (b == null) 13 { 14 lock(this) 15 { 16 b = new BoxedInt(); 17 b.Value = 42; // Write 1 18 _box = b; // Write 2 19 } 20 } 21 return b.Value; // Read 2 22 } 23 } 24
在这个示例中,LazyGet 始终保证返回“42”。但是,如果 _box 字段不是可变的,则出于两个原因将允许 LazyGet 返回“0”: 读取可能会被重新排序,或者写入可能会被重新排序。
为了进一步强调这一点,请考虑下面的类:
1 2 class BoxedInt2 3 { 4 public readonly int _value = 42; 5 void PrintValue() 6 { 7 Console.WriteLine(_value); 8 } 9 } 10
下面是 BoxedInt 的一个使用示例,它允许输出“0”:
1 class Tester 2 { 3 BoxedInt2 _box = null; 4 public void Set() { 5 _box = new BoxedInt2(); 6 } 7 public void Print() { 8 var b = _box; 9 if (b != null) b.PrintValue(); 10 } 11 } 12
同样,使 _box 字段成为可变字段将解决这个问题。
请考虑下面这个简单的线程安全的计数器类:
1 2 class Counter 3 { 4 private int _value = 0; 5 private object _lock = new object(); 6 public int Increment() 7 { 8 lock (_lock) 9 { 10 _value++; 11 return _value; 12 } 13 } 14 }
使用 Interlocked.Increment,您可以按照如下所示重新编写该程序:
1 2 class Counter 3 { 4 private int _value = 0; 5 public int Increment() 6 { 7 return Interlocked.Increment(ref _value); 8 } 9 }
bit.ly/RksCMF) 还公开以下不同的原子操作的方法: 添加值、有条件地替换值、替换值和返回原始值等。
因此,无论是在联锁操作之前还是之后,没有任何内存操作可以通过联锁操作。
但与 Interlocked 方法不同的是,Thread.MemoryBarrier 没有负面影响;它只是约束内存重新排序。
图 9 显示一个中断的轮询循环。
图 9 中断的轮询循环
1 2 class PollingLoopExample 3 { 4 private bool _loop = true; 5 public static void Main() 6 { 7 PollingLoopExample test1 = new PollingLoopExample(); 8 // Set _loop to false on another thread 9 new Thread(() => { test1._loop = false;}).Start(); 10 // Poll the _loop field until it is set to false 11 while (test1._loop) ; 12 // The previous loop may never terminate 13 } 14 }
同时,帮助器线程设置该字段,但主要线程可能永远不会看到更新的值。
一般的专家共识似乎是,不允许编译器将可变字段读取提升出循环,但 ECMA C# 规范是否作出这一保证有争议。
另一方面,该规范中的示例代码实际上轮询一个可变字段,这意味着该可变字段读取不能被提升出该循环。
JIT 编译器将只读取 test1._loop 字段一次,在寄存器中保存值,然后循环直至该寄存器值发生改变,这显然将永远不会发生(转注:这个确实碰到过。当时很困惑,而且换个环境就很难重现)。
因此,您可能最终会在现有程序中看到循环,这些循环将轮询非可变字段但碰巧会出现。
图 10 列出了一些 .NET 并发基元。
图 10 .NET Framework 4 中的并发基元
| 类型 | 说明 |
| Lazy<> | 迟缓初始化的值 |
| LazyInitializer | |
| BlockingCollection<> | 线程安全集合 |
| ConcurrentBag<> | |
| ConcurrentDictionary<,> | |
| ConcurrentQueue<> | |
| ConcurrentStack<> | |
| AutoResetEvent | 用于协调不同线程的执行的基元 |
| 屏障(MemoryBarrier) | |
| CountdownEvent | |
| ManualResetEventSlim | |
| 监视(Monitor?) | |
| SemaphoreSlim | |
| ThreadLocal<> | 为每个线程承载单独值的容器 |
通过使用这些基元,您常常可以避免依赖于复杂方法(通过可变等)中的内存模型的低级别代码。
即将推出(转注:第二部分一并转过来了,如下)
到目前为止,我已经介绍了在 ECMA C# 规范中定义的 C# 内存模型,并且论述了定义内存模型的最重要的线程通信模式。
本文的第二部分将说明如何在不同体系结构上实际实现该内存模型,这对于理解实际真实世界中程序的行为很有帮助。
最佳实践
- 您编写的所有代码都应该仅依赖于 ECMA C# 规范所作出的保证,而不依赖于在本文中说明的任何实现细节。
- 在一些情况下,可以使用可变字段来优化并发代码,但您应该使用性能度量来验证所得到的利益胜过复杂性的增加。
- 应该使用 System.Lazy<T> 和 System.Threading.LazyInitializer 类型,而不是使用可变字段自己实现迟缓初始化模式。
- 通常,您可以使用 BlockingCollection<T>、Monitor.Wait/Pulse、事件或异步编程,而不是轮询循环。
- 尽可能使用标准 .NET 并发基元,而不是自己实现等效的功能。
C# 内存模型的系列文章的第二篇(共两篇)
例如,请考虑以下方法:
1 2 void Init() { 3 _data = 42; 4 _initialized = true; 5 } 6
如果 _data 和 _initialized 是普通(即,非可变)字段,则允许编译器和处理器对这些操作重新排序,以便 Init 执行起来就像是用以下代码编写的:
void Init() { _initialized = true; _data = 42; }
本文将介绍如何在 Microsoft .NET Framework 4.5 支持的不同体系结构上实际实现 C# 内存模型。
编译器优化
但将 IL 编译为机器码的实时 (JIT) 编译器实际上将执行一些对内存操作进行重新排序的优化,我将在下文对此予以介绍。
循环读取提升 请考虑下面的轮询循环模式:
1 2 class Test 3 { 4 private bool _flag = true; 5 public void Run() 6 { 7 // Set _flag to false on another thread 8 new Thread(() => { _flag = false; }).Start(); 9 // Poll the _flag field until it is set to false 10 while (_flag) ; 11 // The loop might never terminate! 12 } 13 }
在这个示例中,.NET 4.5 JIT 编译器可能按如下所示重写循环:
1 if (_flag) { while (true); }
但如果在另一个线程上将 _flag 设置为 false,则优化可能导致挂起。
(有关对此模式更详细的介绍,请参见我在十二月发表的文章中的“轮询循环”部分。)
读取消除 以下示例说明了另一个可能导致多线程代码出现错误的编译器优化:
1 2 class Test 3 { 4 private int _A, _B; 5 public void Foo() 6 { 7 int a = _A; 8 int b = _B; 9 ... 10 } 11 }
因此,如果算法的正确与否取决于读取顺序,则程序将包含错误。
根据 Foo 的编写方式,编译器可能不会交换读取顺序。
但如果我在 Foo 方法的顶部再添加一个无关紧要的语句,则确实会进行重新排序:
1 2 public bool Foo() 3 { 4 if (_B == -1) throw new Exception(); // Extra read 5 int a = _A; 6 int b = _B; 7 return a > b; 8 }
然后,_B 的第二次加载将仅使用寄存器中已有的值,而不发出实际的加载指令。
实际上,编译器将按如下所示重写 Foo 方法:
1 2 public bool Foo() 3 { 4 int b = _B; 5 if (b == -1) throw new Exception(); // Extra read 6 int a = _A; 7 return a > b; 8 }
尽管此代码示例大体上比较接近编译器优化代码的方式,但了解一下此代码的反汇编也很有指导意义:
1 2 if (_B == -1) throw new Exception(); 3 push eax 4 mov edx,dword ptr [ecx+8] 5 // Load field _B into EDX register 6 cmp edx,0FFFFFFFFh 7 je 00000016 8 int a = _A; 9 mov eax,dword ptr [ecx+4] 10 // Load field _A into EAX register 11 return a > b; 12 cmp eax,edx 13 // Compare registers EAX and EDX 14 ...
因此,_A 和 _B 的读取被重新排序。
.NET Framework 4.5 版已修复此问题。
但它有时确实会发生。
要了解读取引入,请考虑以下示例:
1 2 public class ReadIntro { 3 private Object _obj = new Object(); 4 void PrintObj() { 5 Object obj = _obj; 6 if (obj != null) { 7 Console.WriteLine(obj.ToString()); 8 // May throw a NullReferenceException 9 } 10 } 11 void Uninitialize() { 12 _obj = null; 13 } 14 }
CLR JIT 可能会对 PrintObj 方法进行编译,就好像它是用以下代码编写的:
1 2 void PrintObj() { 3 if (_obj != null) { 4 Console.WriteLine(_obj.ToString()); 5 } 6 }
由于 _obj 字段的读取已经拆分为该字段的两个读取,因此 ToString 方法现在可能在一个值为 null 的目标上被调用。
读取引入很难在 .NET Framework 4.5 中重现,但它确实会在某些特殊情况下发生。
x86-x64 上的 C# 内存模型实现
由于 x86 和 x64 在内存模型方面的行为相同,因此我将这两个处理器版本放在一起进行考虑。
即便如此,在某些特定的情况下,x86-x64 处理器仍会对内存操作进行重新排序。
x86-x64 内存重新排序 即使 x86-x64 处理器提供了非常有力的排序保证,特定类型的硬件重新排序仍会发生。
图 1 显示了一个展示此行为的示例。
图 1 StoreBufferExample
1 class StoreBufferExample 2 { 3 // On x86 .NET Framework 4.5, it makes no difference 4 // whether these fields are volatile or not 5 volatile int A = 0; 6 volatile int B = 0; 7 volatile bool A_Won = false; 8 volatile bool B_Won = false; 9 public void ThreadA() 10 { 11 A = true; 12 if (!B) A_Won = true; 13 } 14 public void ThreadB() 15 { 16 B = true; 17 if (!A) B_Won = true; 18 } 19 }
如果您思考一下图 2 中的程序可能产生的结果,则似乎可能得出三个结论:
- 结果是 A_Won=true,B_Won=false。
- 结果是 A_Won=false,B_Won=true。
- 结果是 A_Won=false,B_Won=false。
图 2 从不同的线程中调用 ThreadA 和 ThreadB 方法
尽管此结果与线程 1 和线程 2 的任何交错执行不一致,但它仍会发生。
因此,ThreadA 方法可能会高效执行,就好像它是用以下代码编写的:
1 2 public void ThreadA() 3 { 4 bool tmp = B; 5 A = true; 6 if (!tmp) A_Won = 1; 7 }
更新后的 ThreadA 方法将如下所示:
1 2 public void ThreadA() 3 { 4 A = true; 5 Thread.MemoryBarrier(); 6 if (!B) aWon = 1; 7 }
锁定的 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 内存模型对于“可变”的定义更严格一些,此定义不允许“存储-加载”重新排序,因此 x86 上的 Java 编译器通常会在可变写入之后发出锁定指令。
存储缓冲区可导致写入与后续的读取互换顺序(存储-加载重新排序)。
需要注意的是,如果多个读取操作访问相同的内存位置,编译器可能选择只执行读取一次,并将值存储在寄存器中以供后续读取使用。
当然,您的代码不应依赖这些实现细节,因为不同的硬件体系结构以及可能的 .NET 版本具有不同的细节。
Itanium 体系结构上的 C# 内存模型实现
Itanium 由 .NET Framework 版本 4 以及早期版本提供支持。
即使 .NET Framework 4.5 不再支持 Itanium,但当您阅读有关 .NET 内存模型的旧文章并且必须维护采纳了这些文章中的建议的代码时,了解 Itanium 内存模型仍很有用。
Itanium 对普通加载 (LD) 和加载-获取 (LD.ACQ) 以及普通存储 (ST) 和存储-释放 (ST.REL) 加以区分。
例如,请看下面的代码:
1 2 class ReorderingExample 3 { 4 int _a = 0, _b = 0; 5 void PrintAB() 6 { 7 int a = _a; 8 int b = _b; 9 Console.WriteLine("A:{0} B:{1}", a, b); 10 } 11 ... 12 }
因此,这两个读取可能会有效地在硬件中进行重新排序,从而使 PrintAB 执行起来就像是用以下代码编写的:
1 2 void PrintAB() 3 { 4 int b = _b; 5 int a = _a; 6 Console.WriteLine("A:{0} B:{1}", a, b); 7 }
如果内存读取返回的值决定后续读取的读取位置,则说明这两个读取之间存在数据依赖性。
以下示例说明了数据依赖性:
1 2 class Counter { public int _value; } 3 class Test 4 { 5 private Counter _counter = new Counter(); 6 void Do() 7 { 8 Counter c = _counter; // Read 1 9 int value = c._value; // Read 2 10 } 11 }
不过,需要再次指出的是,Itanium 不会执行此项操作。
我将回过头来再简要介绍一下 Itanium 中的数据依赖性,以便更加清晰地阐明它与 C# 内存模型的相关性。
如果读取返回的值决定后续指令能否执行,则说明存在控制依赖性。
因此,在以下示例中,_initialized 和 _data 的读取通过控制依赖性相关:
1 2 void Print() { 3 if (_initialized) // Read 1 4 Console.WriteLine(_data); // Read 2 5 else 6 Console.WriteLine("Not initialized"); 7 }
请注意,JIT 编译器仍可自由地对两个读取进行重新排序,并且在某些情况下会执行此操作。
比较有趣的一点是,如果您在 Itanium 上对所有读取使用 LD.ACQ 并对所有写入使用 ST.REL,那么您基本上实现了 x86-x64 内存模型,其中的存储缓冲区将是唯一的重新排序源。
但普通读取将作为 LD 发出;只有可变字段中的读取作为 LD.ACQ 发出。
发出 ST.REL 只是编译器选择执行的额外操作,目的是确保特定的通用(但在理论上是错误的)模式将按预期的方式工作。
在此部分的前面所展示的 PrintAB 示例中,仅仅限制写入不会有任何帮助,原因是读取仍被重新排序。
图 3 显示了一个迟缓初始化示例。
图 3 迟缓初始化
1 // Warning: Might not work on future architectures and .NET versions; 2 // do not use 3 class LazyExample 4 { 5 private BoxedInt _boxedInt; 6 int GetInt() 7 { 8 BoxedInt b = _boxedInt; // Read 1 9 if (b == null) 10 { 11 lock(this) 12 { 13 if (_boxedInt == null) 14 { 15 b = new BoxedInt(); 16 b._value = 42; // Write 1 17 _boxedInt = b; // Write 2 18 } 19 } 20 } 21 int value = b._value; // Read 2 22 return value; 23 } 24 }
同时,这两个写入也不会被重新排序,因为 CLR JIT 会将其作为 ST.REL 发出。
然而,即使 _boxed 不是可变字段,编译器的当前版本也会确保代码在实际情况下仍可以在 Itanium 上正常运行。
当然,正如在 x86-x64 上那样,Itanium 上的 CLR JIT 可能会执行循环读取提升、读取消除和读取引入。
Itanium 备注 Itanium 之所以引人注意,是因为它是第一个提供了运行 .NET Framework 的弱内存模型的体系结构。
不管怎么说,在 .NET Framework 4.5 推出之前,Itanium 是除 x86-x64 以外唯一运行 .NET Framework 的体系结构。
此行为不受 ECMA C# 规范的保证,因此在未来版本的 .NET Framework 以及未来的体系结构中可能不复存在(实际上,在 ARM 上的 .NET Framework 4.5 中已经不存在)。
与此类似,某些人会认为迟缓初始化在 .NET Framework 中是正确的,即使所在字段是非可变的也是如此,而其他人可能会认为该字段必须是可变的。
因此,当您尝试理解由其他人编写的并发代码、阅读旧文章甚至是与其他开发人员交谈时,了解 Itanium 的相关功能可能会很有帮助。
ARM 上的 C# 内存模型实现
与 Itanium 一样,ARM 的内存模型也弱于 x86-x64。
任何内存操作都不会在任一方向传递 DMB。
有关数据依赖性和控制依赖性的介绍,请参见本文前面的“Itanium 重新排序”部分。
由于 DMB 指令将禁止可变读取与任何后续操作交换顺序,因此该解决方案将正确实现获取语义。
由于 DMB 指令禁止可变写入与之前的任何操作交换顺序,因此该解决方案将正确实现释放语义。
但使所有写入都有效地成为可变写入并不是 ARM 上的一个良好解决方案,这是因为 DBM 指令的开销很高。
下列写入被视为“释放”屏障:
- 向垃圾收集器 (GC) 堆上的引用类型字段的写入
- 向引用类型静态字段的写入
因此,任何可能发布对象的写入均被视为释放屏障。
以下是 LazyExample 的相关部分(需要重申的是,任何字段都不是可变字段):
1 2 b = new BoxedInt(); 3 b._value = 42; // Write 1 4 // DMB will be emitted here 5 _boxedInt = b; // Write 2
同时,由于 ARM 支持数据依赖性,因此迟缓初始化模式下的读取也不会交换顺序,并且代码将在 ARM 上正常工作。
因此,CLR JIT 将执行额外的工作(超出 ECMA C# 规范中强制要求的内容)以使迟缓初始化的最常见变体在 ARM 上正常工作。
对于 ARM,最后需要说明的是,就 CLR JIT 而言,循环读取提升、读取消除和读取引入均为合法优化,这一点与在 x86-x64 和 Itanium 上一样。
示例: 迟缓初始化
了解迟缓初始化模式的几个不同变体并思考一下它们在不同体系结构上的行为方式可能很有指导意义。
正确实现 根据由 ECMA C# 规范定义的 C# 内存模型,图 4 中迟缓初始化的实现是正确的,因此可以保证它能够在当前和未来版本的 .NET Framework 所支持的所有体系结构上正常运行。
图 4 迟缓初始化的正确实现
1 2 class BoxedInt 3 { 4 public int _value; 5 public BoxedInt() { } 6 public BoxedInt(int value) { _value = value; } 7 } 8 class LazyExample 9 { 10 private volatile BoxedInt _boxedInt; 11 int GetInt() 12 { 13 BoxedInt b = _boxedInt; 14 if (b == null) 15 { 16 b = new BoxedInt(42); 17 _boxedInt = b; 18 } 19 return b._value; 20 } 21 }
请注意,即使此代码示例正确,在实际情况下最好仍使用 Lazy<T> 或 LazyInitializer 类型。
任一重新排序都有可能导致 GetInt 返回 0。
图 5 迟缓初始化的错误实现
1 2 // Warning: Bad code 3 class LazyExample 4 { 5 private BoxedInt _boxedInt; // Note: This field is not volatile 6 int GetInt() 7 { 8 BoxedInt b = _boxedInt; // Read 1 9 if (b == null) 10 { 11 b = new BoxedInt(42); // Write 1 (inside constructor) 12 _boxedInt = b; // Write 2 13 } 14 return b._value; // Read 2 15 } 16 }
然而,此代码将在 .NET Framework 版本 4 和 4.5 中的所有体系结构上正常运行(即,始终返回 42):
-
x86-x64:
- 代码中没有存储-加载模式,编译器也没有理由将值缓存在寄存器中。
-
Itanium:
- 由于写入是 ST.REL,因此不会被重新排序。
- 由于存在数据依赖性,因此读取不会重新排序。
-
ARM:
- 由于 DMB 在“_boxedInt = b”之前发出,因此写入不会重新排序。
- 由于存在数据依赖性,因此读取不会重新排序。
不要在编写新代码时使用此模式。
第二个错误实现 图 6 中的错误实现可能在 ARM 和 Itanium 上均告失败。
图 6 迟缓初始化的第二个错误实现
1 // Warning: Bad code 2 class LazyExample 3 { 4 private int _value; 5 private bool _initialized; 6 int GetInt() 7 { 8 if (!_initialized) // Read 1 9 { 10 _value = 42; 11 _initialized = true; 12 } 13 return _value; // Read 2 14 } 15 }
。
当然,GetInt 可以在 x86-x64 上返回 0(这也是因为 JIT 优化的缘故),但在 .NET Framework 4.5 中似乎不会出现此行为。
我必须添加一个看似无关紧要的读取,如图 7中所示。
图 7 迟缓初始化的第三个错误实现
1 2 // WARNING: Bad code 3 class LazyExample 4 { 5 private int _value; 6 private bool _initialized; 7 int GetInt() 8 { 9 if (_value < 0) throw new 10 Exception(); // Note: extra reads to get _value 11 // pre-loaded into a register 12 if (!_initialized) // Read 1 13 { 14 _value = 42; 15 _initialized = true; 16 return _value; 17 } 18 return _value; // Read 2 19 } 20 }
结果是,此版本的 GetInt 在实际情况下甚至可能在 x86-x64 上返回 0。
总结
编写占用大量 CPU 资源的代码时,有时最好使用可变字段,前提是您只依赖 ECMA C# 规范保证,而非特定于体系结构的实现细节。