理论与实践中的 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 1 Console.WriteLine(_data); // Read 2 else Console.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 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 ... } }
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 1 Console.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 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 的一个使用示例,它允许输出“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.MemoryBarrier 不能与任何之前或之后的内存操作互换顺序。 但与 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 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# 规范是否作出这一保证有争议。
一方面,该规范仅指定可变字段遵守获取-释放语义,这似乎不足以禁止提升可变字段。 另一方面,该规范中的示例代码实际上轮询一个可变字段,这意味着该可变字段读取不能被提升出该循环。
在 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