何时在C#中使用volatile关键字?


299

谁能提供C#中volatile关键字的良好解释?它可以解决哪些问题,而哪些不能解决?在什么情况下可以节省锁定的使用时间?


6
您为什么要节省使用锁定功能?无竞争的锁会在程序中增加几纳秒的时间。你真的负担不起几纳秒吗?
埃里克·利珀特

Answers:


273

我认为没有比埃里克·利珀特Eric Lippert)更好的人了(原文强调):

在C#中,“ volatile”不仅意味着“确保编译器和抖动不会对此变量执行任何代码重新排序或寄存器缓存优化”。这也意味着“告诉处理器去做我需要做的所有事情,以确保我正在读取最新的值,即使这意味着停止其他处理器并使它们将主内存与其缓存同步”。

实际上,最后一点是谎言。易失性读写的真正语义比我在这里概述的要复杂得多。实际上,它们实际上并不能保证每个处理器都会停止正在执行的操作并向/从主内存更新缓存。而是,它们提供了关于在读写之前和之后内存访问如何被视为相对于彼此排序的较弱的保证。诸如创建新线程,输入锁或使用互锁方法系列之一之类的某些操作为观察顺序提供了更有力的保证。如果需要更多详细信息,请阅读C#4.0规范的3.10和10.5.3节。

坦白说,我不鼓励您从事不稳定的领域。易失性字段表明您正在疯狂地做某事:您试图在两个不同的线程上读取和写入相同的值,而没有进行锁定。锁可确保观察到在锁内读取或修改的内存是一致的,锁可确保一次只有一个线程访问给定的内存块,依此类推。锁太慢的情况数量很少,由于不了解确切的内存模型而导致代码错误的可能性非常大。除了互锁操作的最简单用法之外,我不会尝试编写任何低锁代码。我将“易失性”的用法留给真正的专家使用。

有关更多阅读,请参阅:


29
如果可以的话,我将对此投反对票。那里有很多有趣的信息,但是并不能真正回答他的问题。他正在询问volatile关键字的用法,因为它与锁定有关。相当长一段时间(2.0 RT之前),如果字段实例在构造函数中有任何初始化代码,则必须使用volatile关键字才能正确地使静态字段线程安全(请参阅AndrewTek的答案)。在生产环境中仍然有许多1.1 RT代码,维护它的开发人员应该知道为什么存在该关键字以及是否可以安全删除。
保罗·复活节

3
@PaulEaster 可以用于doulbe -checked锁定(通常以单例模式)的事实并不意味着它应该这样做。依靠.NET内存模型可能是一个坏习惯-您应该依靠ECMA模型。例如,您可能希望移植到单声道一天,而该日期可能具有不同的模型。我还了解不同的硬件架构可能会改变事情。有关更多信息,请访问:stackoverflow.com/a/7230679/67824。有关更好的单例替代方案(对于所有.NET版本),请参见:csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider

6
换句话说,对这个问题的正确答案是:如果您的代码在2.0运行时或更高版本中运行,则几乎不需要volatile关键字,如果不必要地使用volatile关键字,则弊大于利。但是在运行时的早期版本中,需要对静态字段进行适当的双重检查锁定。
保罗·复活节

3
这是否意味着锁和volatile变量在以下意义上是互斥的:如果我在某些变量周围使用了锁,则不再需要将该变量声明为volatile了吗?
giorgim

4
@ Giorgi是的- volatile通过锁定可以保证存在的内存障碍
Ohad Schneider

54

如果您想稍微了解volatile关键字的功能,请考虑以下程序(我使用的是DevStudio 2005):

#include <iostream>
void main()
{
  int j = 0;
  for (int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  std::cout << j;
}

使用标准的优化(发布)编译器设置,编译器将创建以下汇编器(IA32):

void main()
{
00401000  push        ecx  
  int j = 0;
00401001  xor         ecx,ecx 
  for (int i = 0 ; i < 100 ; ++i)
00401003  xor         eax,eax 
00401005  mov         edx,1 
0040100A  lea         ebx,[ebx] 
  {
    j += i;
00401010  add         ecx,eax 
00401012  add         eax,edx 
00401014  cmp         eax,64h 
00401017  jl          main+10h (401010h) 
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
00401019  mov         dword ptr [esp],0 
00401020  mov         eax,dword ptr [esp] 
00401023  cmp         eax,64h 
00401026  jge         main+3Eh (40103Eh) 
00401028  jmp         main+30h (401030h) 
0040102A  lea         ebx,[ebx] 
  {
    j += i;
00401030  add         ecx,dword ptr [esp] 
00401033  add         dword ptr [esp],edx 
00401036  mov         eax,dword ptr [esp] 
00401039  cmp         eax,64h 
0040103C  jl          main+30h (401030h) 
  }
  std::cout << j;
0040103E  push        ecx  
0040103F  mov         ecx,dword ptr [__imp_std::cout (40203Ch)] 
00401045  call        dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
}
0040104B  xor         eax,eax 
0040104D  pop         ecx  
0040104E  ret              

查看输出,编译器已决定使用ecx寄存器存储j变量的值。对于非易失性循环(第一个),编译器已将i分配给eax寄存器。非常坦率的。但是,有几个有趣的位-lea ebx,[ebx]指令实际上是多字节nop指令,因此循环跳到16字节对齐的内存地址。另一个是使用edx来增加循环计数器,而不是使用inc eax指令。与inc reg指令相比,add reg,reg指令在几个IA32内核上具有较低的延迟,但从来没有更高的延迟。

现在使用易失性循环计数器进行循环。计数器存储在[esp],而volatile关键字告诉编译器应始终从存储器中读取/写入该值,并且永远不要将其分配给寄存器。甚至在更新计数器值时,编译器甚至不做为三个不同的步骤(加载eax,inc eax,保存eax)进行加载/增量/存储,而是直接在一条指令中修改内存(添加内存) ,reg)。创建代码的方式可确保在单个CPU内核的上下文中循环计数器的值始终是最新的。对数据的任何操作都不会导致损坏或数据丢失(因此,不使用加载/增量/存储,因为该值可以在增量期间更改,从而在存储中丢失)。由于只有当当前指令完成后才能处理中断,

在系统中引入第二个CPU后,volatile关键字将无法防止其他CPU同时更新数据。在上面的示例中,您将需要对数据进行对齐以获取潜在的损坏。如果无法以原子方式处理数据,那么volatile关键字将无法防止潜在的损坏,例如,如果循环计数器的类型为long long(64位),则在中间位置需要两个32位操作来更新值。可能会发生中断并更改数据。

因此,volatile关键字仅适用于小于或等于本机寄存器大小的对齐数据,因此操作始终是原子的。

volatile关键字被认为与IO操作一起使用,在IO操作中,IO会不断变化,但地址不变,例如内存映射的UART设备,并且编译器不应继续重复使用从该地址读取的第一个值。

如果要处理大数据或具有多个CPU,则需要更高级别(OS)的锁定系统来正确处理数据访问。


这是C ++,但原理适用于C#。
Skizz

6
Eric Lippert写道,C ++中的volatile仅阻止编译器执行某些优化,而C#中的volatile另外在其他内核/处理器之间进行一些通信,以确保读取最新值。
Peter Huber

44

如果使用的是.NET 1.1,则在进行双重检查锁定时需要volatile关键字。为什么?因为在.NET 2.0之前,以下情况可能导致第二个线程访问非空但尚未完全构造的对象:

  1. 线程1询问变量是否为空。//if(this.foo == null)
  2. 线程1确定变量为null,因此输入一个锁。//锁定(this.bar)
  3. 线程1再次询问变量是否为null。//if(this.foo == null)
  4. 线程1仍然确定该变量为null,因此它调用构造函数并将该值分配给该变量。//this.foo = new Foo();

在.NET 2.0之前,可以在构造函数完成运行之前为this.foo分配新的Foo实例。在这种情况下,第二个线程可能会进入(在线程1对Foo的构造函数的调用期间),并且会遇到以下情况:

  1. 线程2询问变量是否为null。//if(this.foo == null)
  2. 线程2确定该变量不为null,因此尝试使用它。//this.foo.MakeFoo()

在.NET 2.0之前,您可以将this.foo声明为volatile,以解决此问题。从.NET 2.0开始,您不再需要使用volatile关键字来完成双重检查锁定。

Wikipedia实际上有一篇关于Double Checked Locking的好文章,并简要介绍了该主题:http : //en.wikipedia.org/wiki/Double-checked_locking


2
这正是我在旧版代码中看到的,并且对此感到疑惑。这就是为什么我开始更深入的研究。谢谢!
彼得·波菲

1
我不明白线程2将如何分配值foo?线程1不会锁定this.bar,因此只有线程1能够在给定的时间点初始化foo吗?我的意思是,您确实要在再次释放锁定后检查值,无论如何它应该具有线程1的新值
。– gilmishal

24

有时,编译器会优化字段并使用寄存器来存储它。如果线程1对字段进行写操作,而另一个线程访问该字段,则由于更新存储在寄存器(而不是内存)中,因此第二个线程将获得陈旧数据。

您可以认为volatile关键字是对编译器说的:“我希望您将此值存储在内存中”。这样可以保证第二个线程检索最新值。


21

来自MSDN:volatile修饰符通常用于多个线程访问的字段,而无需使用lock语句来序列化访问。使用volatile修饰符可确保一个线程检索另一线程写入的最新值。


13

CLR喜欢优化指令,因此,当您访问代码中的字段时,它可能并不总是访问该字段的当前值(它可能来自堆栈等)。将字段标记为volatile可以确保指令访问该字段的当前值。当可以通过程序中的并发线程或操作系统中运行的某些其他代码修改值(在非锁定情况下)时,此功能很有用。

您显然会失去一些优化,但这确实使代码更简单。


3

我发现Joydip Kanjilal的这篇文章很有帮助!

When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

我会留在这里供参考


0

编译器有时会更改代码中语句的顺序以对其进行优化。通常,在单线程环境中这不是问题,但在多线程环境中则可能是问题。请参见以下示例:

 private static int _flag = 0;
 private static int _value = 0;

 var t1 = Task.Run(() =>
 {
     _value = 10; /* compiler could switch these lines */
     _flag = 5;
 });

 var t2 = Task.Run(() =>
 {
     if (_flag == 5)
     {
         Console.WriteLine("Value: {0}", _value);
     }
 });

如果运行t1和t2,则不会输出任何结果或出现“ Value:10”。可能是编译器在t1函数内部切换了行。如果然后执行t2,则可能是_flag的值为5,但_value的值为0。因此预期逻辑可能会被破坏。

要解决此问题,您可以使用可应用于字段的volatile关键字。该语句禁用编译器优化,因此您可以在代码中强制使用正确的顺序。

private static volatile int _flag = 0;

仅在真正需要时才应使用volatile,因为它会禁用某些编译器优化,这会损害性能。并非所有.NET语言都支持它(Visual Basic不支持它),因此它阻碍了语言的互操作性。


2
您的例子确实很糟糕。基于首先编写t1的代码的事实,程序员在t2任务中永远不要期望_flag的值。首先写!=先执行。编译器是否在t1中切换这两行并不重要。即使编译器没有切换这些语句,else分支中的Console.WriteLne仍然可以执行,即使使用_flag上的volatile关键字也是如此。
Jakotheshadows '16

@jakotheshadows,您是对的,我已经编辑了答案。我的主要思想是证明当我们同时运行t1和t2时,预期的逻辑可能会被破坏
Aliaksei Maniuk

0

因此,总而言之,问题的正确答案是:如果您的代码在2.0运行时或更高版本中运行,则几乎不需要volatile关键字,如果不必要地使用volatile关键字,则弊大于利。IE永远不要使用它。但是在运行时的早期版本中,需要对静态字段进行适当的双重检查锁定。特别是其类具有静态类初始化代码的静态字段。


-4

多个线程可以访问一个变量。最新更新将在变量上

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.