C# 内存模型

在 C# 的语言规范中 ECMA-334,对于Volatile关键字的描述:

15.5.4 Volatile fields
When a field-declaration includes a volatile modifier, the fields introduced by that declaration are volatile fields. For non-volatile fields, optimization techniques that reorder instructions can lead to unexpected and unpredictable results in multi-threaded programs that access fields without synchronization such as that provided by the lock-statement (§13.13). These optimizations can be
performed by the compiler, by the run-time system, or by hardware. For volatile fields, such reordering optimizations are restricted:

  • A read of a volatile field is called a volatile read. A volatile read has “acquire semantics”; that is, it is guaranteed to occur prior to any references to memory that occur after it in the instruction sequence.
  • A write of a volatile field is called a volatile write. A volatile write has “release semantics”; that is, it is guaranteed to happen after any memory references prior to the write instruction in the instruction sequence.

简单来说,对于常规字段,由于代码优化而导致指令顺序改变,如果没有进行一定的同步控制,在多线程应用中可能会导致意想不到的结果,而造成这种意外的原因可能是编译器优化、运行时系统的优化或者因为硬件的原因(即CPU和主存储器的通信模型)。可变(volatile)字段会限制这种优化的发生,在这里引入两个定义:

  • 可变读: 对于可变字段的读操作会获取语义。即,其可以保证对于可变字段的内存读取操作一定发生在其后内存操作指令的前面。进一步解释,与 Thread.MemoryBarrier 类似,获取语义会保证在读取可变字段指令前的指令可以跨越它出现在它后面,但是相反地,在它后面的指令不能跨越它出现在它的前面。例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Volatile_class
    {
    private int _a;
    private volatile int _b;
    private int _c;

    private void Call()
    {
    int temp=_a;
    //由于_b是可变字段,这样可以保证编译器不会将temp2=_c的指令提前到其之前
    //但是,可以将temp=_a提到其之后
    int temp1=_b;
    int temp2=_c;

    ...
    }

    private void OtherCall(){...}
    }
  • 可变写: 对于可变字段的写操作会释放语义。即,其可以保证对于可变字段的写操作发生在其前面指令执行之后,但是在它之后的指令可以跨域它提前执行。

    X86_X64

    现代的 x86_x64 CPU 可以保证字段的读写都是 “volatile” 的,即你不会读取到旧的字段值,这是由 CPU 提供保证的。这样看起来好像与上面的描述存在矛盾,如果 CPU 可以保证所有字段的读写都是 volatile ,那为什么还需要在语言层面提供volatile关键字。其实这是两个不同的概念,CPU 从硬件层面上保证了对内存的读写是实时的,你不会读取到 Stale Value ,无论这个字段是常规字段还是可变字段。而语言层面上的 volatile 只是一个关键字,告诉编译器不能对该字段进行 instruction reorder 等可能导致多线程读写出现不符合预期结果的优化(暂且这样理解)。

参考这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Program
{
class infinity_loop
{
public bool Terminated;
}

static void Main(string[] args)
{
var loop=new infinity_loop();

new Thread(()=>{
loop.Terminated=true;
}).Start();

while(!loop.Terminated);
}
}

使用 dotnet core Release 模式运行这段代码,可以发现它永远也不会退出,分析汇编代码:

可以看到红色框选位置,指令test一直在比较eax寄存器上的值,而该寄存器缓存了loop对象的Terminated值(为false),汇编语言中,test是对两个参数进行AND操作,并设置对应的标志位。例如,如果两个值的AND操作为0,则ZF标志会被设置为1。而je指令是:根据特定标志位的情况进行跳转,其中就包括了ZF标志位。回到上面的汇编代码,可以知道 test eax eax 肯定会将ZF设置为1,则je就会导致死循环的产生。

尝试为Terminated值添加volatile关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Program
{
class infinity_loop
{
public volatile bool Terminated;//可变字段
}

static void Main(string[] args)
{
var loop=new infinity_loop();

new Thread(()=>{
loop.Terminated=true;
}).Start();

while(!loop.Terminated);
}
}

运行代码,可以发现程序正常退出。再看汇编代码:

可以看到,这次是直接比较内存中字段的真实值,而不是寄存器上的值,这样循环会正常退出。

这是因为Loop Hoisting优化策略导致其中的循环判断经过JIT编译器优化后变成如下:

1
2
if(!loop.Terminated)
while(true);

可以想象,这段优化过后的代码在多线程应用中是永远不会退出的。

最佳实践

volatile 是一个比较晦涩,理解起来可能比较困难的概念,并不建议在不理解的情况下使用,你可以使用lock,Thread.MemoryBarrier或者Interlocked作为替代,不仅仅因为其中有过多的细节对开发人员隐藏,而且还要保证你的团队组员都理解其中的工作原理,特别地,volatile还会受不同环境影响,例如.NET Framework,编译器版本,甚至是硬件实现,这些都是需要考虑的因素。你要在使用 lock(或者其他)导致的性能开销和 volatile 引入导致的代码维护难度这两方面进行权衡。

参考文章:
https://msdn.microsoft.com/en-us/magazine/jj883956.aspx

评论