C++中的volatile关键字是否引入了内存屏障?

90
我了解到,volatile 告诉编译器该值可能会被更改,但为了实现这个功能,编译器需要引入 memory fence 吗?
根据我的理解,对 volatile 对象的操作顺序不能被重新排序,必须保留。这似乎意味着一些 memory fences 是必要的,并且没有什么办法可以避免这种情况。我说得对吗?

这个相关问题有一个有趣的讨论(链接)

Jonathan Wakely写道:

......只要它们出现在不同的完整表达式中,编译器就不能重新排序对不同易失变量的访问......他说易失变量对于线程安全是无用的这一点是正确的,但原因不是他所提供的。这不是因为编译器可能会重新排序对易失对象的访问,而是因为CPU可能会重新排序它们。原子操作和内存屏障可以防止编译器和CPU重新排序

David Schwartz在评论中回复如下

从C++标准的角度来看,编译器执行某些操作与编译器发出导致硬件执行某些指令之间是没有区别的。如果CPU可以重新排序对易失变量的访问,则标准不要求保留它们的顺序。C++标准不对重新排序的具体实现作出任何区分。你不能认为CPU可以对它们进行重新排序而没有观察到影响,因此这是可以接受的——C++标准定义了它们的顺序是可观察的。如果标准要求对易失性不进行重排序,则在平台上生成使平台符合标准所需的代码的编译器符合C++标准。我想说的是,如果C++标准禁止编译器对不同易失性变量的访问进行重新排序,理由是这些访问的顺序是程序可观察行为的一部分,则它还要求编译器发出禁止CPU这样做的代码。标准不区分编译器的操作和编译器生成的代码使CPU执行的操作。
这就产生了两个问题:它们中的任何一个都是“正确”的吗?实际的实现会做什么?

10
这句话的意思大致是编译器不应该将那个变量保存在寄存器中。源代码中的每个赋值和读取操作都应该对应于二进制代码中的内存访问。 - Basile Starynkevitch
1
https://dev59.com/KGUq5IYBdhLWcg3wHcv5 - Brian Bi
1
我怀疑的重点是,如果值存储在内部寄存器中,任何内存屏障都将无效。我认为,在并发情况下仍需要采取其他保护措施。 - Galik
据我所知,volatile用于可以被硬件修改的变量(通常与微控制器一起使用)。它意味着无法以不同的顺序读取变量,并且无法进行优化。这适用于C语言,但在C++中应该是相同的。 - Mast
1
@Mast 我还没有看到过一个编译器能够防止CPU缓存优化掉volatile变量的读取。要么所有这些编译器都不符合标准,要么标准并不是你所认为的意思。(标准没有区分编译器做了什么和编译器让CPU做了什么。编译器的工作是生成代码,当运行时符合标准。) - David Schwartz
显示剩余13条评论
13个回答

61
与其解释volatile的作用,不如让我解释何时应该使用volatile
  • 当处于信号处理器内部时。因为从信号处理器内部写入volatile变量基本上是标准允许您在信号处理器内进行的唯一操作。自C++11以来,您可以使用std::atomic来实现这个目的,但仅当原子操作是无锁的时。
  • 当涉及到setjmp根据英特尔时。
  • 直接涉及硬件且想要确保编译器不会优化掉您的读取或写入时。
例如:
volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

没有 volatile 说明符,编译器允许完全优化循环。 volatile 说明符告诉编译器它不能假设两次连续的读取返回相同的值。
请注意,volatile 与线程无关。如果有不同的线程写入 *foo,则上述示例将无效,因为没有涉及获取操作。
在所有其他情况下,应该考虑使用 volatile 是不可移植的,并且除了处理 pre-C++11 编译器和编译器扩展(例如 msvc 的 /volatile:ms 开关,在 X86/I64 下默认启用)之外,不再通过代码审查。

5
比“不应假定两次连续读取返回相同的值”要严格。即使你只读一次和/或抛弃这些值,也必须进行读取。 - philipxy
1
标准所做的两个保证是在信号处理程序和 setjmp 中的使用。另一方面,至少在开始时,意图是支持内存映射IO。在某些处理器上可能需要栅栏或membar。 - James Kanze
@philipxy 除了没人知道“读取”是什么意思。例如,没有人相信必须从内存实际读取 - 我所知道的编译器都不尝试绕过CPU缓存来访问“volatile”。 - David Schwartz
1
@DavidSchwartz 一些编译器-架构对将标准指定的访问序列映射到实际效果和工作程序访问易失性以获得这些效果。一些这样的配对没有映射或者有一个微不足道的无用映射,这个事实与实现的质量有关,但与手头的问题无关。 - philipxy
MMIO寄存器怎么办?如果不使用volatile,我看它们无法正常工作。 - Kef Schecter
显示剩余9条评论

27
C++中的volatile关键字是否引入内存屏障? 符合规范的C++编译器不需要引入内存屏障。特定的编译器可能会这样做;请向您的编译器作者提出问题。在C++中,“volatile”的功能与线程无关。记住,“volatile”的目的是禁用编译器优化,以便从由外部条件改变的寄存器读取不会被优化掉。正在被不同CPU上的不同线程写入的内存地址是一个因外部条件而改变的寄存器吗?不是。如果一些编译器作者选择将不同CPU上的不同线程写入的内存地址视为因外部条件而更改的寄存器,那就是他们的事情;他们不必这样做。即使它确实引入了内存屏障,他们也不必确保每个线程都看到易失性读取和写入的一致顺序。 实际上,在C/C++中,volatile对于线程几乎没有用处。最好的做法是避免使用它。此外:内存屏障是特定处理器体系结构的实现细节。在C#中,volatile显式地设计用于多线程,规范没有说半个屏障将被引入,因为程序可能在根本没有屏障的架构上运行。相反,规范再次对编译器、运行时和CPU放弃某些(极其微弱的)优化做出了一定的(极其微弱的)约束,以便对某些副作用的排序方式进行限制。实践中,通过使用半屏障消除了这些优化,但这是一个可能在未来发生变化的实现细节。您关心任何语言中易失性的语义如何涉及多线程表明您正在考虑在线程之间共享内存。请考虑不这样做。它会使您的程序更难理解,并且很可能包含微妙且无法复现的错误。

21
“volatile在C/C++中几乎是无用的”并不完全正确!您的观点过于侧重用户模式桌面,但大多数C和C++代码运行在嵌入式系统上,在那里,对于内存映射I/O非常需要volatile。 - Ben Voigt
12
volatile 访问之所以被保留,并不仅仅是因为外部条件可能会改变内存位置。访问本身可能会触发进一步的操作。例如,读取操作通常会推进先进先出(FIFO)队列或清除中断标志。 - Ben Voigt
3
我理解您的原意是“对于有效处理线程问题无用”。 - Eric Lippert
4
标准显然无法保证内存映射IO的工作方式。但是,正是因为内存映射IO,才在C标准中引入了“volatile”。尽管如此,由于标准无法指定诸如“访问”时实际发生的事情之类的东西,因此它说,“对于具有易失性限定类型的对象构成访问的内容是由实现定义的。”今天有太多的实现没有提供有用的访问定义,即使符合字面上的标准,也违反了标准的精神,在我看来是这样的。 - James Kanze
8
虽然您的编辑确实有所改进,但是您的解释仍然过于关注“内存可能会受到外部更改”的问题。 volatile 语义比这个强,编译器必须生成每次请求的访问(1.9/8、1.9/12),而不仅仅是保证最终会检测到外部更改(1.10/27)。在内存映射 I/O 的世界中,内存读取可能具有任意相关逻辑,如属性获取器。 您不应根据您在 volatile 的规则中陈述的规则来优化对属性获取器的调用,标准也不允许这样做。 - Ben Voigt
显示剩余4条评论

13

David忽略了一个事实,即C++标准仅在特定情况下指定多个线程之间的交互行为,其他情况都会导致未定义的行为。如果您不使用原子变量,则涉及至少一个写入的竞争条件是未定义的。

因此,编译器完全有权放弃任何同步指令,因为只有在由于缺少同步而导致未定义行为的程序中,CPU才会注意到程序中的差异。


5
讲得很清楚,谢谢。标准只定义对 volatile 变量的访问顺序是可观察的,但前提是程序没有未定义行为。 - Jonathan Wakely
4
如果程序存在数据竞争,则标准对程序的可观察行为没有任何要求。编译器不应期望在易失性访问中添加屏障以防止程序中存在的数据竞争,这是程序员的工作,可以通过使用显式屏障或原子操作来完成。 - Jonathan Wakely
你为什么认为我忽略了那个?你认为我的论点哪一部分是无效的?我完全同意编译器放弃任何同步的做法是正确的。 - David Schwartz
2
这是完全错误的,或者至少忽略了本质。volatile与线程无关;它最初的目的是支持内存映射IO。而且至少在某些处理器上,支持内存映射IO需要栅栏。(编译器不会这样做,但这是另一个问题。) - James Kanze
@JamesKanze volatile 与线程有很大关系:volatile 处理可以在编译器不知道可以访问的内存,这涵盖了特定 CPU 上多个线程之间共享数据的许多实际用途。 - curiousguy

12
首先,C ++标准不保证非原子读/写的内存屏障顺序正确。建议在使用MMIO,信号处理等方面使用 volatile 变量。在大多数实现中,volatile 对于多线程并不有用,并且通常不建议使用。
关于 volatile 访问的实现,这是编译器的选择。
描述gcc行为的文章 显示,您不能使用易失性对象作为内存屏障以对易失性内存中的一系列写入进行排序。
关于icc行为,我发现这个来源也说明了volatile不能保证有序的内存访问。 Microsoft VS2013编译器具有不同的行为。这个文档解释了volatile如何强制执行Release / Acquire语义并使易失性对象可以在多线程应用程序上用于锁定/释放。
需要考虑的另一个方面是相同的编译器可能具有针对目标硬件架构的不同volatile行为。关于MSVS 2013编译器的这个帖子清楚地说明了使用易失性编译ARM平台的特定内容。
因此,我的回答是:
  

C ++易失关键字是否引入内存屏障?

不保证,可能不会,但某些编译器可能会这样做。您不应该依赖其这一事实。

2
它并不防止优化,只是防止编译器在超出某些限制的情况下改变加载和存储。 - Dietrich Epp
不清楚你的意思。你是在说在某些未指定的编译器上,volatile可以防止编译器重新排序加载/存储吗?还是说C++标准要求这样做?如果是后者,你能否回应我在原始问题中引用的相反论点? - David Schwartz
@DavidSchwartz 标准防止通过volatile左值重新排序访问(来自任何源)。然而,由于它将“访问”的定义留给实现,如果实现不关心,则这并没有为我们带来太多好处。 - James Kanze
我认为某些版本的MSC编译器确实对volatile实现了栅栏语义,但在Visual Studios 2012编译器生成的代码中没有栅栏。 - James Kanze
@JamesKanze 这基本上意味着 volatile 的唯一可移植行为是由标准明确列举的(如 setjmp,信号等)。 - David Schwartz
@DavidSchwartz 是的。但是,委员会从未打算让volatile具有任何可移植的语义。它被引入以支持内存映射IO,而内存映射IO并不可移植。 - James Kanze

7

据我所知,编译器仅在Itanium架构上插入内存屏障。

volatile关键字最好用于异步更改,例如信号处理程序和内存映射寄存器;通常不适用于多线程编程。


1
有点类似。当目标架构不是ARM且使用/volatile:ms开关(默认情况下)时,编译器(msvc)会插入内存屏障。请参见http://msdn.microsoft.com/en-us/library/12a04hfd.aspx。据我所知,其他编译器不会在易失变量上插入屏障。除非直接处理硬件、信号处理程序或非符合C++11的编译器,否则应避免使用易失变量。 - Stefan
@Stefan 不是的,volatile 对于许多不涉及硬件的用途非常有用。每当您希望实现生成遵循 C/C++ 代码的 CPU 代码时,请使用 volatile - curiousguy

7

这取决于“编译器”是哪个。自2005年以来,Visual C++可以使用该功能。但标准并不要求它,因此其他一些编译器可能不支持。


VC++ 2012似乎没有插入障栅:'int volatile i; int main() { return i; }'生成了一个仅有两条指令的主函数:'mov eax, i; ret 0;'。 - James Kanze
@JamesKanze:具体是哪个版本?您是否使用了任何非默认编译选项?我依赖于文档(第一个受影响的版本)和(最新版本),它们明确提到获取和释放语义。 - Ben Voigt
cl /help 显示版本为 18.00.21005.1。它所在的目录是 C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC。命令窗口上的标题显示为 VS 2013。因此关于版本...我使用的唯一选项是 /c /O2 /Fa。(如果没有 /O2,它还会设置本地堆栈帧。但仍然没有 fence 指令。) - James Kanze
@JamesKanze:我更感兴趣的是架构,例如“Microsoft (R) C/C++ 优化编译器版本18.00.30723 for x64”。也许之所以没有障碍是因为x86和x64在其内存模型中具有相当强的缓存一致性保证? - Ben Voigt
可能吧。我不是很确定。我在main函数中进行操作,因此编译器可以看到整个程序,并且知道在我的操作之前没有其他线程访问该变量(因此可能不存在缓存问题),这也可能会对结果产生影响,但我还是有所怀疑。 - James Kanze
显示剩余2条评论

5
它不必这样。Volatile不是同步原语,它只是禁用优化,即您在同一线程中按照抽象机器指定的顺序获得可预测的读写序列。但是,在不同线程中的读写没有顺序,讲述保留或不保留它们的顺序没有意义。线程之间的顺序可以通过同步原语建立,如果没有同步原语,将产生未定义行为。
关于内存屏障的解释:典型的CPU有几个级别的内存访问。有一个内存管道,几个级别的缓存,然后是RAM等。
Membar指令刷新了管道。它不会改变执行读写操作的顺序,只是强制在给定时刻执行未完成的操作。这对于多线程程序很有用,但在其他情况下并不常用。
缓存通常在多个CPU之间自动协调。如果要确保缓存与RAM同步,需要进行缓存刷新。这与membar非常不同。

1
所以你的意思是C++标准说volatile只是禁用编译器优化?这没有任何意义。编译器可以进行的任何优化,原则上都可以由CPU同样地完成。因此,如果标准说它只是禁用编译器优化,那就意味着它在可移植代码中提供不可靠的行为。但显然这不是真的,因为可移植代码可以依赖于它相对于setjmp和信号的行为。 - David Schwartz
1
@DavidSchwartz 不,标准并没有这样规定。禁用优化只是实现标准常用的方法。标准要求可观察行为按照抽象机器所需的顺序发生。当抽象机器不需要任何顺序时,实现可以自由地使用任何顺序或根本不使用顺序。除非应用了额外的同步,否则不同线程中对易失性变量的访问不会被排序。 - n. m.
1
@DavidSchwartz,我对措辞不够精确感到抱歉。标准并不要求禁用优化。它根本没有优化的概念。相反,它规定了一种行为,在实践中需要编译器以某种方式禁用某些优化,以使可观察的读写序列符合标准。 - n. m.
1
除非标准允许实现随意定义“可观察的读写序列”,否则不需要这样做。如果实现选择定义可观察序列的话,以使得优化必须被禁用,那么就会这样做。如果没有,那么就不会有这种情况。只有当实现选择了将其提供给您时,才能获得可预测的读写序列。 - David Schwartz
1
不,实现需要定义什么构成单个访问。这些访问的顺序由抽象机器规定。实现必须保持顺序。标准明确表示“volatile是提示实现避免涉及对象的激进优化”的一部分,尽管它不是规范的一部分,但意图很清楚。 - n. m.
显示剩余3条评论

5
这主要是基于记忆,以及在没有线程的情况下使用C++11之前。但是,在委员会讨论线程时,我可以说,委员会从未打算使用volatile来同步线程。微软提出了这个建议,但是该提案并未获得通过。 volatile的关键规范是访问volatile表示“可观察行为”,就像IO一样。同样,编译器不能重新排序或删除特定的IO,它也不能重新排序或删除对volatile对象的访问(或更正确地说,通过具有易失性限定类型的lvalue表达式的访问)。事实上,易失性最初的目的是支持映射到内存的IO。然而,“问题”在于,什么构成“易失性访问”是由实现定义的。许多编译器将其实现为“已执行读取或写入内存的指令”。如果实现指定,则这是合法但无用的定义。(我还没有找到任何编译器的实际规范。)
可以说(并且我接受这个论点),这违反了标准的意图,因为除非硬件将地址识别为映射到内存的IO,并禁止任何重新排序等操作,否则您甚至不能在Sparc或Intel体系结构上使用易失性进行内存映射IO。然而,我查看的所有编译器(Sun CC、g++和MSC)都没有输出任何fence或membar指令。(大约在微软提出扩展volatile规则的时候,我认为他们的一些编译器实现了他们的提案,并为易失性访问发出了fence指令。我没有验证最近的编译器做了什么,但如果它取决于某些编译器选项,这不会让我感到惊讶。我检查的版本(我认为是VS6.0)没有发出fences。)

为什么你只是说编译器不能重新排序或删除对易失对象的访问?如果这些访问是可观察的行为,那么防止CPU、写入缓冲区、内存控制器和其他所有东西重新排序它们同样重要。 - David Schwartz
@DavidSchwartz 因为这就是标准规定的。当然,从实际角度来看,我验证过的编译器所做的完全没有用处,但标准使用了一些模棱两可的措辞,以便他们仍然可以声称符合标准(或者如果他们实际上记录下来的话)。 - James Kanze
1
@DavidSchwartz:对于独占(或互斥)的内存映射 I/O 到外设,volatile 语义是完全足够的。通常这样的外设将其内存区域报告为不可缓存的,这有助于在硬件层面上进行重排序。 - Ben Voigt
@BenVoigt 我对此有些疑惑:处理器如何“知道”它正在处理的地址是内存映射IO。据我所知,Sparc没有任何支持这一点的功能,因此在Sparc上使用Sun CC和g++仍然无法用于内存映射IO。(当我调查此事时,我主要关注的是Sparc。) - James Kanze
@JamesKanze:从我所做的少量搜索来看,Sparc似乎有专用地址范围,用于非缓存的内存“备用视图”。只要您的易失性访问点进入地址空间的ASI_REAL_IO部分,我认为您应该没问题。(Altera NIOS使用类似的技术,高位地址控制MMU绕过;我相信还有其他类似的技术) - Ben Voigt
显示剩余5条评论

4
编译器需要在特定平台上,仅当需要使标准规定的volatile用法(例如setjmp、信号处理等)生效时,才在volatile访问周围引入内存屏障。
请注意,一些编译器为了使volatile在这些平台上更加强大或有用而超出C++标准要求的范畴。可移植的代码不应该依赖于volatile去执行C++标准未规定的任何操作。

在2017年,编译器不会在“volatile”周围创建内存栅栏。 - Konstantin Burlachenko

2
我总是在中断服务例程中使用volatile,例如ISR(通常是汇编代码)修改某个内存位置,而在中断上下文之外运行的高级别代码通过对volatile指针访问该内存位置。
我不仅在RAM中使用它,还在映射IO中使用它。
根据这里的讨论,似乎这仍然是volatile的有效用法,但与多线程或CPU无关。如果微控制器的编译器“知道”没有其他访问(例如每个东西都在芯片上,没有缓存,并且只有一个核心),我认为根本不需要内存屏障,编译器只需要防止某些优化。
随着我们将更多内容堆入执行对象代码的“系统”中,至少我是这样理解这个讨论的,所有赌注都关闭了。编译器如何才能涵盖所有基础呢?

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