【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()
为何寄存器与内存里的值会不同?
理解volatile用到的知识点:
1、内存模型:CPU硬件有它自己的内存模型,不同的编程语言也有它自己的内存模型。
2、原子性:最小的cpu操作 double 和 long在64位的操作系统中是保持原子性,在32位的操作系统中分两次执行,如果数据类型不支持原子性,则会造成多线程的读取访问数据不一致。
- 如果一组变量总是在相同的锁内进行读写,就可以称为原子的(atomically)读写。假定字段
x
与y
总是在对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
关键字使用范围:
- 引用类型。
- 指针类型(在不安全的上下文中)。 请注意,虽然指针本身可以是可变的,但是它指向的对象不能是可变的。 换句话说,不能声明“指向可变对象的指针”。
- 简单类型,如
sbyte
、byte
、short
、ushort
、int
、uint
、char
、float
和bool
。不支持double、long和ulong,但是Volatile类支持 - 具有以下基本类型之一的
enum
类型:byte
、sbyte
、short
、ushort
、int
或uint
。 - 已知为引用类型的泛型类型参数。
- IntPtr 和 UIntPtr。
- 其他类型(包括
double
和long double 和 long在64位的操作系统中是保持原子性,在32位的操作系统中分两次执行。
)无法标记为volatile
,因为对这些类型的字段的读取和写入不能保证是原子的。 若要保护对这些类型字段的多线程访问,请使用 Interlocked 类成员或使用lock
语句保护访问权限。
volatile
关键字只能应用于 class
或 struct
的字段。 不能将局部变量声明为 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;
}
}
}
}