Thread.VolatileRead()与Volatile.Read()有何不同?

34

在大多数情况下,由于后者会发出完整的屏障(full-fence),而前者只会发出相关的半屏障(例如获取屏障),我们被告知应该更喜欢使用Volatile.Read而不是Thread.VolatileRead,因为前者更高效。

然而,在我的理解中,Thread.VolatileRead实际上提供了Volatile.Read所没有的一些东西,这是由于Thread.VolatileRead的实现方式:

public static int VolatileRead(ref int address) {
  int num = address;
  Thread.MemoryBarrier();
  return num;
}
由于实现的第二行存在完整的内存屏障,我认为VolatileRead确实确保读取到最后一次写入address的值。 根据维基百科的说法,"一个完整的屏障确保在屏障之前的所有加载和存储操作都已被提交,任何在屏障之后发出的加载和存储都必须在此之后执行。"
我的理解是正确的吗?因此,Thread.VolatileRead是否仍然提供了Volatile.Read没有的东西?
2个回答

40

虽然我可能有点迟到了,但我仍然想发表一些意见。首先,我们需要就某些基本定义达成共识。

  • 获取屏障(acquire-fence):在该内存屏障之前,其他读写操作不允许移动。
  • 释放屏障(release-fence):在该内存屏障之后,其他读写操作不允许移动。

我喜欢使用箭头符号来帮助说明内存屏障的作用。↑箭头表示释放屏障,↓箭头表示获取屏障。把箭头头部想像成将内存访问向箭头方向推送。但是,这很重要的一点是,内存访问可以越过箭头的尾巴。请阅读上面屏障的定义,并确信箭头在视觉上代表这些定义。

使用这种符号,让我们分析来自JaredPar答案的例子,从Volatile.Read开始。但是,首先让我指出,Console.WriteLine 可能会产生完整的栅栏屏障,但我们并不知道。为了使示例更容易跟踪,在这里我们假装它没有。实际上,我将完全忽略该调用,因为在我们试图实现的情况下,它是不必要的。

// Example using Volatile.Read
x = 13;
var local = y; // Volatile.Read
↓              // acquire-fence
z = 13;

使用箭头符号,我们更容易看出对z的写入不能向上移动并在y读取之前。同样,y的读取也不能向下移动并在z的写入之后进行,因为这实际上与另一种方式相同。换句话说,它锁定了yz的相对顺序。然而,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

// Example using Thread.VolatileRead
x = 13;
var local = y; // inside Thread.VolatileRead// Thread.MemoryBarrier / release-fence// Thread.MemoryBarrier / acquire-fence
z = 13;

仔细观察。在对x进行写操作和对y进行读操作之间没有箭头(因为没有内存屏障)。这意味着这些内存访问仍然可以相对于彼此自由移动。然而,调用Thread.MemoryBarrier将产生额外的释放栅栏,使得下一个内存访问似乎具有volatile写语义。这意味着对xz的写入无法再被交换。

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提供了一个完美的例子,说明在某些情况下它可以提供额外的功能。

@BrianGideon,我花了一些时间,但现在我明白你的意思了。我懂了:内存屏障和volatile语义等并不提供任何即时性或“刷新”……只有排序保证。是这样吗? - Xenoprimate
@Motig:没错,那是正确的!不过,就像我说的,你通常可以利用这些排序保证来获得所需的行为,尽管需要仔细思考。 - Brian Gideon
@dcarapic:文档与.NET中模型工作方式的规范不符。几个内存屏障生成器API调用的文档与规范之间存在断开。这已经被提出过了。要点是...要带着一定的保留看待文档。实际上,由于这些方法在紧密循环中使用的方式,它们将返回最新值。 - Brian Gideon
2
每当我认为我已经找到了最好的线程安全变量的方法,我就会发现新的问题 叹气。这多线程的东西真的很烦人... - Dalibor Čarapić
声明一个volatile变量是给我们提供了Thread.VolatileRead或Volatile.Read的语义吗? - William
显示剩余10条评论

17

Volatile.Read 的作用是确保在它之后发生的读写操作不能被移动到它之前。但是,它并没有防止在它之前发生的写操作超越它。例如:

// assume x, y and z are declared 
x = 13;
Console.WriteLine(Volatile.Read(ref y));
z = 13;

对于对x的写入操作,在读取y之前并没有任何保证。但是,对于y的读取操作之后,对z的写入一定会发生。

// assume x, y and z are declared 
x = 13;
Console.WriteLine(Thread.VolatileRead(ref y));
z = 13;

虽然在这种情况下,您可以确保此处的顺序为

  • 写入x
  • 读取y
  • 写入z

完整的围栏可以防止读写操作在任何方向上穿过它移动


这要视平台而定,在x86/x64上,由于存在强大的内存模型,Thread.VolatileRead和Volatile.Read具有相同的结果。 - Peter Ritchie
@JeredPar 这方面有参考资料吗?MSDN非常模糊,Volatile.Read是唯一提到屏障的方法,而且只含糊地说了“内存屏障”。Volatile.Read确实说了“如果在代码中使用此方法之后出现读取或写入,则处理器无法将其移动到此方法之前”——这就是该屏障的作用;但您必须知道这一点才能与“内存屏障”进行比较。我知道你是对的;但是最好引用来自Microsoft的东西(并且有更好的Microsoft文档),而不是来自Stackoverflow的东西:( - Peter Ritchie
@PeterRitchie 这里的文档非常明确,说明了Volatile.Read将设置的围栏类型。你是在寻找更多信息吗?http://msdn.microsoft.com/en-us/library/gg712828(v=vs.110).aspx - JaredPar
@JaredPar 除非你知道“在代码中的此方法之后出现读取或写入操作,处理器无法将其移动到此方法之前”的意思,否则它并不具体涉及栅栏。 - Peter Ritchie
3
我认为这不完全正确。在你的第二个例子中,写入 x 和从 Y 进行的 VolatileRead 之间没有内存屏障(实现表明读取发生在内存屏障之前),所以 x 和 y 仍然可能会被重新排序。也许我错了,这些都非常令人困惑。 - William
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接