【C# 线程】 volatile 关键字和Volatile类、Thread.VolatileRead|Thread.VolatileWrite 详细 完整

overview

同步基元分为用户模式和内核模式

用户模式:Iterlocked.Exchange(互锁)、SpinLocked(自旋锁)、易变构造(volatile关键字、volatile类、Thread.VolatitleRead|Thread.VolatitleWrite)、MemoryBarrier。

重要内容来源:C# 中的线程处理 - 第 4 部分 - 高级线程处理 (albahari.com)

==========================volatile简介(多语言共性)==========================

易失性:volatile关键字可以用来提醒编译器它后面所定义的变量随时有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。

原子性:Volatile类型的操作 都具有原子特性,所以线程间无法对其占有,它的值永远是最新的。

顺序性:volatile是内存屏障,防止处理器重新对内存操作进行排序的内存屏障。

.net C#中有3个易失性 相关的类:关键字volatile 、静态类Volatile、Thread线程中方法hread.VolatileRead()|Thread.VolatileWrite()

 

为何寄存器与内存里的值会不同?

中断发生时,CPU会立即把当前所有寄存器的值存入任务的自己的内存区域里。空出所有寄存器,交给中断去做事。中断处理函数返回后,CPU再把需要唤醒的任务的内存区里寄存器部分内容一一载入到寄存器里,并跳转到上次中断的地址,传入PC指针。这样任务就可以继续执行了。这里的关键就是中断结束后恢复到寄存器的值是从任务私有内存区载入的,而不是从原始变量载入的。所以中断期间对变量的修改就无法立即反应到寄存器里。
 

理解volatile用到的知识点:

1、内存模型:CPU硬件有它自己的内存模型,不同的编程语言也有它自己的内存模型。
2、原子性:最小的cpu操作  double 和 long在64位的操作系统中是保持原子性,在32位的操作系统中分两次执行,如果数据类型不支持原子性,则会造成多线程的读取访问数据不一致。

  • 如果一组变量总是在相同的锁内进行读写,就可以称为原子的(atomically)读写。假定字段xy总是在对locker对象的lock内进行读取与赋值
  • 指令原子性是一个相似但不同的概念:如果一条指令可以在 CPU 上不可分割地执行,那么它就是原子的。
  • volatile 关键字只对原子性的数据类型有效。


3、多线程:多线程对一个实例对象的A()和B()方法进行访问操作时候,由于线程访问顺序不同,会引起线程实例对像内部字段读取顺序不一样,导致每次运行的结果不一样。处理这个问题就要给内部字段添加volatile 关键字。
4、编辑器 clr cpu优化:编译器和clr为了提高程序的运行效率会对代码经行优化。这样优化会导致代码顺序发生变化,导致多线程的时候执行的效果不一样。
5、CPU和主存储器的通信模型:包括cpu和 缓冲 寄存器 内存直接的互动。

volatile 关键字(C#)

C# 编程语言提供可变字段,限制对内存操作重新排序的方式。 ECMA 规范规定,可变字段应提供获取-­释放语义。

 

volatile作用:

1、直接读取和写入内存数据,不使用寄存器中的数据。易失性:voldatile关键字首先具有“易失性”,就是不会缓存的意思,声明为volatile变量编译器会强制要求读内存,相关语句不会直接使用上一条语句对应的的寄存器内容,而是重新从内存中读取。

2、不具有可见性,不能保证读取的数据最新,因为无法读取其他cpu core上的store buffer的内容。 store buffer的内容只能通过内存屏障刷新得到。

注意 MSDN 文档指出,使用关键字可确保始终在字段中显示最新的值。这是不正确的,因为正如 案例二中我们所看到的,可以对写入后跟读取进行重新排序。导致出乎我们预期的结果0,0

3、具有原子性。变量具有原子性,操作不具有原子性

4、防止编译器过度优化

5、具有释放和获取的语义,以下详细解说。

6、按引用传递参数或捕获的局部变量不支持该关键字,请使用Volatile类的VolatileRead 和 VolatileWrite方法

8、volatile 它是对指令起作用而不是对语句起用

9、一般用于类内部各个方法共享字段的定义 。

获得语义(Acquire:限制对内存操作重新排序的方式 执行完。Acquire语义修饰内存读操作(包括内存修改或者读-修改-写操作)倘若一个字段用volatile 修饰符修饰(read-acquire),那么他就能够防止其后面的所有内存读写操作重排到他的前面。

 

为了方便理解获得语义,读操作, 就是使用该变量  请看以下例子:

C#
class AcquireSemanticsExample {
  int _a;
  volatile int _b;
  int _c;
  void Foo() {
    int a = _a; // Read 1
    int b = _b; // Read 2 (volatile)Read 1 和 Read 3 是不可变的,而 Read 2 是可变的。 Read 2 不能与 Read 3 互换顺序,但可与 Read 1 互换顺序。 图 2 显示了 Foo 正文的有效重新排序。
    int c = _c; // Read 3
    ...
}
}

 

释放语义(Release:限制对内存操作重新排序的方式。Release语义修饰内存写操作(包括内存修改或者读-修改-写操作)倘若一个字段用volatile 修饰符修饰(write-Release),那么他就能够防止其前面的所有内存读或写操作重排到他的后面。

 

写操作, 就是给该变量赋值。 写/读是允许的互换的, /表示屏障

 

//C#
class ReleaseSemanticsExample
{
  int _a;
  volatile int _b;
  int _c;
  void Foo()
  {
    _a = 1; // Write 1
    _b = 1; // Write 2 (volatile) Write 1 和 Write 3 是非可变的,而 Write 2 是可变的。 Write 2 不能与 Write 1 互换顺序,但可与 Write 3 互换顺序。 图 3 显示了 Foo 正文的有效重新排序。
    _c = 1; // Write 3
    ...
}
}

 

使用案例

1、防止编译器使用循环提升技术

        bool complete = false;
        var t = new Thread(() =>
        {
            bool toggle = false;
            while (!complete) toggle = !toggle;
        });
        t.Start();
        Thread.Sleep(1000);
        complete = true;
        t.Join();        // Blocks indefinitely

这个程序永远不会结束,由于complete变量被缓存在了CPU寄存器中。在while循环中加入Thread.MemoryBarrier能够解决这个问题。开发

另一种更高级的方式来解决上面的问题,那就是考虑使用volatile关键字。Volatile关键字告诉编译器在每一次读操做时生成一个fence,来实现保护保护变量的目的。具体说明能够参见msdn的介绍

以上内如来自 MSDN

C# volatile 关键字使用范围:

  • 引用类型。
  • 指针类型(在不安全的上下文中)。 请注意,虽然指针本身可以是可变的,但是它指向的对象不能是可变的。 换句话说,不能声明“指向可变对象的指针”。
  • 简单类型,如 sbytebyteshortushortintuintcharfloatbool。不支持double、long和ulong,但是Volatile类支持
  • 具有以下基本类型之一的 enum 类型:bytesbyteshortushortintuint
  • 已知为引用类型的泛型类型参数。
  • IntPtrUIntPtr
  • 其他类型(包括 doublelong double 和 long在64位的操作系统中是保持原子性,在32位的操作系统中分两次执行。)无法标记为 volatile,因为对这些类型的字段的读取和写入不能保证是原子的。 若要保护对这些类型字段的多线程访问,请使用 Interlocked 类成员或使用 lock 语句保护访问权限。

volatile 关键字只能应用于 classstruct 的字段。 不能将局部变量声明为 volatile

以上内容来源:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile

 

 错误用法

 虽然能输出正确结果,但是违反了线程安全3要素中的可见性,所以这代码在如特殊情境中就会出错。

Counter counter = new Counter();
Parallel.Invoke(counter.B, counter.B, counter.B, counter.B);
Console.WriteLine(counter.X);
 class Counter
{
    private  volatile int x = 0;

    public  int X { get => x;   }

    public void B()
    {
        for (int i = 0; i < 100; i++)
        {

            x += 1;
            
        }
    }
}

 

正确的是将:x+=1;加锁或者 修改成  Interlocked.Increment(ref x);

 正确的用法:

 

LockFreeStack<int> reeStac = new();

for (int i = 1; i <=3; i++)
{
    Thread se = new Thread(test);
    se.Start();
}

    void test(){


    for (int i = 0; i < 20; i++)
    {
        reeStac.Push(i);

    }

}


public class LockFreeStack<T>
{
    private volatile Node m_head;

    private class Node { public Node Next; public T Value; }

    public void Push(T item)
    {
        var spin = new SpinWait();
        Node node = new Node { Value = item }, head ;
        while (true)
        { 
            head = m_head;
            node.Next = head;
            Console.WriteLine("Processor:{0},Thread{1},priority:{2} count:{3} ", Thread.GetCurrentProcessorId(), Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.Priority,item );
            Node dd = Interlocked.CompareExchange(ref m_head, node, head);//如果相等 就把node赋值给m_head,返回值都是原来的m_head。
            if (dd == head) break;//判断是否赋值成功。成功就跳出死循环。
            spin.SpinOnce();
            Console.WriteLine("Processor:{0},Thread{1},priority:{2}  spin.SpinOnce()", Thread.GetCurrentProcessorId(), Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.Priority);
        }
    }

    public bool TryPop(out T result)
    {
        result = default(T);
        var spin = new SpinWait();

        Node head;
        while (true)
        {
           
            head = m_head;
            if (head == null) return false;
            if (Interlocked.CompareExchange(ref m_head, head.Next, head) == head)
            {
                result = head.Value;
                return true;
            }
            spin.SpinOnce();  //这边使用了spinwait 结构
        }
    }
}

 

 案例二、

这个案例主要考验对 volatile关键字的理解。

该案例会出乎意料的输出0,0 。为了避免该情况必须使用全内存屏障。

该例子来自:volatile的内存屏障的坑

using System;
using System.Threading;
using System.Threading.Tasks;

namespace MemoryBarriers
{
    class Program
    {
        static volatile int x, y, a, b;
        static void Main()
        {
            while (true)
            {
                var t1 = Task.Run(Test1);
                var t2 = Task.Run(Test2);
                
                Task.WaitAll(t1, t2);
                if (a == 0 && b == 0)
                {
                    Console.WriteLine("{0}, {1}", a, b);
                }
              
                x = y = a = b = 0;
            }
        }

        static void Test1()
        {
            x = 1; // Volatile write (release-fence)  转汇编 mov dword ptr [rax+0xc]
 
           //方案一 Interlocked.MemoryBarrier();
           //方案二 Interlocked.MemoryBarrierProcessWide();

            a = y; // Volatile read (acquire-fence) 转汇编edx, [rax+8]. 和 mov [rax+0x14], edx.两条指令。 cpu将y获取指令重排序移x=1之前执行。导致a=0
        }

        static void Test2()
        {
             
            y = 1;// Volatile read (acquire-fence)

            //方案一 Interlocked.MemoryBarrier();
            b = x;// Volatile read (acquire-fence)
          
        }
    }
}

 

 

 

Volatile静态类(C#)

(1)在多处理器系统上,易失性写入操作可确保写入内存位置的值立即对所有处理器都可见。 易失性读取操作可获取由任何处理器写入内存位置的最新值,因此用来做线程间读写同步。  这些操作可能需要刷新处理器缓存,这可能会影响性能。

 类中的静态和方法读/写变量,同时强制执行(技术上是超集)关键字所做的保证。然而,它们的实现相对低效,因为它们实际上会产生完整的围栏。以下是它们对整数类型的完整实现:

public static void VolatileWrite (ref int address, int value)
{
  MemoryBarrier(); address = value;
}
 
public static int VolatileRead (ref int address)
{
  int num = address; MemoryBarrier(); return num;
}

 

VolatileRead能执行以下几种原子读取:

方法
public static byte VolatileRead(ref byte address);
public static double VolatileRead(ref double address);
public static float VolatileRead(ref float address);
public static int VolatileRead(ref int address);
public static IntPtr VolatileRead(ref IntPtr address);
public static long VolatileRead(ref long address);//注意:这里保证long类型的原子读取  ,volatile 关键字不支持这个
public static object VolatileRead(ref object address);
public static sbyte VolatileRead(ref sbyte address);
public static short VolatileRead(ref short address);
public static uint VolatileRead(ref uint address);
public static UIntPtr VolatileRead(ref UIntPtr address);
public static ulong VolatileRead(ref ulong address);//注意:这里保证long类型的原子读取,volatile 关键字不支持这个
public static void Write<T> (ref T location, T value) where T : class;
location T 将对象引用写入的字段。
value T 要写入的对象引用。 立即写入一个引用,以使该引用对计算机中的所有处理器都可见。
public static T Read<T> (ref T location) where T : class;
location T 要读取的字段。
T 对读取的 T 的引用。 无论处理器的数目或处理器缓存的状态如何,该引用都是由计算机的任何处理器写入的最新引用。


案例

仅做案例说明Volatile类的使用,该方法在复杂环境肯定会出错。

 

 Thread类中的VolatileRead和VolatileWrite方法和Volatile关键字(C#)

     Thread类来看下其中比较经典的VolatileRead和VolatileWrite方法 不知long和ulong类型、也不支持泛型

VolatileWrite:

   该方法作用是,当线程在共享区(临界区)传递信息时,通过此方法来原子性的写入最后一个值

VolatileRead:

   该方法作用是,当线程在共享区(临界区)传递信息时,通过此方法来原子性的读取第一个值

 

 

 

 

 

 

/// <summary>
    /// 本例利用VolatileWrite和VolatileRead来实现同步,来实现一个计算
    /// 的例子,每个线程负责运算1000万个数据,共开启10个线程计算至1亿,
    /// 而且每个线程都无法干扰其他线程工作
    /// </summary>
    class Program
    {
        static Int32 count;//计数值,用于线程同步 (注意原子性,所以本例中使用int32)
        static Int32 value;//实际运算值,用于显示计算结果
        static void Main(string[] args)
        {
            //开辟一个线程专门负责读value的值,这样就能看见一个计算的过程
            Thread thread2 = new Thread(new ThreadStart(Read));
            thread2.Start();
            //开辟10个线程来负责计算,每个线程负责1000万条数据
            for (int i = 0; i < 10; i++)
            {
                Thread.Sleep(20);
                Thread thread = new Thread(new ThreadStart(Write));
                thread.Start();
            }
            Console.ReadKey();
        }

        /// <summary>
        /// 实际运算写操作
        /// </summary>
        private static void Write()
        {
            Int32 temp = 0;
            for (int i = 0; i < 10000000; i++)
            {
                temp += 1;
            }
            value += temp;
            //注意VolatileWrite 在每个线程计算完毕时会写入同步计数值为1,告诉程序该线程已经执行完毕
            //所以VolatileWrite方法类似与一个按铃,往往在原子性的最后写入告诉程序我完成了
            Thread.VolatileWrite(ref count, 1);
        }

        /// <summary>
        ///  显示计算后的数据,使用该方法的线程会死循环等待写
        ///  操作的线程发出完毕信号后显示当前计算结果
        /// </summary>
        private static void Read()
        {
            while (true)
            {
                //一旦监听到一个写操作线执行完毕后立刻显示操作结果
                //和VolatileWrite相反,VolatileRead类似一个门禁,只有原子性的最先读取他,才能达到同步效果
                //同时count值保持最新
                if (Thread.VolatileRead(ref count) > 0)
                {
                    Console.WriteLine("累计计数:{1}", Thread.CurrentThread.ManagedThreadId, value);
                    //将count设置成0,等待另一个线程执行完毕
                    count = 0;
                }
            }
        }


    }
posted @ 2021-12-27 02:06  小林野夫  阅读(628)  评论(2编辑  收藏  举报
原文链接:https://www.cnblogs.com/cdaniu/