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

28 浏览
0 Comments

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

编译器无法消除或重新排序对于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;

同样的问题,编译器能够重新排序第一和第二个赋值操作,或第三和第四个赋值操作吗?

0
0 Comments

C++标准规定,可观察的行为是对volatile数据的读写和对库I/O函数的调用。在第一种情况中,你提出的任何更改都会改变对volatile数据的写入顺序。在第二种情况下,你提出的任何更改都不会改变顺序。因此,根据“as-if”规则,这些更改是允许的,标准要求符合规范的实现仅需要模拟抽象机器的可观察行为。

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

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

为什么在2.1中的重排序会改变顺序?只有一个赋值是给volatile变量?

很高兴有人理解volatile +1

机器代码是不够的,因为它对OOO处理器实际执行操作的方式没有任何说明。要控制顺序,还必须使用硬件内存屏障(使用内部函数或汇编语言)。为了更好地控制编译顺序,可以使用编译器特定的读写屏障(仅内部函数)。volatile的定义是不优化读取和写入(在没有可见副作用的假设下)。最好不要依赖于关于volatile顺序的任何内容,它不是关于顺序,而是关于代码消除。

如果在机器代码中观察到重排序,那么编译器已经进行了重排序。你说的没错,不能仅仅因为指令不明显就说没有重排序,但我讨论的是确认发生了重排序,而不是确认没有发生重排序。然后,无论写入是否重排序,在没有内存屏障的情况下,可能会观察到在另一个线程中以与其发生的顺序不同的顺序发生,这取决于缓存架构。

0
0 Comments

"volatile"修饰符和编译器重新排序之间的问题源于以下几个原因:首先,volatile修饰符并不是一个内存屏障,这意味着对于代码段#2中变量B和C的赋值可以被消除或者在任何时候进行。但是为什么我们希望代码段#2中的声明能够导致代码段#1的行为呢?原因在于,有一种常见的模式是代码需要写入一组变量,然后设置一个"ready"标志,或者在观察到"ready"标志后需要读取一组变量。如果可以将volatile标志作为内存屏障,就可以避免将所有其他变量都设置为volatile的需要,从而允许编译器重新排序或合并对这些变量的访问。更好的方法是引入一个"semi-volatile"修饰符,可以将对变量的访问与volatile变量的顺序相关联,但不与其他semi-volatile变量相关联,但是标准中并没有包含这个修饰符。

C++标准中引入了更好的解决方案,即std::atomic。关于C11的_Atomic,我不确定,但它可能提供了内存屏障。

从我所了解的情况来看,标准并没有很清楚地阐明编译器与执行平台之间的关系。特别是在无操作系统的实现中,程序员可能比编译器更了解内存接口配置,因此编译器可能不知道在具有多个CPU、DMA等多种情况下创建有效的内存屏障所需的信息。但是程序员可能知道在必要时如何确保执行的加载和存储命中物理内存,但前提是能够确保这些加载和存储能够被执行。对于无操作系统的实现来说,支持_Atomic类型可能是困难的,但是支持有用的加载-存储级别语义并不困难。不幸的是,尽管每个平台都应该能够有效地提供加载-存储级别的内存屏障,但是标准并没有强制要求这样做,而且一些编译器对类型别名规则的解释可能会导致在单线程程序中使用内部内存管理来回收不同类型的内存块时出现数据竞争的情况。

内存屏障可以阻止这种无意义的行为,但是C标准中没有包含任何可以处理单线程情况的内存屏障。

0
0 Comments

"volatile" qualifier and compiler reorderings

在编译器重排的情况下,"volatile"限定符的出现原因和解决方法是什么?

对于情景1,编译器不应该执行您提到的任何重排操作。对于情景2,答案可能取决于以下几个因素:

- 变量b和c是否在当前函数之外可见(通过非局部或通过传递地址)

- 与您交谈的人(显然在C/C++中对"volatile"的解释存在一些分歧)

- 编译器的实现方式

因此(软化我的第一个答案),我会说,如果您依赖情景2中的某些行为,那么您必须将其视为非可移植代码,其在特定平台上的行为需要根据实现的文档确定(如果文档对此没有说明,那么您将无法获得有保障的行为)。

根据C99 5.1.2.3/2 "Program execution"的规定:

在执行程序时,访问"volatile"对象、修改对象、修改文件或调用执行这些操作的函数都是副作用,即更改执行环境的状态。表达式的评估可能会产生副作用。在执行序列的特定指定点,称为序列点,所有先前评估的副作用都必须完成,而后续评估的副作用不得发生。

...

(第5段)符合规范的实现的最低要求是:

- 在序列点上,"volatile"对象是稳定的,即先前的访问已经完成,后续的访问尚未发生。

以下是Herb Sutter在C/C++中对"volatile"访问所需行为的一些观点(来自"volatile vs. volatile"的文章):

关于附近的普通读写,它们是否仍然可以在不可优化的读写周围进行重排序?今天,由于C/C++编译器的实现方式各不相同,因此没有实际的可移植答案,而且不太可能在短期内达成一致。例如,C++标准的一种解释认为普通读可以自由地在C/C++的"volatile"读或写的任何方向上移动,但是普通写不能在C/C++的"volatile"读或写的任何方向上移动- 这将使C/C++的"volatile"既比有序原子更自由,又更受限制。一些编译器供应商支持该解释;其他人根本不对"volatile"读写进行优化;还有一些有自己首选的语义。

至于微软关于C/C++中"volatile"关键字的文件(作为微软特定的)的内容如下:

- 对"volatile"对象的写入(volatile写入)具有释放语义;在指令序列中,在写入"volatile"对象之前对全局或静态对象的引用将在编译二进制文件中发生在该"volatile"写入之前。

- 对"volatile"对象的读取(volatile读取)具有获取语义;在指令序列中,在读取"volatile"内存之后对全局或静态对象的引用将在编译二进制文件中发生在该"volatile"读取之后。

这允许将"volatile"对象用于多线程应用程序中的内存锁定和释放。

从同一个线程观察到的相同线程的变化是可见的,但没有关于何时对其他任务可见的保证。但是,即使您在答案中提供的引用也说"volatile"对象是稳定的,对于其他非"volatile"访问,没有任何保证。

"volatile"对象是稳定的;并不是说在访问"volatile"对象时所有对象都是稳定的。

我对第一段的解释是只排除第一种情况。它说“先前评估的所有副作用都必须完成”,但没有提到"非副作用":局部的非"volatile"变量读/写,根据此定义,这不被视为副作用。

c = b之后的序列点上,必须完成c = b的副作用,在抽象机器中,但这仍然受到"as-if"规则的约束。在示例代码中,生成的机器代码中是否实际分配了c的值是不可观察的行为,因此实现可以对其进行重排序(或完全消除,因为c未使用)。如果在c = b之后但在a = 3之前对未知代码的函数调用可能有对c的引用,那么c的值在"那个序列点"可能会影响可观察行为。

关于您的编辑:如果实现支持作为扩展的线程,并希望将"volatile"用作其多线程内存模型的一部分,那么情况会变得更加有趣,这也是微软的做法。当然,C++03对此并不关心。我很想知道Sutter是否读到标准允许完全优化掉c,但不允许将其写入重新排序到写入a之后。如果是这样,那有什么意义呢?如果不是,他是在说c不能被省略吗?还是说他的解释对于这种情况没有适用的原因。

根据反馈,我在对情景2的回答中进行了软化。

0