1.Overview
经常研究.NET源码库的小伙伴会经常看到一个关键字volatile,那它在开发当中的作用是什么呢?
我们一起来看看官方文档里是怎么描述的,如下:
“volatile
关键字指示一个字段可以由多个同时执行的线程修改。出于性能原因,编译器,运行时系统甚至硬件都可能重新排列对存储器位置的读取和写入。声明为 volatile
的字段将从某些类型的优化中排除。不确保从所有执行线程整体来看时所有易失性写入操作均按执行顺序排序。”
本文将围绕这部分进行解读。
声明语法如下:
class VolatileTest
{
public volatile int sharedStorage;
public void Test(int i)
{
sharedStorage = i;
}
}
2.Detail
我们先了解一下前置知识点。
(1)在CLR中将对sbyte
、byte
、short
、ushort
、int
、uint
、char
、float
和 bool
。以及引用类型保证读写时原子性的(long、double不是原子性读写)变量中的所有字节都是一次性写入或读取的。
(2)Framework Class Library(FCL) 保证所有静态方法都是线程安全的。这意味着假如两个线程同时调用一个静态方法,不会有数据被损坏。为什么?
public static string Print(String str)
{
string val = "";
val += str;
return val;
}
因为静态方法内声明的变量,每个线程调用时都会新创建一份,而不会共用一个存储单元。比如这里的val每个线程都会创建自己的一份,因此不会有线程安全问题。注意:静态变量,由于是在类加载时占用一个存储区每个线程都是共用这个存储区的,所以如果在静态方法里使用了静态变量;这就会有线程安全问题。
(3)内存、CPU缓存(注:下列为简述内容,实际上不仅如此)
CPU缓存,CPU集成的缓存。
内存,内存条硬件提供的存储空间。
我们继续回到主要内容上,用下面的若干代码示例来表达volatile的作用。
public class Program
{
public static int bookNum = 0;
public static void Main(string[] args)
{
Console.WriteLine("juster书的数量:" + bookNum);
Thread juster = new Thread(() =>
{
Console.WriteLine("juster没带书,等待家长送书到学校...");
while (bookNum == 0) {}
Console.WriteLine("juster拿到书,开始上课听讲。");
});
juster.Name = nameof(juster);
juster.Start();
Thread parent = new Thread(() =>
{
Console.WriteLine("parent在屋里找书中...");
Thread.Sleep(2000);
Console.WriteLine("parent找到了书之后,送往学校...");
SendBook();
});
parent.Name = nameof(parent);
parent.Start();
}
public static void SendBook()
{
bookNum = 1;
}
}
代码执行输出如下:
这时候诡异的来了,按照正常的代码执行逻辑不难看出当parent线程执行Sendbook()的时候juster应该就能拿到书上课了。但是这里juster却一直没有拿到是为什么呢?心细的小伙伴应该观察到了这里的运行模式是Release,众所周知Release是.Net的发布版本执行效率会比Debug版本要高。为什么Release版本效率高呢?怎么得来的?下面这段代码来解释:
上面这张反编译的图不难看出,10*10-100这段代码直接编译成0了。这种现象是因为Release编译的时候编译器会对代码进行‘优化’。这段是最直观能看到的‘优化’效果,其实C#编译器将你的代码转换成中间语言(IL)。然后,JIT将IL转换成本机CPU指令。此外,C#编译器、JIT编译器,甚至CPU本身都可能优化你的代码。
但是实际上在上述代码中count的值始终为0;所以循环永远不会执行,没有必要编译循环内的代码在编译后会被‘优化’。说了这么多,只是为了给大伙证明Release编译这一层会存在‘优化’;接下来继续回到volatile上。
说到这里,如何解决各种‘优化’带来的问题呢?这时候只需要在booknum前面加上volatile关键字修饰即可。
public class Program
{
public static volatile int bookNum = 0;
public static void Main(string[] args)
{
Console.WriteLine("juster书的数量:" + bookNum);
Thread juster = new Thread(() =>
{
Console.WriteLine("juster没带书,等待家长送书到学校...");
while (bookNum == 0) { }
Console.WriteLine("juster拿到书,开始上课听讲。");
});
juster.Name = nameof(juster);
juster.Start();
Thread parent = new Thread(() =>
{
Console.WriteLine("parent在屋里找书中...");
Thread.Sleep(2000);
Console.WriteLine("parent找到了书之后,送往学校...");
SendBook();
});
parent.Name = nameof(parent);
parent.Start();
}
public static void SendBook()
{
bookNum = 1;
}
}
在被各种优化之后,booknum因为是值类型在每个线程访问时会发生复制且又是在静态方法中被修改。所以每个线程都会复制booknum的值到当前线程上下文中缓存起来。这样就导致了parent线程修改了booknum的值juster线程看不到的情况。这个时候就需要用volatile关键字告诉编译器不需要这样的优化,表示用volatile定义的变量会被改变,每次都必须从内存中读取,而不能把他放在CPU cache或寄存器中重复使用。最后booknum会在运行的过程中修改值且其他线程能‘共享访问’达到最终的效果。
3.Conclusion
Part1
volatile
关键字可应用于以下类型的字段:
- 引用类型。
- 指针类型(在不安全的上下文中)。请注意,虽然指针本身可以是可变的,但是它指向的对象不能是可变的。换句话说,不能声明“指向可变对象的指针”。
- 简单类型,如
sbyte
、byte
、short
、ushort
、int
、uint
、char
、float
和bool
。 - 具有以下基本类型之一的
enum
类型:byte
、sbyte
、short
、ushort
、int
或uint
。 - 已知为引用类型的泛型类型参数。
- IntPtr 和 UIntPtr。
其他类型(包括 double
和 long
)无法标记为 volatile
,因为对这些类型的字段的读取和写入不能保证是原子的。若要保护对这些类型字段的多线程访问,请使用 Interlocked 类成员或使用 lock
语句保护访问权限。
volatile
关键字只能应用于 class
或 struct
的字段。不能将局部变量声明为 volatile
。
Part2
volatile并不能用来做线程同步,它的主要作用时为了让多个线程之间能看到被修改过后最新的值。
Part3
C#不支持以传递引用的方式将volatile字段传给方法。
int.TryParse("123", out x);
Part4
除了禁止编译优化,还有同步到内存中因为CPU每个核心都有自己Cache所以需要同步到内存中方便其他核心使用。
Part5
看完本文也能解开小白时期的疑惑,为什么我写代码编译成release版本之后就不能运行报错的奇特现象了。
Part6
volatile
牵扯到的相关知识点和原理远远不止这些。
4.Reference
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile?WT.mc_id=WDIT-MVP-5004326