"volatile"限定符和编译器重排序

18

编译器无法消除或重新排列对 volatile 修饰变量的读/写操作。

但是,如果存在其他变量,这些变量可能是 volatile 修饰的,也可能不是,那么情况如何呢?

情景1

volatile int a;
volatile int b;

a = 1;
b = 2;
a = 3;
b = 4;

编译器是否可以重新排列第一和第二,或第三和第四个赋值操作?

情景2

volatile int a;
int b, c;

b = 1;
a = 1;
c = b;
a = 3;

同样的问题,编译器是否可以重新排列第一和第二个,或者第三和第四个赋值语句?

4个回答

13

C++标准规定(1.9/6):

抽象机器的可观察行为是它对易失性数据的读写顺序和库I/O函数的调用序列。

在第一种情况下,您提出的任何更改都会更改易失性数据的写入顺序。

在第二种情况下,您提出的任何更改都不会更改顺序。因此,根据“as-if”规则(1.9/1),它们是允许的:

  

...符合规范的实现需要模拟(仅)抽象机器的可观察行为...

要确定发生了这种情况,您需要检查机器代码、使用调试器或引发未定义或未指定的行为,其结果您恰巧知道您的实现。例如,一个实现可以保证并发执行的线程对同一内存的视图,但这超出了C++标准的范围。因此,虽然标准可能允许特定的代码转换,但特定的实现可能会排除它,因为它不知道您的代码是否将运行在多线程程序中。

如果您使用可观察行为来测试是否发生了重新排序(例如,在上述代码中打印变量的值),那么当然不允许根据标准。


为什么2.1中的重新排序会改变顺序?只有一个分配是给易失变量吗? - Alex B
很高兴有人理解volatile +1。 - Norman Ramsey
机器码并不足够,因为它无法说明OOO处理器实际执行事务的方式。为了控制顺序,还必须使用硬件内存屏障(使用Intrinsics或汇编语言)。为了更好地控制编译顺序,可以使用特定于编译器的读/写屏障(仅限Intrinsics)。volatile的定义是“不优化读取和写入”(在没有可见副作用的假设下)。最好不要依赖于与volatile有关的任何顺序,这与代码消除无关。 - v.oddou
@v.oddou:如果你在机器代码中观察到重新排序,那么编译器已经重新排序了。你说得对,仅仅因为指令中没有明显的重新排序并不意味着没有重新排序,但我所说的是确认已经发生了重新排序,而不是确认它没有发生。然后,无论写入是否被重新排序,在缺乏内存屏障的情况下,它们可能会在另一个线程中以与它们被制造的顺序不同的顺序被观察到,这取决于缓存架构。 - Steve Jessop

4
对于方案1,编译器不应执行您提到的任何重排。 对于方案2,答案可能取决于:
- 变量b和c是否在当前函数之外可见(通过非本地或已传递其地址) - 与谁交谈(显然,在C / C ++中string volatile的使用存在争议) - 您的编译器实现
因此(软化我的第一个答案),我会说,如果您依赖方案2中的某些行为,则必须将其视为不可移植代码,其在特定平台上的行为必须由实现的文档确定(如果文档没有说明,则无法保证行为)。
来自C99 5.1.2.3/2“程序执行”:
访问易失性对象、修改对象、修改文件或调用执行这些操作的函数都是副作用,即执行环境状态的更改。表达式的评估可能会产生副作用。在执行序列中称为序列点的某些指定点上,所有先前评估的副作用都应该完成,并且随后评估的任何副作用都不应发生。
(第5段)符合要求的实现至少需要:
在序列点上,易失性对象稳定,以前的访问已经完成,随后的访问尚未发生。
以下是Herb Sutter在C / C ++中对volatile访问所需行为的一些说法(来自“volatile vs. volatilehttp://www.ddj.com/hpc-high-performance-computing/212701484)。
关于附近的普通读写,它们是否仍然可以在不可优化的读写周围重新排序?今天,没有实用的便携式答案,因为C/C++编译器实现差异很大,而且不太可能很快收敛。例如,C++标准的一种解释认为,普通读取可以自由地在C/C++易失性读取或写入的任何方向移动,但是普通写入不能在C/C++易失性读取或写入的任何方向上移动——这将使C/C++易失性既不那么受限制,也更受限制,比有序原子还要严格。一些编译器供应商支持该解释;其他人根本不会优化易失性读取或写入;还有一些人有自己的首选语义。

就此而言,微软为C/C++ volatile关键字(作为Microsoft特定内容)记录了以下信息:

  • 对volatile对象的写操作(volatile write)具有Release语义;在指令序列中出现在对volatile对象写操作之前的全局或静态对象的引用将出现在编译后的二进制代码中对该volatile写操作之前。

  • 对volatile对象的读操作(volatile read)具有Acquire语义;在指令序列中出现在对volatile内存读取之后的全局或静态对象的引用将出现在编译后的二进制代码中对该volatile读操作之后。

这使得volatile对象可以用于多线程应用程序中的内存锁定和释放。


1
从同一线程中观察到,标准并未说明更改何时可对其他任务可见。但是,即使您在答案中提供的引用表明 volatile 对象是稳定的,但对于其他非 volatile 访问,没有任何保证。 - Ben Voigt
易失性对象是稳定的;当访问易失性对象时,并不是所有对象都是稳定的。 - Potatoswatter
我对第一段的理解是只有第一种情况被排除了。它说“所有先前评估的副作用都必须完成”,但没有提到“非副作用”:本地非易失性变量的读/写,根据这个定义不被视为副作用。 - Alex B
在紧随c = b之后的序列点上,在抽象机器中,c = b 的副作用必须是完整的,但这仍然受到“仿佛”规则的限制。在示例代码中,c是否已经被赋值或者在生成的机器代码中是否被观察到,都不会影响可观测行为,所以实现可以重新排序它(或者完全消除它,因为c没有被使用)。如果在c = b之后但a = 3之前调用了可能引用c的未知代码函数,则c的值“在那个序列点”潜在地影响可观测行为。 - Steve Jessop
关于您的编辑:如果实现支持作为扩展的线程,并希望将volatile用作其多线程内存模型的一部分,那么情况会变得更加有趣,这就是Microsoft所做的。当然,C++03不关心任何这些。我很想知道Sutter是否阅读标准以允许完全优化掉c,但不允许将对a的写入重新排序到对c的写入之后。如果是这样,那有什么意义呢?如果不是,他是在说c不能被省略吗?还是他的解释由于某种原因不适用于这种情况。 - Steve Jessop
根据反馈,我已经缓和了关于场景2的回答。 - Michael Burr

2

Volatile不是内存屏障。在代码段#2中,对变量B和C的赋值可以被消除或随时执行。为什么你想让#2中的声明导致#1中的行为呢?


1
有一个非常常见的模式,其中代码需要写入一堆变量,然后设置一个“就绪”标志,或者需要在观察到“就绪”标志之后读取一堆变量。如果具有对volatile标志作为内存屏障的访问权限,则可以消除使所有其他变量volatile的需要,并因此允许编译器重新排序或合并对这些变量的访问。更好的是,将是一个“半易失性”限定符,它将使对可变变量的访问与易失性有序,但不与其他半易失性有序,但标准不包括该内容。 - supercat
@supercat,C++标准在std::atomic中有更好的东西。我不确定C11的_Atomic是否提供了内存屏障。 - Potatoswatter
从我所了解的情况来看,标准并没有清楚地说明编译器和执行平台之间的关系。特别是在独立实现中,程序员可能比编译器更了解内存接口配置,因此编译器可能不知道在多个CPU、DMA等各种情况下创建有效的内存屏障所需的内容,但程序员可能知道需要什么来确保执行的加载和存储效果在需要时命中物理内存,如果可以确保... - supercat
首先执行加载和存储。对于独立实现来说,有效地支持_Atomic类型可能很困难,但有用的加载-存储级语义不应该是问题。不幸的是,尽管每个平台都应该能够有效地提供加载-存储级内存屏障,但标准并没有强制要求这样做,一些编译器会以某种方式解释类型别名规则,使得使用内部内存管理回收不同类型内存块的单线程程序可以成功... - supercat
...失去数据竞争本身 [例如,如果内存被回收为惰性清除哈希表(它读取未初始化的数据,但如果这些读取产生任何值,则会正常工作),在用作其他类型之后,侵略性编译器可能会决定,由于在写入其他类型后读取哈希表条目将是UB,因此编译器可以将其他类型的写入重新排序为在读取相同插槽之后的哈希表写入之前。]。内存栅栏将阻止这种荒谬行为,但C标准甚至没有包括可以处理单线程情况的内存栅栏。 - supercat

0

一些编译器将对易失性限定对象的访问视为内存栅栏,而其他编译器则不会。有些程序需要使用volatile作为栅栏,而有些则不需要。

在提供栅栏的平台上运行需要栅栏的代码可能比在不提供栅栏的平台上运行不需要栅栏的代码更好,但如果没有提供栅栏,则需要栅栏的代码将出现故障。不需要栅栏的代码在提供栅栏的平台上通常会比需要栅栏的代码运行得更慢,而提供栅栏的实现将比不提供栅栏的实现运行这样的代码更慢。

一个好的方法可能是定义一个宏semi_volatile,在系统中volatile意味着内存屏障时扩展为空,或者在系统中不需要内存屏障时扩展为volatile。如果需要按照其他volatile变量但彼此之间不需要排序访问的变量被标记为semi-volatile,并且该宏被正确定义,则可以在具有或不具有内存屏障的系统上实现可靠操作,并且可以在具有屏障的系统上实现最高效的操作。如果编译器实际上实现了一个符合要求的限定符semivolatile,则可以将其定义为使用该限定符的宏,并实现更好的代码。
我认为这是标准真正应该解决的领域,因为所涉及的概念适用于许多平台,任何没有意义的屏障的平台都可以简单地忽略它们。

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