有些人可能从来没看到过这个关键字,这也难怪,因为这个关键字并不常用。那这个关键字到底有什么用呢?

我在网上搜索这个关键字的时候,发现很多朋友都有一个错误的认识 ------ 认为这个关键字可以防止并发争用(有点类似 lock 的赶脚)。

volatile 作用重定义

volatile 中文解释是“可变的”,MSDN 上关于此关键字的解释如下:“volatile 关键字指示一个字段可以由多个同时执行的线程修改。 声明为 volatile 的字段不受编译器优化(假定由单个线程访问)的限制。 这样可以确保该字段在任何时间呈现的都是最新的值。”

不知道你看了上述描述是不是恍然大悟,反正我是没看懂。在网上查阅了众多资料后,才算有所明白,把上面的话用新的方式重新解读后,就有了如下的结论。

1、阻止编译器优化:JIT 编译器会自动对代码进行优化,从而导致最终代码的指令顺序发生变化。使用 volatile 关键字就可以避免 JIT 编译器对此进行优化,如:

public bool _goOn = true;

//未优化
public void Execute()
{
    while(_goOn)
    {
        //do something
    }
}

//优化后
public void ExecuteOptimized
{
    if(_goOn)
    {
        while(true)
        {
            //do something
        }
    }
}

 上面的方法只是拿来举个例子,实际优化后的情况并不完全一样。

因为 JIT 认为在单个线程内,_goOn 这个变量的值并没有在循环中修改,所以不需要每次重新去读取,因此就会把这个值提取出来。但是如果在循环的时候,有另一个线程修改了 _goOn 的值,那逻辑就会出现错误。

C++ 中的 volatile 关键字无法保证指令的顺序执行

2、阻止处理器优化:对于多线程尤其是多核心的 CPU 来说,当两个线程操作同一个变量,其中一个在不断的读取这个变量,另一个在不断修改这个变量,CPU 会为了减少对内存的大量访问,而将这个变量缓存在多个核的 Cache 中,这样每次执行指令都可以从 Cache 中迅速返回(访问高速缓存的速度要远高于访问内存的速度)。这样虽然性能提高了,但了伴随着一个问题就是,其中一个线程无法立刻收到另一个线程对该变量的更新。使用 volatile 关键字可以确保每次对变量的读取和更新都是直接操作内存,也就是说每个线程所获取到的值都是相同的,不会有冲突。

volatile 运行效果

在 Stackoverflow 上,有个朋友给出了一个可以运行的 volatile 实例,通过这个实例就能更直观的知道 volatile 的作用。

static void Main()
{
    var test = new Test();

    new Thread(delegate() { Thread.Sleep(500); test.foo = 255; }).Start();

    while (true)
    {
        if (test.foo == 255)
        {
            break;
        }
    };
    Console.WriteLine("OK");
}

根据那位朋友给出的运行方案,我在 release 模式下,使用 Ctrl + F5 直接运行得到的输出是:

等待许多,仍然没有任何输出

修改 foo 的修饰符,加上 volatile,然后再运行:

本机的运行环境为:Win7 x64、Visual Studio 2012。

之所以在不用 volatile 关键字修饰的时候会导致死循环,就是因为指令被优化了。不同的 CPU 架构采用的方式会有所不同,在我的机器上(x64)上,通过查看运行时的汇编指令时可以发现在没有使用 volatile 的情况下,在判断 test.foo == 255 这句话的时候,一直是在读取 EAX 寄存器中的值。而当使用了 volatile 关键字后,每次都是重新从内在中读取。

// 没有使用 volatile 的情况
0000004f  mov         eax,dword ptr [esi+4]    // 读取内存中的值,并保存在寄存器 EAX 中(esi 指向内存中的地址)
00000052  mov         eax,dword ptr [eax+4]
00000055  cmp         eax,0FFh                 // 直接比较寄存器 EAX 的值是否为 255
0000005a  jne         00000055                 // 如果判断不成立,则继续执行上一行代码


// 使用了 volatile 的情况
0000004f  mov         eax,dword ptr [esi+4]     // 读取内存中的值,并保存在寄存器 EAX 中
00000052  cmp         dword ptr [eax+4],0FFh    // 比较寄存器 EAX 的值是否为 255
00000059  jne         0000004F                  // 如果判断不成立,则继续执行地址为 4f 的代码

 当没有 volatile 修饰时,执行循环的线程只读取了一次 foo 值,然后一直使用该值,造成了死循环。而使用 volatile 后,每次都会去查看最新的 foo 值,因此才能正常执行。

寄存器知识拾遗:多核 CPU 中,每个核心都有全套寄存器。一个线程只可能在一个核心上运行,不可能开始的时候在核心 A 上,结束时却在核心 B 上,这意味着一个线程在其生命周期内只可能操作一套寄存器。而当同一个核心上的不同线程切换时,当前CPU的寄存器值会被保存到线程内核对象的一个上下文结构中,然后下次该线程被再次调度时,会用内核对象中保存的值恢复寄存器。

volatile 不能替代 lock

从上述提到的两点,应该不难看出 volatile 关键字的作用中并没有哪一点是用于避免多线程对同一个变量的争用的,也就是说它不具有同步的作用。

先来看一个示例:

static int i = 0;

static void Main(string[] args)
{
    Task t = Task.Factory.StartNew(() =>
    {
        i = 10;
        //Thread.Sleep(500);
        Console.WriteLine("10 i={0}", i);
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        i = 100;
        //Thread.Sleep(1);
        Console.WriteLine("100 i={0}", i);
    });

    Console.ReadLine();
}


10 i=100上述程序运行后,除了主线程,还会创建两个新线程,且都会修改同一个变量。由于无法控制每个线程执行的时机,上述代码运行的结果有可能如下(把注释掉的代码反注释回来,效果更明显):

100 i=100

这就需要同步机制。修改上述代码,加上 lock 看下效果:

static object lckObj = new object();
static int i = 0;

static void Main(string[] args)
{

    Task t = Task.Factory.StartNew(() =>
    {
        lock (lckObj)
        {
            i = 10;
            //Thread.Sleep(500);
            Console.WriteLine("10 Thread.Id:{0} i={1}", Thread.CurrentThread.ManagedThreadId, i);
        }
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        lock (lckObj)
        {
            i = 100;
            //Thread.Sleep(1);
            Console.WriteLine("100 Thread.Id:{0} i={1}", Thread.CurrentThread.ManagedThreadId, i);
        }
    });

    Console.ReadLine();
}


10 i=10现在,无论运行上述代码多少次,得的答案都是一样的:

100 i=100

现在,再使用 volatile 看看,是否有同步的效果:

static volatile int i = 0;

static void Main(string[] args)
{
    Task t = Task.Factory.StartNew(() =>
    {
        i = 10;
        //Thread.Sleep(500);
        Console.WriteLine("10 i={0}", i);
    });
    Task t2 = Task.Factory.StartNew(() =>
    {
        i = 100;
        //Thread.Sleep(1);
        Console.WriteLine("100 i={0}", i);
    });

    Console.ReadLine();
}


 运行后,你便会发现,屏幕上显示的输出和没有使用 lock 是完全一样的。

什么时候使用 volatile?

x86 和 x64 架构的 CPU 本身已经对指令的顺序进行了严格的约束,除了各别情况,大多数情况下使用和不使用 volatile 的效果是一样的。

上面的文字大致意思是指 X86 和 X64 的处理器总是会加入内存屏障来防止乱序,所以加不加 volatile 效果一样。但是在诸如 64位的 AMD CPU 或者 Itanium CPU 则需要手动去预防可能的乱序。

lock 关键字会隐式提供内存屏障,且更严格(完全禁止乱序和缓存,而 volatile 只是禁止一部分的乱序,这样编译器仍然可以在一定程度上进行代码优化),在性能上要差于 volatile。因此,除非你非常在意性能,同时对内存模型或CPU平台非常了解,否则建议直接使用 lock 关键字,lock 关键字不止屏蔽了乱序和缓存可能引起的异常,同时也可以避免多个线程的争用。

修改 <volatile 运行效果> 这一节中的示例,使用 lock 关键字,如:

int foo;
    static object lckObj = new object();

    static void Main()
    {
        var test = new Program();

        new Thread(delegate()
        {
            Thread.Sleep(500);
            lock (lckObj)
                test.foo = 255;
        }).Start();

        while (true)
        {
            lock (lckObj)
                if (test.foo == 255)
                {
                    break;
                }
        }
        Console.WriteLine("OK");
    }

 上述代码运行效果与使用了 volatile 关键字的效果一样。

参考资源

Don't get C# volatile the wrong way

Volatile fields in .NET: A look inside

Volatile vs. Interlocked vs. lock

Nonblocking Synchronization

C/C++ Volatile关键词深度剖析

转载至 http://blog.chenxu.me/post/detail?id=1d39c8ae-4ed7-4498-8408-9ef3a71ed954

01-19 20:00