任何人都可以在C#中对volatile关键字提供一个很好的解释吗?它解决了哪些问题,哪些问题没有解决?在哪些情况下它会节省我使用锁定?
我不认为有一个更好的人来回答这个问题,而不是Eric Lippert(强调原文):
在C#中,"volatile"不仅意味着"确保编译器和抖动不对此变量执行任何代码重新排序或寄存器缓存优化".它还意味着"告诉处理器做他们需要做的任何事情,以确保我正在读取最新的值,即使这意味着停止其他处理器并使它们与主存储器的缓存同步".
实际上,最后一点是谎言.易失性读写的真正语义比我在此概述的要复杂得多; 实际上,它们实际上并不能保证每个处理器都停止正在进行的操作并更新主存储器的缓存.相反,它们提供了关于如何观察读取和写入之前和之后的存储器访问相对于彼此进行排序的较弱保证.某些操作(如创建新线程,输入锁定或使用Interlocked系列方法)可以为观察排序提供更强有力的保证.如果您想了解更多详细信息,请阅读C#4.0规范的第3.10和10.5.3节.
坦率地说,我不鼓励你做一个不稳定的领域.易失性字段表明你正在做一些彻头彻尾的疯狂:你试图在两个不同的线程上读取和写入相同的值,而不是锁定到位.锁定保证锁内部读取或修改的内存一致,锁定保证一次只有一个线程访问给定的内存块,依此类推.锁定速度太慢的情况非常少,并且由于您不了解确切的内存模型而导致代码错误的可能性非常大.除了Interlocked操作最琐碎的用法之外,我不会尝试编写任何低锁代码.我将"挥发性"的用法留给了真正的专家.
进一步阅读请参阅:
了解低锁技术在多线程应用中的影响
Sayonara不稳定
如果您想了解volatile关键字的功能,请考虑以下程序(我正在使用DevStudio 2005):
#includevoid 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变量的值.对于非易失性循环(第一个),编译器已将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)锁定系统来正确处理数据访问.
如果您使用的是.NET 1.1,则在执行双重检查锁定时需要使用volatile关键字.为什么?因为在.NET 2.0之前,以下场景可能导致第二个线程访问非空但尚未完全构造的对象:
线程1询问变量是否为空.//if(this.foo == null)
线程1确定变量为null,因此输入一个锁.//lock(this.bar)
如果变量为null,则线程1询问AGAIN.//if(this.foo == null)
线程1仍然确定变量为null,因此它调用构造函数并将值赋给变量.//this.foo = new Foo();
在.NET 2.0之前,可以在构造函数完成运行之前为this.foo分配新的Foo实例.在这种情况下,可以进入第二个线程(在线程1调用Foo的构造函数期间)并体验以下内容:
线程2询问变量是否为空.//if(this.foo == null)
线程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
有时,编译器将优化字段并使用寄存器来存储它.如果线程1对字段进行写操作而另一个线程访问它,则由于更新存储在寄存器(而不是存储器)中,因此第二个线程将获得过时的数据.
您可以将volatile关键字视为编译器"我希望您将此值存储在内存中".这可以保证第二个线程检索最新值.
来自MSDN:volatile修饰符通常用于多个线程访问的字段,而不使用lock语句来序列化访问.使用volatile修饰符可确保一个线程检索另一个线程写入的最新值.
CLR喜欢优化指令,因此当您访问代码中的字段时,它可能无法始终访问字段的当前值(可能来自堆栈等).将字段标记为volatile
确保指令访问字段的当前值.当程序中的并发线程或操作系统中运行的某些其他代码可以修改(在非锁定方案中)时,这非常有用.
你显然失去了一些优化,但它确实使代码更简单.