为什么System.Double和System.Long不使用volatile?

47
像我这样的问题已经被提出了,但我的问题有点不同。问题是,“为什么在C#中不允许在System.DoubleSystem.Int64等类型上使用volatile关键字?”
乍一看,我回答我的同事,“嗯,在32位机器上,那些类型至少需要两个滴答声才能进入处理器,并且.Net框架有意抽象掉处理器特定的细节。” 他回答道,“如果因为处理器特定问题而阻止你使用一个功能,那么它就没有抽象任何东西!”
他暗示使用框架的人不应该看到处理器特定的细节,框架(或C#)应该将它们抽象掉并执行需要完成System.Double等的相同保证的操作(无论是Semaphore、内存屏障还是其他)。 我争辩说,框架不应该在volatile上添加Semaphore的开销,因为程序员不期望使用这种关键字会带来这种开销,因为32位类型不需要Semaphore。 64位类型的更大开销可能会令人惊讶,因此,如果开销可接受,则最好由.Net框架不允许它,并且如果使用更大的类型,则需要自己实现Semaphore。
这导致我们调查volatile关键字的作用是什么。(请参见页面)。 该页面在注释中声明:

在C#中,在字段上使用volatile修饰符保证对该字段的所有访问都使用VolatileRead或VolatileWrite。

嗯......VolatileReadVolatileWrite都支持我们的64位类型!! 那么,我的问题是,

为什么在 C# 中,System.DoubleSystem.Int64 类型等不允许使用 volatile 关键字呢?


3
请注意,微软已经更正了该页面,并且不再说“在C#中,在字段上使用volatile修饰符可以确保对该字段的所有访问都使用VolatileRead或VolatileWrite。” - Zach Saw
1
正好相反。通过施加这个限制,他们可以保证在任何处理器上都能产生不稳定的行为。 - Hans Passant
5个回答

18
他的意思是,特定于处理器的细节不应该出现在使用框架把这样的细节从程序员身上抽象出来的人面前。
如果你正在使用像volatile字段、显示内存屏障等低锁技术,那么你完全处于特定于处理器的细节世界中。为了编写正确、可移植、健壮的使用低锁技术的程序,你需要深入了解处理器在重排序、一致性等方面所能和不能做到的知识。
此功能的要点是:“我放弃单线程编程保证的便利抽象,拥抱通过对我的处理器深入的实现特异性知识可能获得的性能提升。”当你开始使用低锁技术时,你应该期望你拥有的抽象更少而不是更多。
你之所以去“接触硬件”是有原因的;你要付出的代价是必须处理说起来有些怪异的金属问题。

1
请问为什么 volatile 不允许用于 longdouble?理论上可能可以做一些技巧使它们变成 volatile - Andrey
6
@Andrey: 当然,CLR允许我们对double类型的字段进行volatile、非原子访问(我想是这样,没有尝试过)。但C#设计者决定不允许这样做;在C#中,所有volatile访问也都是原子性的。如果你需要对包含大型结构体的变量进行volatile但非原子访问,那么C#可能不是最好的语言选择。如果你想要在C#中使用,你可以始终使用VolatileRead和VolatileWrite;请注意,实际上这样做会导致完整的内存屏障,虽然这不是API的保证,并且未来可能会更改。 - Eric Lippert
感谢您的评论。您提到:“...拥有对处理器深入的实现特定知识。”在说“编写...可移植...程序...”之后。那么,我们是对“我的”处理器感兴趣,还是对可移植性(大致所有处理器或防御性编程)感兴趣?我当然同意,越接近底层(多线程是主题),就越需要“实现”。不过,我认为,关键字volatile可以提供一种与实现无关的解决方案,类似于“此关键字包装了VolatileReadVolatileWrite”。您觉得呢? - lmat - Reinstate Monica
1
我同意,具有易失性的非原子读/写确实令人困惑。但在Java中,long是易失性和原子性的。在32位系统上,通过使用允许从内存进行原子加载的XMM寄存器来实现,并且其大小为128。我认为CLR应该实现类似的东西。 - Andrey
正如Andrey所提到的,Java支持“volatile long和double值的写入和读取始终是原子性的。无论引用是实现为32位还是64位值,对引用的写入和读取始终是原子性的。” https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7 没有什么神奇的。 - andresp
显示剩余2条评论

14

是的。原因在于你甚至无法一次操作中读取doublelong。我认为这是一个很差的抽象。我有一种感觉,那就是读取它们需要花费一些精力进行原子操作,而这对编译器来说可能会过于“聪明”。所以他们让你选择最佳解决方案:lockInterlocked等。

有趣的事情是,在32位系统上,它们实际上可以使用MMX寄存器进行原子读取。这就是Java JIT编译器的做法。并且在64位机器上也可以进行原子读取。所以我认为这是设计上的严重缺陷。


3
关键在于它们是否可以在CLI的任何实现中被原子地读取/写入。 - H H
我有一种感觉,原因是原子地阅读它们需要努力,这对编译器来说太聪明了。但是它并不比其他类型更聪明。无论如何,只需对System.Double和System.Int32使用System.Threading.Thread.VolatileRead即可。就像你对其他变量所做的一样,并不需要更多的“智慧”或其他任何东西。 - lmat - Reinstate Monica
3
所有CLI的实现都有System.Threading.Thread.VolatileRead,对吗?或者说我没有理解你的意思。 - lmat - Reinstate Monica
@有限救赎 在简单的语句上调用VolatileRead,这就是我所谓的太聪明了。你可以对此进行争论,但这是编译器团队的决定。我希望Eric Lipper能够解释一下。 - Andrey
2
@Henk Holterman,这实际上是一个相当薄弱的点。Java允许在longdouble上使用volatile,并且它比CLI有更多的实现。 - Andrey

6

虽然这并不是对你问题的回答,但是...

我相信你引用的MSDN文档在声明“在字段上使用volatile修饰符可以保证对该字段的所有访问都使用VolatileRead或VolatileWrite”时是错误的。

直接读取或写入volatile字段只会生成半屏障(读取时是获取屏障;写入时是释放屏障)。

VolatileReadVolatileWrite方法在内部使用MemoryBarrier,它会生成完整的屏障。

Joe Duffy对并发编程有所了解;他对volatile的看法如下:

(顺便说一句,很多人想知道标记为volatile的变量的加载和存储与调用Thread.VolatileRead和Thread.VolatileWrite之间的区别。区别在于前者API比JIT编译的代码更强:它们通过在正确位置生成完整屏障来实现获取/释放语义。调用这些API的成本也更高,但至少可以让你根据调用点逐个决定哪些单个加载和存储需要MM保证。)


1
@Zach:但这不仅仅是因为x86内存模型本身保证了读取具有获取语义,写入具有释放语义吗?也许我应该重新表述一下,“直接读取或写入易失性字段只会生成半栅栏(在读取时是获取栅栏,在写入时是释放栅栏),仅当这样做是为了强制执行当前硬件上的CLR内存模型时。”这有任何意义,还是我错过了重点? - LukeH
1
是的,它只在需要时生成它们 - 否则你就意味着无论何时都需要在易失性读/写操作上使用半屏障(这也可以在x86/x64上完成,但实际上并没有这样做)。 - Zach Saw
实际上,x86 / x64 保证存储是有序的,因此它并不完全具有隐式获取/释放语义。 - Zach Saw
@Zach: 是的,我认为 Joe Duffy 的文章明确指出,就 x86 Jitter 而言,写操作的 volatile 方面只是一个nop(非操作指令)。(因为他的网站暂时无法访问,所以我无法确认。) - LukeH
我不知道Joe Duffy是谁,但我在英特尔时曾经参与过Northwood / Prescott CPU的工作。我看到了一篇微软博客,关于估算x86 CPU的顺序写入。我可以百分之百确定它们是设计的一部分,并且经过了广泛的测试。否则就会有CPU召回(对于x86 / x64)。我不知道也不关心IA64(我认为英特尔也不太关心)。 - Zach Saw
显示剩余6条评论

4
这是关于遗留系统的简单解释。如果您阅读这篇文章 - http://msdn.microsoft.com/en-au/magazine/cc163715.aspx,您会发现.NET Framework 1.x运行时的唯一实现是在x86机器上,因此微软针对x86内存模型进行了实现。x64和IA64是后来添加的。因此,基础内存模型始终是x86之一。
它是否可以针对x86进行实现?我其实不确定它是否可以完全实现 - 从本地代码返回的double类型的引用可能会对齐到4个字节而不是8个字节。在这种情况下,所有原子读/写的保证都不再成立。

4

从.NET Framework 4.5开始,现在可以使用Volatile.ReadVolatile.Write方法在longdouble变量上执行volatile读取或写入操作。尽管没有记录,但这些方法对long/double变量执行原子性读取和写入,这从它们的实现中明显可见:

private struct VolatileIntPtr { public volatile IntPtr Value; }

[Intrinsic]
[NonVersionable]
public static long Read(ref long location) =>
#if TARGET_64BIT
    (long)Unsafe.As<long, VolatileIntPtr>(ref location).Value;
#else
    // On 32-bit machines, we use Interlocked,
    // since an ordinary volatile read would not be atomic.
    Interlocked.CompareExchange(ref location, 0, 0);
#endif

使用这两个方法并不像volatile关键字那样方便。需要注意的是,不要忘记用Volatile.ReadVolatile.Write包装每次对volatile字段的读/写访问。

注意:关于Intrinsic属性,你可以查看我的回答以获取相关问题的信息。这可能会引起关注。


关于Intrinsic属性,请查看这个相关问题的答案。它可能会引起关注。 - Theodor Zoulias

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