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

23 浏览
0 Comments

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

能否有人提供一个关于C#中volatile关键字的好解释?它解决了哪些问题?哪些问题它不解决?在哪些情况下,它可以节省锁的使用?

admin 更改状态以发布 2023年5月21日
0
0 Comments

如果您想对volatile关键字做更加精细的技术探究,请考虑以下程序(我使用的是DevStudio 2005):

#include 
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 >::operator<< (402038h)] 
}
0040104B  xor         eax,eax 
0040104D  pop         ecx  
0040104E  ret              

从输出结果来看,编译器已经决定使用ecx寄存器来存储j变量的值。对于非volatile循环(第一个),编译器已经将i分配给eax寄存器。比较简单明了。但还有一些有趣的地方 - lea ebx,[ebx]指令实际上是一个多字节nop指令,使得循环跳转到16字节对齐的内存地址。另一个是使用edx来增加循环计数器,而不是使用inc eax指令。对于一些IA32内核来说,add reg,reg指令具有更低的延迟,但从不具有更高的延迟。

现在来看有volatile循环计数器的循环。计数器存储在[esp]处,volatile关键字告诉编译器该值应始终从/写入内存中,并且永远不应分配到寄存器中。编译器甚至没有像更新计数器值时那样进行三个不同步骤(读取eax,增加eax,保存eax),而是直接用单个指令修改内存(add mem,reg)。代码的编写方式确保循环计数器的值始终在单个CPU内核的上下文中处于最新状态。数据的任何操作都不会导致损坏或数据丢失(因此不使用load/inc/store,因为值可以在inc期间更改,从而在store时丢失)。由于中断只能在当前指令完成后服务,因此即使使用不对齐的内存,数据也永远不可能被破坏。

一旦引入第二个CPU到系统中,volatile关键字将不能防止数据同时被另一个CPU更新。在上面的例子中,如果要出现潜在的错误,数据需要被设置为不对齐。如果数据无法被原子地处理,例如,如果循环计数器是long long类型(64位),那么它需要两个32位的操作来更新值,在其中一个中断发生并更改数据时可能导致潜在的错误,volatile关键字将无法防止潜在的错误发生。

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

volatile关键字的创建是为了与IO操作一起使用,其中IO将不断变化,但具有恒定的地址,例如内存映射的UART设备,并且编译器不应该反复使用从该地址读取的第一个值。

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

0
0 Comments

我认为没有比Eric Lippert更适合回答这个问题了(原文中有强调):

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

实际上,最后一个部分是错误的。volatile读取和写入的真实语义比我在这里概述的要复杂得多;事实上,他们并没有保证每个处理器停止工作并更新缓存到/from主内存。相反,它们提供了更弱的保证,即关于在读取和写入之前和之后的内存访问如何被观察到相互排序的保证。特定的操作,如创建新线程、进入锁定或使用Interlocked方法系列之一,可以引入更强的关于排序观察的保证。如果你想了解更多细节,请阅读C# 4.0规范的第3.10节和第10.5.3节。

坦白地说,我不鼓励你使用volatile字段。volatile字段是你正在做一些非常疯狂的事情的信号:你正在试图在两个不同的线程上读写相同的值,而不放置锁定。锁定保证在锁内读取或修改的内存是一致的,锁定保证只有一个线程同时访问给定的内存块,等等。需要使用锁的情况非常少,而且由于你不理解精确的内存模型,所以你会出错的可能性非常大。我不试图编写任何低锁代码,除了最简单的Interlocked操作使用。我把“volatile”的使用留给真正的专家。

进一步阅读请参见:

0