it-swarm.cn

什么时候应该在C#中使用volatile关键字?

任何人都可以在C#中对volatile关键字提供一个很好的解释吗?它解决了哪些问题,哪些问题没有解决?在哪些情况下它会节省我使用锁定?

284
Doron Yaacoby

我不认为有更好的人回答这个问题而不是 Eric Lippert强调原文):

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

实际上,最后一点是谎言。易失性读写的真正语义比我在此概述的要复杂得多;事实上_(他们实际上并不保证每个处理器都会停止它正在做的事情并更新主存储器的缓存。而是它们提供了关于读写之前和之后内存访问方式的较弱保证)相对于彼此进行排序。某些操作,例如创建新线程,输入锁定或使用其中一个Interlocked方法,可以为观察排序提供更强的保证。如果您想了解更多细节,请阅读第3.10节和C#4.0规范的10.5.3。

坦率地说,我不鼓励你做一个易变的字段。易失性字段表明你正在做一些彻头彻尾的疯狂:你试图在两个不同的线程上读取和写入相同的值,而不是锁定到位锁定保证锁内部读取或修改的内存一致,锁定保证一次只能访问一个给定的内存块,依此类推。锁定速度太慢的情况非常多因为你不了解确切的内存模型,所以你要弄错代码的可能性非常大。除了Interlocked操作最琐碎的用法之外,我不会尝试编写任何低锁代码。我将“挥发性”的用法留给了真正的专家。

进一步阅读请参阅:

259
Ohad Schneider

如果您想了解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内核上具有更低的延迟,但从不具有更高的延迟。

现在循环使用volatile循环计数器。计数器存储在[esp]中,volatile关键字告诉编译器应始终从内存中读取/写入值,并且永远不会将其分配给寄存器。在更新计数器值时,编译器甚至不会执行加载/增量/存储作为三个不同的步骤(加载eax,inc eax,save eax),而是直接在单个指令中修改内存(添加内存) ,REG)。创建代码的方式可确保循环计数器的值始终在单个CPU内核的上下文中保持最新。对数据的操作不会导致损坏或数据丢失(因此不使用load/inc/store,因为值可能会在inc期间发生变化,从而在商店中丢失)。由于中断只能在当前指令完成后进行处理,因此即使存在未对齐的内存,数据也不会被破坏。

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

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

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

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

55
Skizz

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

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

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

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

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

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

38
AndrewTek

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

21
Dr. Bob

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

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

20
Benoit

CLR喜欢优化指令,因此当您访问代码中的字段时,它可能无法始终访问字段的当前值(可能来自堆栈等)。将字段标记为volatile可确保指令访问字段的当前值。当程序中的并发线程或操作系统中运行的某些其他代码可以修改(在非锁定方案中)时,这非常有用。

你显然失去了一些优化,但它确实使代码更简单。

13
Joseph Daigle

总而言之,这个问题的正确答案是:如果您的代码在2.0运行时或更高版本中运行,那么几乎不需要volatile关键字,如果不必要地使用,则弊大于利。 I.E.不要使用它。但是在运行时的早期版本中,它需要IS才能在静态字段上进行正确的双重检查锁定。特别是静态字段,其类具有静态类初始化代码。

1
Paul Easter

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

 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,你会期望没有输出或“值:10”作为结果。可能是编译器在t1函数内部切换行。如果t2然后执行,则可能是_flag的值为5,但_value为0.因此预期的逻辑可能被破坏。

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

private static volatile int _flag = 0;

你应该使用 volatile 只有你确实需要它,因为它会禁用某些编译器优化,这会损害性能。它也不受所有.NET语言的支持(Visual Basic不支持它),因此它阻碍了语言的互操作性。

0
Aliaksei Maniuk