[多线程之旅]四、浅谈volatile

本文深入探讨了volatile关键字在多线程编程中的应用,包括内存操作顺序、可见性和原子性,详细解释了如何使用volatile来解决并发编程中的常见问题,并通过实例展示了volatile如何确保内存操作的顺序、提升变量的可见性和限制内存操作的重新排序。
摘要由CSDN通过智能技术生成

一、       内存操作顺序

导致多线程编程的具有复杂性的众多原因之一是:编译器和硬件可能会在背后悄然改变程序的操作顺序,这种顺序的的改变不会影响单线程的行为,但在多线程编程中,由于编译器和硬件的这种行为,可能会导致一些我们异想不到的结果,我们先看下Jeffrey的《CLR VIA C#》中的一段代码示例:

internal class ThreadsSharingData
{
    private Int32 m_flag = 0;
    private Int32 m_value = 0;
    /// <summary>
    /// 这个方法由一个线程执行
    /// </summary>
    public void Thread1()
    {
        m_value = 5;
        m_flag = 1;
    }
    /// <summary>
    /// 这个方法由另外一个线程执行
    /// </summary>
    public void Thread2()
    {
        while (m_flag == 1)
            Console.WriteLine(m_value);
    }
}

大家考虑一下Thread2的输出结果可不可能为“0”?

答案先且不谈,我先照我的思维去推测一下(即代码的执行顺序与我们编写的一致)所有的输出可能,我列了一个表如下:


以上表列出了我认为可能输出的所有结果,大家注意一下,当m_flag=1时,没有任何一项结果表明m_value为0,于是,我给出的答案是不可能为0,但很遗憾我的答案是错的,这是为什么呢?

这是因为编译器和硬件可能会在背后悄然的改变了我的代码操作顺序

我们考虑Thread1中的方法:

public void Thread1()
{
    m_value = 5;
    m_flag = 1;
}

编译器和CPU可能会重新对这些操作重新排序,改变完后的顺序类似于下:

public void Thread1()
{
    m_flag= 1;
    m_value=5;
}

在单线程中,CPU和编译器进行这样的改变,并不会影响我们的意图,因为无论先后,我们在后续代码中的使用都能保证m_flag和m_value会读取到我们想要的值,但如果存在另一个线程,在时时的监测这两个值的变化,并做出相应的处理时,就会导致我们意想不到的结果

比如第一个线程给m_flag赋值完后,第二个线程就很有可能得到m_flag=1,m_value=0的结果了。

Thread1中的内存操作顺序的改变仅仅是导致麻烦的因素之一,另外一种情况是:Thread1中的内存写入顺序没变,但Thread2中的方法却被编译器和CPU优化成如下类似代码:

public void Thread2()
{
    Int32  temp=m_value
    while (m_flag == 1)
        Console.WriteLine(temp);
}

当Thread1还未执行,Thread2执行读取m_value至缓存(值为0),Thread1再设置m_value=5,m_flag=1,由于线程1之前将已将m_value读入了缓存(但实际上这个缓存已过期了),但CPU是不知情的,因为处理器发现缓存中存在m_value的缓存,但没有m_flag,的,因此处理器只会从主存中读取m_flag,因此Thread2方法同样有仍可能输出结果“0”;

易失性(volatile)

C#语言提供关键字volatile来修饰字段,以此来限制对内存读写操作重新排序。且被修饰的字段应提供“获取-释放”语义。

易失字段的读取具有获取语义,它意味着易失字段不能与后续操作互换顺序,

internal class AcquireSample
{
    private Int32 _a;
    private volatile Int32 _b;
    private Int32 _c;
    public void AcquireValue()
    {
        Int32 a = _a;//Read1
        Int32 b = _b;//Read2 易失性
        Int32 c = _c;//Read3
        ….
    }
}

易失性的获取语义规定,Read2不能与后续的操作互换位置。即Read2不能与Read3互换。但Read1与Read3可以互换位置,下面列出CPU和编译器可以合理优化内存操作顺序:


大家注意一下,Read3永远只能处于Read2之后,假如在Read3之后还有Read4、Read5(非易失性)等操作,只会使得Read3、Read4、Read5等都应该在Read2之后,至于Read3和Read4、Read5之间的读取顺序,CPU和编译器可以任意排序

 

易失性字段的写入操作具有释放语义,因此它不能与之前的操作互换顺序

internal class ReleaseSample
{
    private Int32 _a;
    private volatile Int32 _b;
    private Int32 _c;
    public void ReleaseValue()
    {
        _a = 1;//Write1
        _b = 2;//Write2 易失性
        _c = 3;//Write3
    }
}

Write2不能与Write1互换,但Write1可以和Write3互换顺序,下面列出CPU和编译器合理的优化内存操作顺序:


现在我们再来回顾一下,本篇的第一个示例代码,我们对其进行一些修改,将m_flag标志为volatile(易失性),如下:

internal class ThreadsSharingData
{
    private volatile Int32 m_flag = 0;
    private Int32 m_value = 0;
    /// <summary>
    ///这个方法由一个线程执行
    /// </summary>
    public void Thread1()
    {
        m_value = 5;//Write1
        // volatile释放语义,确保Write1操作在Write2之前执行,保证内存写入顺序
        m_flag = 1; //Write2 
    }
    /// <summary>
    ///这个方法由另外一个线程执行
    /// </summary>
    public void Thread2()
    {
        // volatile获取语义,确保Read2操作在Read1之后执行,保证内存读取顺序
        while (m_flag == 1) //Read1
            Console.WriteLine(m_value);//Read2
    }
}

在我们对m_flag标识为volatile后,Thread2方法的输出结果永远不可能为0,执行的结果,程序的执行过程,完全在我们的预期之中,我们再来分析下这个过程

 

Thread1方法中,m_flag内存写入操作现在保证在m_value的内存写入操作之后

(注意这句话的意思,这要说明一下,这里不是m_flag缓存写入在m_value缓存写入操作之后,这是因为,程序运行时,所有的变更,先会在Cache中完成,然后再才会拷贝到内存中,如果仅仅是m_flag的缓存写入在m_value缓存写入之后,这也不能保证m_flag的内存写入在m_value内存写入之后)

 

因此在Thread2方法中满足while(m_flag==1)时,m_value在内存中的值一定已经为5了,当然如果仅仅是Thread1有了保证还不足以使得结果如我们预期这样,这是因为就算Thread1中的写入操作是按顺序执行,但如果Thread2方法中,如果没有易失性的获取语义限制的话,那么m_value仍有可能在获取m_flag之前获取一个0值,所幸的是,现在由于m_flag的获取语义规定m_value的获取必须要在获取m_flag之后进行的。

 

思考:

我们将上述代码中的Thread2改变一下,如下:

public void Thread2()
{
    while(true){
          while (m_flag == 1) {
             Console.WriteLine(m_value);
             return;
          }
     }
}
Thread2方法用于一个单独的线程循环监测m_flag和m_value的值。当然这里的输出不可能输出"0",因为m_flag为易失性字段。

但我们考虑一下,如果易变性仅仅只是保证内存操作不乱序这一种作用的话,那么有没有可能,while(m_flag==1)条件永远不会满足,从而导致死循环呢?

考虑在多核CPU的情况下:

CPU2中的线程2执行Thread2,此时读取m_flag的值加载至缓存,此时m_flag的值为0

CPU1中的线程1执行Thread1,改变m_flag内存为1;

CPU2中的线程2再次执行m_flag==1,但很可能,m_flag只会取缓存中的值(0),从而导致Thread2中的while会一直循环下去。

因为编译器在编译Thread2方法时,发现m_flag的值在其方法内没有发生变更,因此为了提高效率,对m_flag的值只会在第一次执行时从内存中取出一次,以后的每一次只会从本地cache中读取。

因此我们在此还要讨论易失字段的另外一个重要特性---可见性

二、       可见性

我们先来看下以下代码示例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
namespace ThreadDemo
{
    public class Sample
    {
        private bool flag = false;
        //private volatile flag=flase; 
        public void Main()
        {
            Thread th = new Thread(Worker);
            th.Start();
            Thread.Sleep(2000);
            flag = true;
            Console.WriteLine("Flag became true!");
        }
        void Worker()
        {
            flag = false;
            while (!flag)
            {
            }
            Console.WriteLine("Done");
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Sample s = new Sample();
            s.Main();
            Console.Read();
        }
    }

}

我们在选择Release模式发布后,运行,会发现,在输出”Flag became true!”后,Worker方法中的while还会一直循环,不会输出“Done”;

这是什么原因呢,明明flag已经变为true了呀?

其实这里问题是,主线程对字段flag所做的变更,对于新线程来讲,很可能是不可见的,造成这种现象的原因可能是:在Worker方法中,在对flag=false进行内存写入操作,并持有flag的cache,并且编译器发现,在其后续的代码没有看到flag的变化,从而导致编译器的优化处理,这种处理的后果是,新线程在执行这个方法时,对flag的值的读取,只会从本地cache中读取一个实际上可能过期的flag值,它不会每次都跨内存栅栏去内存读取(编译器和CPU优化的本意是好的,这样可以加快处理速度)。

如果想要修复这个问题,便可以使flag应用volatile,这里利用的是volatile的另一个特性,被标识volatile的字段,会告诉编译器,对于flag=true的写入操作,立马从缓存中刷新到主存中,对flag的读取,忽略本地cache,直接从内存中读取。

思考:

我在了解volatile的过程中,参考了许多博友的博文,我发现有很多都使用类似下面代码的例子:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
namespace ThreadDemo
{
    class Program
    {
        private static bool flag;
        static void Main(string[] args)
        {
            Thread th = new Thread(Worker);
            th.Start();
            Thread.Sleep(2000);
            flag = true;
            Console.WriteLine("Flag became true!");
            Console.Read();
        }
        static void Worker()
        {
            while (!flag)
            {
            }
            Console.WriteLine("Done");
        }
    }
}

但实际上,我在Relase模式后,去测试这段代码(测试次数超过50次)发现都是在2秒过后,While循环退出,输出”Done”

这里我不是很理解,在网上,有个网友给我的回复是:“编译器在线程编译的时候只有在flag在该线程被设定了值,并且从未对其再次赋值,才会对其进行相应的嵌入优化,无论是volatie还是static都会破坏编译器的这种优化”

这里我也不知道确定的答案,大家一起思考一下。

三、      volatile并不保证原子性

volatile关键可以用来修饰byte,sbyte,short,ushort,int,uint,char,float、bool以及引用类型,例如object等

大家注意到了这些类型有一个什么共同点吗?

那就是对他们的读写都是一个原子操作,这是因为在32位CPU中,对于小于等于32位的变量进行操作,都可以用一条指令完成,byte 8位,short16位,float 32位,引用类型变量中存的是一个对象的引用,即一个指针,指针也是一个32位的Int类型

 

正是因为volatile修饰的变量本身就保证其读写是一个原子操作,这样有时候会给我们一个错觉,认为这个原子操作是由于volatile保证的,这一点我们一定要注意。

 

我们假设有一个这样一个变量 private volatile Int64 a(事实上,这段代码无法编译通过,volatile不能修饰long类型)对Int64类型的读写是非原子操作,因为执行这样的操作要使用多条指令

线程1:a=0x0123456789abcdef;

线程2:getVal(a);

Volatile只能保证a的可见性,即线程1对a进行内存写入,线程2能获取到内存中的最新值。

但不能保证原子性,如果对读写不进行LOCK的话,那么线程2很有可能获取到一个撕裂值,如0x0123456700000000或者0x0000000089abcdef。

 

如果即要使用volatile的顺序不变性和可见性,同时又要保证对Int64这种非原子性读写的原子性的话,C#会能另外一个选择,Thread.VolatileWrite/VolatileRead,这两种方法的使用,会在下一节进行详细介绍。

 

注意:易失构造,要求传递“包含一个简单数据类型的一个变量”的引用(内存地址),有的CPU架构要求这个内存地址正确对齐,这意味着包含1字节、2字节、4字节的变量所处的内存地址必须分别是1、2、4的倍数,而包含8字节的一个变量所在的内存地址(4或8的倍数)允许底层硬件对值进行原子操作。


参考:

《CLR VIA C#》

http://www.cnblogs.com/bibiKu/archive/2013/02/19/2917161.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值