虽然我可能有点迟到了,但我仍然想发表一些意见。首先,我们需要就某些基本定义达成共识。
- 获取屏障(acquire-fence):在该内存屏障之前,其他读写操作不允许移动。
- 释放屏障(release-fence):在该内存屏障之后,其他读写操作不允许移动。
我喜欢使用箭头符号来帮助说明内存屏障的作用。↑箭头表示释放屏障,↓箭头表示获取屏障。把箭头头部想像成将内存访问向箭头方向推送。但是,这很重要的一点是,内存访问可以越过箭头的尾巴。请阅读上面屏障的定义,并确信箭头在视觉上代表这些定义。
使用这种符号,让我们分析来自JaredPar答案的例子,从Volatile.Read
开始。但是,首先让我指出,Console.WriteLine
可能会产生完整的栅栏屏障,但我们并不知道。为了使示例更容易跟踪,在这里我们假装它没有。实际上,我将完全忽略该调用,因为在我们试图实现的情况下,它是不必要的。
// Example using Volatile.Read
x = 13
var local = y
↓ // acquire-fence
z = 13
使用箭头符号,我们更容易看出对z
的写入不能向上移动并在y
读取之前。同样,y
的读取也不能向下移动并在z
的写入之后进行,因为这实际上与另一种方式相同。换句话说,它锁定了y
和z
的相对顺序。然而,y
的读取和x
的写入可以交换,因为没有箭头头阻止该移动。同样,x
的写入可以越过箭头的尾部,甚至越过z
的写入。规范理论上允许这样做。这意味着我们有以下有效的排序。
Volatile.Read
write x | read y | read y
read y | write x | write z
write z | write z | write x
现在让我们继续使用 Thread.VolatileRead
的示例。为了方便理解,我将内联调用 Thread.VolatileRead
。
x = 13;
var local = y;
↑
↓
z = 13;
仔细观察。在对x
进行写操作和对y
进行读操作之间没有箭头(因为没有内存屏障)。这意味着这些内存访问仍然可以相对于彼此自由移动。然而,调用Thread.MemoryBarrier
将产生额外的释放栅栏,使得下一个内存访问似乎具有volatile写语义。这意味着对x
和z
的写入无法再被交换。
Thread.VolatileRead
-----------------------
write x | read y
read y | write x
write z | write z
当然,有人声称Microsoft对CLI(.NET Framework)的实现和x86硬件已经保证了所有写入的release-fence语义。因此,在这种情况下,这两个调用之间可能没有任何区别。但是在使用Mono的ARM处理器上会有所不同。
现在让我们继续回答您的问题。
“由于实现中第二行的完整内存屏障,我认为VolatileRead确实确保将读取地址的最后一个写入值。我的理解正确吗?”
不对。Volatile Read不同于“新鲜读取”。为什么呢?因为内存屏障放置在读取指令之后。这意味着实际读取仍然可以向前或向后移动。其他线程可以写入该地址,但当前线程可能已将读取移动到比另一个线程提交之前更早的时间点。
那么问题就是,“如果volatile读似乎保证如此少,为什么人们还要使用它?”答案是它绝对保证下一次读取将比上一次读取更新。这就是它的价值所在!这就是为什么许多无锁代码会在循环中旋转,直到逻辑能够确定操作已成功完成的原因。换句话说,无锁代码利用了许多读取序列中的后续读取将返回更新的值的概念,但代码不应假定任何读取都必然代表最新值。
请思考一下。什么是“最新”的值?在您使用该值的时候,它可能不再是最新的。其他线程可能已经将不同的值写入相同的地址。您仍然可以称该值为最新吗?
但是,如果您仍然希望像“新鲜”读取一样工作,则需要在读取之前放置一个获取屏障。请注意,这显然不同于volatile读取,但它更符合开发人员对“新鲜”含义的直觉。但是,在这种情况下,“新鲜”的术语不是绝对的。相反,相对于屏障,读取是“新鲜”的。也就是说,它不能比执行屏障的时间点更旧。但是,正如上面提到的那样,到您使用或基于它做出决策的时间,该值可能不代表最新值。请记住这一点。
因此,“Thread.VolatileRead是否仍然提供了Volatile.Read没有的功能?”是的。我认为JaredPar提供了一个完美的例子,说明在某些情况下它可以提供额外的功能。