内存模型
在谈论线程同步相关的知识之前,我们得先知道 “内存模型” 是什么。
内存模型是系统定义的规则,编译器会根据它对读写操作进行重排序。处理器根据它对跨线程读写进行重排序。并且内存模型是无法修改。
数据格式
内存模型的数据格式被分为两种
- 强类型:严格模式下,禁止编译器和硬件进行优化。
- 若类型:允许编译器和处理器自由的重新排列读和写指令。
不同的系统平台会用不同的内存模型,有些比较严格(比如 x86/x64 架构),而 arm 架构比较宽松。但是另一方面,ARM 它不仅允许 JIT 编译器以正确的顺序发出机器指令,还允许使用特殊的指令来确保处理器本身不会以违反 CLR 内存模型的方式来重排序。
不同处理器架构对程序编码也有明显影响,特别是当在线程同步的某些 bug 情况下。因为在 ARM 下,JIT 编译器要比在 x86/x64 下对读写指令的重排序更加自由,所以关于线程同步的 bug 在后者可能无法察觉,但是转到 ARM 下就能看到。
内存屏障
屏障(Memory Barrier)是一种同步机制,用于控制指令重排序和内存访问顺序,确保指定操作的执行顺序以及内存操作的可见性。主要有以下几种类型
写屏障
确保在屏障之后的任何写操作都不会对其他 CPU 可见,直到屏障之前的所有写操作对其他 CPU 可见。即:确保屏障前的写操作在屏障后的任何写操作之前完成。
读屏障
确保在屏障之后的任何读操作都不会被重排序到屏障之前。即:确保屏障前的读操作在屏障后的任何读操作之前完成。
通用内存屏障
也叫全屏障,结合读屏障和写屏障的功能,保证所有先前的读写操作在通用屏障的后续操作之前完成。它们是实现线程同步、锁等同步原语的基础。
数据依赖屏障
数据依赖屏障是一种用于处理指令间明确数据依赖关系的同步机制。这种依赖通常出现在当一个操作的输出直接作为另一个操作的输入时。数据依赖屏障确保在有数据依赖的两个操作之间,第一个操作的结果必须在第二个操作开始前完全可用。这对于确保程序在多处理器系统中按预期执行非常关键,特别是在使用弱内存模型的处理器架构中。
例如,在以下情况需要数据依赖屏障:
- 在加载一个变量的值后,基于这个值进行一些计算或决策。数据依赖屏障会确保加载操作完成,且其结果对接下来的操作可见。
控制依赖屏障
控制依赖屏障用于处理程序控制流中的依赖。控制依赖发生在程序的执行路径依赖于某些条件判断的结果。控制依赖屏障确保在进行决策前,所有相关的条件检查都已完全评估完成,并且其结果正确地影响了程序的控制流。
例如:在判断指令(如if
语句)中使用了某个变量的值,而这个变量可能由其他 CPU 核心在并行修改。而没有控制依赖屏障的情况下,处理器或编译器可能会提前读取这个变量的值,导致产生错误的执行路径。而控制依赖屏障就是确保在执行决策逻辑前,所有相关的数据加载操作已经完成,并且结果已经是最新的。
volatile
关键字 Volatile 就是设置系统编译器是否对这些读写指令进行重新排序,加上 Volatile 关键字就是表明要严格按照顺序执行指令。
除了使用关键字 Volatile 之外,还可以将共享数据放入 lock 关键字内或者是 interlocked 块中。所有的同步方法都会创建一道内存屏障(Memory Barrier)。所有在同步指令之前读取的数据在屏障之后都不允许重新排序。所有在屏障之前写入的数据都不允许重排序。这样数据的更新对所有 CPU 都是可见的。(CLR via C# 中将此过程简称为,易失共享变量要在第一个读,最后一个写)。
双检索 —— volatile
先看下面的 “双检索” 的实现
private bool isComplete = false;
private object asyncObject = new object();
// 以下是错误实现
private void Complete()
{
if(!isComplete)
{
lock (syncObject)
{
if (!isComplete) {
DoCompletionWork();
isComplete = true;
}
}
}
}
由于前面提到的内存模型,系统允许编译器对读写指令重排序,所以 isComplete 变量更新的顺序是不可控的,即某个线程将它设为 true 之后,其他线程仍然会看到 false。更糟糕的是,对 isComplete = true
这个写指令有可能被提前到 DoCompletionWork() 之前。
那么如何修正问题呢?只要在 IsComplete 加关键字 volatile 即可。
private volatile bool isComplete = false;
要记住:
volatile 不是用来提高性能的,而是为了保证正确性的,它不会明显降低或提高性能