volatile
关键字的好解释吗?它解决了哪些问题,又没有解决哪些问题?在哪些情况下使用它可以避免使用锁定?volatile
关键字的好解释吗?它解决了哪些问题,又没有解决哪些问题?在哪些情况下使用它可以避免使用锁定?我认为没有比Eric Lippert(原文重点)更适合回答这个问题的人了:
在C#中,“volatile”不仅意味着“确保编译器和jit不对此变量执行任何代码重新排序或寄存器缓存优化”,还意味着“告诉处理器尽其所能确保我正在读取最新值,即使这意味着停止其他处理器并使它们将主内存与其缓存同步”。实际上,最后一部分是错误的。易失性读写的真正语义比我在这里概述的要复杂得多;实际上,它们并没有确保每个处理器都停止正在进行的操作并更新缓存到/从主内存。相反,它们提供了关于在读写之前和之后的内存访问如何按顺序观察彼此的较弱保证。某些操作(例如创建新线程、进入锁定或使用Interlocked系列方法之一)引入了更强的有关排序观察的保证。如果您想了解更多细节,请阅读C# 4.0规范的3.10和10.5.3节。坦率地说,我不建议您制作易失性字段。易失性字段表明您正在做一些非常疯狂的事情:您正在尝试在两个不同的线程上读取和写入相同的值,而没有放置锁定。锁定保证在锁定内读取或修改的内存被观察为一致,锁定保证只有一个线程同时访问给定的内存块等等。需要使用锁定的情况非常少,而您由于不理解精确的内存模型而出错的概率非常大。除了最简单的Interlocked操作之外,我不尝试编写任何低锁定代码。我将“易失性”使用留给真正的专家。进一步阅读请参见:
volatile
保证的内存栅栏将因锁的存在而存在。 - 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内核上具有较低的延迟,但从不具有更高的延迟。
现在讲述有易失性的循环计数器。计数器存储在[esp]中,易失关键字告诉编译器该值应始终从内存中读取/写入,不应分配给寄存器。当更新计数器值时,编译器甚至不会执行加载/递增/存储这三个步骤(加载eax,递增eax,保存eax)。相反,内存直接在单个指令中进行修改(add mem,reg)。代码创建方式确保循环计数器的值始终在单个CPU核心的上下文中保持最新。数据的任何操作都不会导致破坏或数据丢失(因此不使用load/inc/store,因为值可能会在inc期间更改从而在存储时丢失)。由于中断只能在当前指令完成后进行服务,即使使用不对齐的内存,数据也永远不会被破坏。
一旦在系统中引入第二个CPU,易失关键字将无法防止另一个CPU同时更新数据。在上面的示例中,您需要使数据不对齐才能出现潜在破坏。如果数据不能被原子方式处理,例如循环计数器是long long(64位),则需要两个32位操作来更新值,在此期间可以发生中断并更改数据,易失关键字将无法防止潜在破坏。
因此,易失关键字仅适用于小于或等于本机寄存器大小的对齐数据,以便操作始终是原子的。
易失关键字的设计初衷是与IO操作一起使用,其中IO会不断变化但具有恒定的地址,例如内存映射的UART设备,编译器不应始终重用从地址读取的第一个值。
如果要处理大量数据或有多个CPU,则需要更高级别(操作系统)的锁定系统才能正确处理数据访问。
foo
赋值?难道线程1不是锁定了this.bar
,因此只有线程1能在某个时间点初始化foo吗?我的意思是,在释放锁之后再次检查值时,它应该已经具有来自线程1的新值。 - gilmishalfoo
赋值,而是会使用一个未完全初始化的foo
,即使它不是null。 - clctothis.foo = new Foo();
这一行时,只要该赋值在下一行指令之前完成,编译器就允许在构造函数结束之前执行字段赋值。对于单个线程来说,在字段赋值和构造函数完成之间存在这样一个窗口并不是问题,但如果第二个线程在此期间遇到了双重检查锁定,则可能会在第一个线程完成构造之前尝试使用该字段。 - Spencer Bench从MSDN: volatile修饰符通常用于被多个线程访问且没有使用锁语句来序列化访问的字段。使用volatile修饰符可以确保一个线程检索到另一个线程写入的最新值。
CLR 喜欢优化指令,所以当您在代码中访问字段时,它可能并不总是访问字段的当前值(可能来自堆栈等)。将字段标记为volatile
确保指令访问字段的当前值。当该值可以由程序中的并发线程或运行在操作系统中的其他代码修改(在非锁定场景下)时,这非常有用。
显然,您会失去一些优化,但它确实使代码更简单。
只需查看volatile关键字的官方页面,您就可以看到典型用法的示例。
public class Worker
{
public void DoWork()
{
bool work = false;
while (!_shouldStop)
{
work = !work; // simulate some work
}
Console.WriteLine("Worker thread: terminating gracefully.");
}
public void RequestStop()
{
_shouldStop = true;
}
private volatile bool _shouldStop;
}
using System;
using System.Threading;
class Test
{
public static int result;
public static volatile bool finished;
static void Thread2() {
result = 143;
finished = true;
}
static void Main() {
finished = false;
new Thread(new ThreadStart(Thread2)).Start();
for (;;) {
if (finished) {
Console.WriteLine("result = {0}", result);
return;
}
}
}
}
输出结果为:result = 143
如果字段finished未被声明为volatile,则存储器将可以在存储finished后向主线程可见,因此主线程可以从result字段读取值0。
volatile行为取决于平台,因此您应始终考虑在需要时使用volatile
,以确保它满足您的需求。
即使使用volatile
也不能防止(所有类型的)重排序(参见C#-C#内存模型的理论与实践第二部分)
尽管对A的写入是volatile的,从A_Won的读取也是volatile的,但是栅栏都是单向的,并且实际上允许这种重排序。
所以我认为,如果你想知道何时使用volatile
(与lock
和Interlocked
相比),你应该熟悉内存栅栏(全、半)和同步需求。然后你就可以自己得到宝贵的答案,以便更好地使用。
我发现Joydip Kanjilal的这篇文章非常有帮助:
当你将一个对象或变量标记为volatile时,它就成为了一个可进行volatile读写操作的候选对象。需要注意的是,在C#中,无论你是向一个volatile对象还是一个非volatile对象写入数据,所有的内存写操作都是volatile的。然而,当你读取非volatile数据时会出现歧义。当你读取非volatile数据时,执行线程可能会得到最新值,也可能不会。但如果对象是volatile的话,线程总是能获取到最新的值。
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);
}
});
private static volatile int _flag = 0;
只有在确实需要时,才应使用volatile,因为它会禁用某些编译器优化,从而影响性能。此外,它并不受所有.NET语言的支持(Visual Basic不支持),因此它会妨碍语言互操作性。