这个说得有点复杂。我会从你的一些问题开始,并更新我的回答。
循环提升易失性变量
我在多个地方读过,循环中或if中无法提升易失性变量,但我在C#规范中找不到任何提到这一点的地方。这是一个隐藏的特性吗?
MSDN说:“声明为volatile的字段不受编译器通过单个线程访问的优化的影响”。这是一种比较宽泛的陈述,但它包括将变量从循环中提升或“提升”的情况。
C#中所有写操作都是易失的
这是否意味着所有写操作都具有与使用易失关键字相同的属性?例如,C#中的普通写操作具有释放语义吗?所有写操作都刷新处理器的存储缓冲区吗?
普通的写操作不是易失的。它们具有释放语义,但它们不会刷新CPU的写缓冲区。至少根据规范是这样的。
来自Joe Duffy的CLR 2.0内存模型
规则2:所有存储操作具有释放语义,即一个存储操作后不得移动任何加载或存储操作。
我读过几篇文章声称C#中的所有写操作都是易失的(就像你链接的那个),但这是一种常见的误解。按照官方说法(C#内存模型的理论和实践,第2部分):
因此,作者可能会说:“在.NET 2.0内存模型中,所有写操作都是易失的——即使是对非易失性字段的写入。”(...)ECMA C#规范不能保证这种行为,并且因此可能不适用于未来版本的.NET Framework和未来体系结构(事实上,这在 .NET Framework 4.5 on ARM 中并不适用)。
释放语义
这是否是正式表达处理器的存储缓冲区在进行易失性写入时被清空?
不,这是两个不同的概念。如果指令具有“释放语义”,那么任何存储/加载指令都不会在该指令之下移动。该定义与清空写缓冲区无关,它只涉及指令重新排序。
延迟写入
我在多个地方读到过写入可以被延迟。这是因为重排序和存储缓冲区吗?
是的。写指令可能会被编译器、Jitter或CPU本身延迟/重新排序。
因此,volatile写操作具有两个属性:释放语义和存储缓冲区刷新。
有点像。我更喜欢这样思考:
C#规范保证了volatile关键字的一个属性:读取具有获取语义,写入具有释放语义。通过发出必要的释放/获取栅栏来实现这一点。
而Microsoft的C#实现则增加了另一个属性:读取将是最新的,并且写入将立即刷新到内存并对其他处理器可见。为此,编译器会发出一个OpCodes.Volatile
,而Jitter则会接收这个信息并告诉处理器不要将此变量存储在其寄存器中。
这意味着不保证立即性的另一个C#实现将是完全有效的实现。
内存屏障
bool complete = false;
var t = new Thread (() =>
{
bool toggle = false;
while (!complete) toggle = !toggle;
});
t.Start();
Thread.Sleep(1000);
complete = true;
t.Join();
但是否总是这样呢?调用 Memory.Barrier() 方法会不会始终将存储缓冲区刷新,并获取更新后的值到处理器缓存中呢?
这里有一个提示:试着将自己抽象出来,不要纠结于诸如刷新存储缓冲区或者直接从内存中读取的概念。Memory Barrier(或全栅栏)的概念与前面两个概念完全没有关系。
Memory Barrier 的唯一目的就是确保栅栏下方的存储/加载指令不会被移动到栅栏上方,反之亦然。如果 C# 的 Thread.MemoryBarrier() 恰好刷新了未完成的写操作,那么您应该将其视为一个副作用,而不是主要意图。
现在,让我们来重点讨论。您发布的代码(在 Release 模式下编译并且没有调试器的情况下运行时会阻塞)可以通过在 while 块内的任何位置引入全栅栏来解决。为什么?首先,让我们将循环展开。以下是前几次迭代的样子:
if(complete) return;
toggle = !toggle;
if(complete) return;
toggle = !toggle;
if(complete) return;
toggle = !toggle;
...
由于complete
没有被标记为volatile
,也没有使用任何屏障,编译器和CPU允许移动对complete
字段的读取操作。实际上,CLR的内存模型(参见规则6)允许在合并相邻的加载时删除!所以,可能会出现这种情况:
if(complete) return;
toggle = !toggle;
toggle = !toggle;
toggle = !toggle;
...
请注意,这与将读取操作提升出循环在逻辑上是等价的,编译器可能会这样做。
通过在toggle =! toggle
之前或之后引入一个完整的栅栏,您可以防止编译器将读取操作向上移动并将它们合并在一起。
if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
...
总之,解决这些问题的关键是确保指令按照正确的顺序执行。这与其他处理器何时看到一个处理器的写入操作无关。