理论与实践中的 C# 内存模型
理论与实践中的 C# 内存模型
在InfoQ上看了几篇关于Java内存模型的文章 ,想起前段写C#多线程的有些不知所以然的地方,专门找了找C#的内存模型的文章。
发现Igor Ostrovsky写的两篇文章,专门找了翻译后的转摘如下,译文转自这里。
原文在这里
这是该系列(包含两部分内容)的第一部分,这部分将以较长的篇幅介绍 C# 内存模型。 第一部分说明 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 和 _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 1 Console.WriteLine(_data); // Read 2 else Console.WriteLine("Not initialized"); } }
假定在一个新的 DataInit 实例上并行(即,在不同线程上)调用了 Init 和 Print。 如果您查看 Init 和 Print 的代码,Print 似乎只能输出“42”或“Not initialized”。但是,Print 也可以输出“0”。
C# 内存模型允许在某一方法中对内存操作进行重新排序,只要单线程执行的行为不发生改变即可。 例如,编译器和处理器会自行对 Init 方法操作重新排序,如下所示:
1 2 void Init() { 3 _initialized = true; // Write 2 4 _data = 42; // Write 1 5 } 6
这一重新排序不会更改单线程程序中 Init 方法的行为。 但在多线程程序中,另一个线程可能会在 Init 已修改一个字段但未修改其他字段后读取 _initialized 和 _data 字段,随后进行重新排序可能会更改该程序的行为。因此,Print 方法最终可能会输出“0”。
Init 的重新排序并不是在这个代码示例中造成麻烦的唯一根源。 即使 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 部分,我将详细介绍在不同硬件体系结构上时这些变化在实际中是如何发生以及为什么发生的。
可变字段 C# 编程语言提供可变字段,限制对内存操作重新排序的方式。 ECMA 规范规定,可变字段应提供获取-释放语义 (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
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 |
另一方面,可变字段的写入具有释放语义,因此它不能与之前的操作互换顺序。 可变写入构成单向的防护,如下面的示例所示:
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
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# 中,值不一定以原子方式写入内存。 请考虑以下示例:
2 class AtomicityExample { 3 Guid _value; 4 void SetValue(Guid value) { _value = value; } 5 Guid GetValue() { return _value; } 6 }
如果一个线程反复调用 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 使用锁定的线程通信
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
添加 Print 和 Set 获取的锁提供了一个简单的解决方法。 现在,Set 和 Print 将相互以原子方式执行。 lock 语句确保 Print 和 Set 的正文将像是以某种连续顺序执行的,即使是从多个线程调用它们的。
图 5 中的图表显示一个可能的连续顺序,如果线程 1 调用 Print 三次,线程 2 调用 Set 一次并且线程 3 调用 Print 一次,则这个顺序就可能会发生。
图 5 使用锁定的顺序执行
在某一锁定的代码块执行时,保证会看到来自在该锁的连续顺序中该块之前的块的所有写入。 此外,保证不会看到来自在该锁的连续顺序中该块之后的块的任何写入。
简言之,锁隐藏了内存模型的所有不可预测性和复杂性问题: 如果您正确使用了锁,则不必担心内存操作的重新排序。 但是,请注意必须正确使用锁定。 如果只有 Print 或 Set 使用锁(或者 Print 和 Set 获取两个不同的锁),则内存操作可能会重新排序,而内存模型的复杂程度将恢复原状。
通过线程 API 发布 锁定是用于在线程之间共享状态的非常普遍和强大的机制。 通过线程 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”将输出到屏幕。 并且,实际上,您的直觉是正确的。 该代码示例确保输出“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 发挥作用,就可以推断出它们。(转注: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
如果并行从多个线程调用 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 关键字
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 }
如果 _initialized 未标记为可变的,则这两种重新排序都是允许的。 但在 _initialized 标记为可变时,这两种重新排序都不允许! 对于写入,您在一个普通写入后跟随一个可变写入,并且可变写入不能与之前的内存操作互换顺序。 对于读取,您在一个可变读取后跟随一个普通读取,并且可变读取不能与后续的内存操作互换顺序。
因此,Print 将永远不会输出“0”,即使使用 Init 对 DataInit 的新实例进行了并发调用。
请注意,如果 _data 字段是可变的,但 _initialized 不是,则允许这两种重新排序。 因此,记住此示例是记住可变语义的一个很好的方法。
迟缓初始化 通过可变字段进行发布的一个常见的变化形式是迟缓初始化。 图 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
现在,PrintValue 可以(至少在理论上可以)由于内存模型问题而输出“0”。 下面是 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
因为该 BoxedInt 实例未正确发布(通过非可变字段 _box),所以,调用 Print 的线程可能会观察到部分构造的对象! 同样,使 _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 }
在使用 Interlocked.Increment 程序编写后,该方法应该更快地执行,至少在某些体系结构上会更快。 除了递增操作之外,Interlocked 类 (bit.ly/RksCMF) 还公开以下不同的原子操作的方法: 添加值、有条件地替换值、替换值和返回原始值等。
所有 Interlocked 方法都具有一个非常有趣的属性: 它们不能与其他内存操作互换顺序。 因此,无论是在联锁操作之前还是之后,没有任何内存操作可以通过联锁操作。
与 Interlocked 方法密切相关的一个操作是 Thread.MemoryBarrier,该操作可被视作虚拟联锁操作。 与 Interlocked 方法一样,Thread.MemoryBarrier 不能与任何之前或之后的内存操作互换顺序。 但与 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 }
在这个示例中,主要线程循环轮询一个特定的非可变字段。 同时,帮助器线程设置该字段,但主要线程可能永远不会看到更新的值。
现在,如果 _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 | 用于协调不同线程的执行的基元 |
屏障(MemoryBarrier) | |
CountdownEvent | |
ManualResetEventSlim | |
监视(Monitor?) | |
SemaphoreSlim | |
ThreadLocal<> | 为每个线程承载单独值的容器 |
通过使用这些基元,您常常可以避免依赖于复杂方法(通过可变等)中的内存模型的低级别代码。
即将推出(转注:第二部分一并转过来了,如下)
到目前为止,我已经介绍了在 ECMA C# 规范中定义的 C# 内存模型,并且论述了定义内存模型的最重要的线程通信模式。
本文的第二部分将说明如何在不同体系结构上实际实现该内存模型,这对于理解实际真实世界中程序的行为很有帮助。
最佳实践
- 您编写的所有代码都应该仅依赖于 ECMA C# 规范所作出的保证,而不依赖于在本文中说明的任何实现细节。
- 避免不必要地使用可变字段。 大多数的时间、锁或并发集合 (System.Collections.Concurrent.*) 更适合于在线程之间交换数据。 在一些情况下,可以使用可变字段来优化并发代码,但您应该使用性能度量来验证所得到的利益胜过复杂性的增加。
- 应该使用 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; }
在上一篇文章中,我介绍了抽象 C# 内存模型。 本文将介绍如何在 Microsoft .NET Framework 4.5 支持的不同体系结构上实际实现 C# 内存模型。
编译器优化
正如在第一篇文章中提到的,编译器可能通过对内存操作进行重新排序来优化代码。 在 .NET Framework 4.5 中,将 C# 编译为 IL 的 csc.exe 编译器并不执行大量的优化操作,因此该编译器不会对内存操作进行重新排序。 但将 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,则优化可能导致挂起。
请注意,如果 _flag 字段是可变字段,则 JIT 编译器不会将读取提升出循环。 (有关对此模式更详细的介绍,请参见我在十二月发表的文章中的“轮询循环”部分。)
读取消除 以下示例说明了另一个可能导致多线程代码出现错误的编译器优化:
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 }
此类包含两个非可变字段:_A 和 _B。 方法 Foo 先读取字段 _A,然后读取字段 _B。 但由于这两个字段是非可变字段,因此编译器可自由地对两个读取进行重新排序。 因此,如果算法的正确与否取决于读取顺序,则程序将包含错误。
很难想象编译器通过交换读取顺序将获得什么结果。 根据 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 }
在 Foo 方法的第一行上,编译器将 _B 的值加载到寄存器中。 然后,_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 ...
即使您不了解汇编,也很容易理解以上代码中所执行的操作。 在计算条件 _B == -1 的过程中,编译器将字段 _B 加载到 EDX 寄存器中。 此后再次读取字段 _B 时,编译器仅重用 EDX 中已有的值,而不发出实际的内存读取指令。 因此,_A 和 _B 的读取被重新排序。
在此示例中,正确的解决方案是将字段 _A 标记为可变字段。 如果完成此项标记,编译器便不会对 _A 和 _B 的读取进行重新排序,因为 _A 的加载具有加载-获取语义。 但需要指出的是,.NET Framework(版本 4 以及早期的版本)不会正确地处理此示例,实际上将字段 _A 标记为可变字段不会禁止读取重新排序。 .NET Framework 4.5 版已修复此问题。
读取引入 正如我刚刚介绍的,编译器有时会将多个读取融合为一个读取。 编译器还可以将单个读取拆分为多个读取。 在 .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 }
如果查看一下 PrintObj 方法,会发现 obj.ToString 表达式中的 obj 值似乎永远不会为 null。 但实际上该行代码可能会引发 NullReferenceException。 CLR JIT 可能会对 PrintObj 方法进行编译,就好像它是用以下代码编写的:
1 2 void PrintObj() { 3 if (_obj != null) { 4 Console.WriteLine(_obj.ToString()); 5 } 6 }
由于 _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
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 }
考虑一下,当从 StoreBufferExample 新实例的不同线程上调用方法 ThreadA 和 ThreadB(如图 2 所示)时,将出现什么情况。 如果您思考一下图 2 中的程序可能产生的结果,则似乎可能得出三个结论:
- 线程 1 在线程 2 开始之前完成。 结果是 A_Won=true,B_Won=false。
- 线程 2 在线程 1 开始之前完成。 结果是 A_Won=false,B_Won=true。
- 线程交错。 结果是 A_Won=false,B_Won=false。
图 2 从不同的线程中调用 ThreadA 和 ThreadB 方法
但出乎意料的是,竟然还会出现第四种情况: 在这段代码运行完毕后,A_Won 和 B_Won 字段的值可能同时为 true! 存储缓冲区的存在可能导致存储“延迟”,从而最终导致与后续的加载互换顺序。 尽管此结果与线程 1 和线程 2 的任何交错执行不一致,但它仍会发生。
该示例很有趣,这是因为尽管我们的处理器 (x86-x64) 具有相对较强的排序能力,并且所有字段均为可变字段,但我们仍观察到内存操作的重新排序。 尽管向 A 的写入是可变的,并且从 A_Won 进行的读取也是可变的,但防护却都是单向的,并且实际上允许这一重新排序。 因此,ThreadA 方法可能会高效执行,就好像它是用以下代码编写的:
1 2 public void ThreadA() 3 { 4 bool tmp = B; 5 A = true; 6 if (!tmp) A_Won = 1; 7 }
一种可能的修复方法是在 ThreadA 和 ThreadB 中均插入内存屏障。 更新后的 ThreadA 方法将如下所示:
1 2 public void ThreadA() 3 { 4 A = true; 5 Thread.MemoryBarrier(); 6 if (!B) aWon = 1; 7 }
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) 加以区分。
只要单线程行为保持不变,硬件便可以自由地对普通加载和存储进行重新排序。 例如,请看下面的代码:
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 方法中 _a 和 _b 的两个读取。 由于读取操作访问普通的非可变字段,因此编译器将使用普通 LD(而非 LD.ACQ)来实现读取。 因此,这两个读取可能会有效地在硬件中进行重新排序,从而使 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 }
在 Do 方法中,Itanium 始终都不会对 Read 1 和 Read 2 进行重新排序,即便 Read 1 是普通加载而非加载-获取也不例外。 有一点似乎是显而易见的,那就是这两个读取无法重新排序: 第一个读取将决定第二个读取应访问的内存位置! 然而,除 Itanium 以外的某些其他处理器实际上可能会对读取进行重新排序。 处理器可能猜测 Read 1 将返回的值,并推测性地执行 Read 2,甚至会在 Read 1 已经完成之前执行。 不过,需要再次指出的是,Itanium 不会执行此项操作。
我将回过头来再简要介绍一下 Itanium 中的数据依赖性,以便更加清晰地阐明它与 C# 内存模型的相关性。
此外,如果两个普通读取通过控制依赖性而彼此相关,则 Itanium 将不会对其进行重新排序。 如果读取返回的值决定后续指令能否执行,则说明存在控制依赖性。
因此,在以下示例中,_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 }
即使 _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 迟缓初始化
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 }
为了让这段代码始终返回 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 使用一种略有不同的方法确保迟缓初始化正常工作。 下列写入被视为“释放”屏障:
- 向垃圾收集器 (GC) 堆上的引用类型字段的写入
- 向引用类型静态字段的写入
因此,任何可能发布对象的写入均被视为释放屏障。
以下是 LazyExample 的相关部分(需要重申的是,任何字段都不是可变字段):
1 2 b = new BoxedInt(); 3 b._value = 42; // Write 1 4 // DMB will be emitted here 5 _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 迟缓初始化的正确实现
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 类型。
第一个错误 实现 图 5 显示了一个不符合 C# 内存模型要求的实现。 尽管不符合要求,该实现仍有可能在 .NET Framework 中的 x86-x64、Itanium 以及 ARM 上正常工作。 此版本的代码不正确。 由于 _boxedInt 不是可变的,因此允许 C# 编译器将 Read 1 与 Read 2 交换顺序,或将 Write 1 与 Write 2 交换顺序。 任一重新排序都有可能导致 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 }
此版本的迟缓初始化使用两个单独字段来跟踪数据 (_value) 以及字段是否已初始化 (_initialized)。 因此,Read 1 和 Read 2 这两个读取将不再通过数据依赖性相关。 此外,与下一个错误实现(第三个实现)的原因一样,在 ARM 上,写入也可能重新排序 。
因此,此版本可能失败,并在实际情况下在 ARM 和 Itanium 中返回 0。 当然,GetInt 可以在 x86-x64 上返回 0(这也是因为 JIT 优化的缘故),但在 .NET Framework 4.5 中似乎不会出现此行为。
第三个错误实现 最后,此示例甚至可能在 x86-x64 上失败。 我必须添加一个看似无关紧要的读取,如图 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 }
检查 _value 是否小于 0 的额外读取现在导致编译器将值缓存在寄存器中。 因此,Read 2 将从寄存器中获得服务,因此可以有效地与 Read 1 交换顺序。 结果是,此版本的 GetInt 在实际情况下甚至可能在 x86-x64 上返回 0。
总结
编写新的多线程代码时,通常最好完全避免 C# 内存模型的复杂性,方法是使用锁、并发集合、任务和并行循环等高级并发基元。 编写占用大量 CPU 资源的代码时,有时最好使用可变字段,前提是您只依赖 ECMA C# 规范保证,而非特定于体系结构的实现细节。