可以对易失物的访问重新排序

May accesses to volatiles be reordered?

本文关键字:新排序 排序 访问 易失物      更新时间:2023-10-16

考虑以下写入volatile内存的顺序,这是我从David Chisnall在InformIT上的文章"理解C11和C++11原子学"中摘录的

volatile int a = 1;
volatile int b = 2;
             a = 3;

我从 C++98 的理解是,根据 C++98 1.9,这些操作无法重新排序:

符合 实现需要模拟(仅(抽象机器的可观察行为,作为 下面解释 ... 抽象机器的可观察行为是其对易失性数据的读取和写入序列,以及 调用库 I/O 函数

Chisnall说,对顺序保存的约束仅适用于单个变量,他写道,一个符合要求的实现可以生成这样做的代码:

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

或者这个:

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

C++11重复了C++98的措辞

符合 需要实现来模拟(仅(抽象机器的可观察行为,如前所述 下面。

但这样说volatile S (1.9/8(:

对易失性对象的访问严格根据抽象机器的规则进行评估。

1.9/12 表示访问 volatile glvalue(包括上面的变量 abc(是一种副作用,1.9/14 表示一个完整表达式(例如,语句(中的副作用必须先于同一线程中后来的完整表达式的副作用。 这使我得出结论,Chisnall显示的两次重新排序是无效的,因为它们与抽象机器规定的顺序不对应。

是我忽略了什么,还是奇斯纳尔弄错了?

(请注意,这不是一个线程问题。 问题是是否允许编译器在单个线程中对不同volatile变量的访问进行重新排序。

IMO Chisnalls的解释(如您所介绍的(显然是错误的。更简单的情况是 C++98。需要保留sequence of reads and writes to volatile data,这适用于任何易失性数据的有序读取和写入序列,而不是单个变量。

如果您考虑易失性的原始动机:内存映射 I/O,这一点变得很明显。在mmio中,您通常在不同的内存位置有几个相关的寄存器,并且I/O设备的协议需要对其寄存器集进行特定的读取和写入序列 - 寄存器之间的顺序很重要。

C++11 的措辞避免谈论绝对sequence of reads and writes,因为在多线程环境中,跨线程没有一个明确定义的此类事件序列 - 如果这些访问转到独立的内存位置,这不是问题。但我相信其意图是,对于任何具有明确定义顺序的易失性数据访问序列,规则与 C++98 相同 - 无论该序列中访问多少个不同的位置,都必须保留顺序。

这是一个

完全独立的问题,这对实施意味着什么。如何(甚至是否(从程序外部观察到易失性数据访问,以及程序的访问顺序如何映射到外部可观察的事件是未指定的。实现可能应该给你一个合理的解释和合理的保证,但什么是合理的取决于上下文。

C++11 标准为非同步易失性访问之间的数据竞争留出了空间,因此不需要用完整的内存围栏或类似的结构来包围这些访问。如果内存的某些部分真正用作外部接口(用于内存映射 I/O 或 DMA(,那么实现可能会合理地保证对这些部分的易失性访问如何暴露给消耗设备。

一个保证可能可以从标准中推断出来(参见[into.execution](:类型volatile std::sigatomic_t的值必须具有与写入顺序兼容的值,即使在信号处理程序中也是如此 - 至少在单线程程序中。

你是对的,他是错的。编译器不能对不同易失变量的访问进行重新排序,只要它们出现在单独的完整表达式中,即由 C++98 称为序列点的分隔,或者用 C++11 术语来说,一个访问先于另一个访问进行排序。

Chisnall似乎试图解释为什么volatile对于编写线程安全代码毫无用处,通过展示一个简单的互斥实现,依赖于编译器重新排序会破坏volatile。 他是对的,volatile对线程安全毫无用处,但不是因为他给出的原因。这不是因为编译器可能会对volatile对象的访问重新排序,而是因为 CPU 可能会对它们重新排序。原子操作和内存屏障阻止编译器 CPU 根据线程安全需要跨屏障对事物进行重新排序。

请参阅表 1 的右下角单元格,请参阅萨特的信息性挥发性与挥发性文章。

目前,我假设您的a=3只是复制和粘贴中的一个错误,而您确实希望它们c=3

这里真正的问题是评估之间的区别之一,以及事物如何对另一个处理器可见。标准描述了评估的顺序。从这个角度来看,你是完全正确的 - 给定abc的分配,必须按该顺序评估作业。

但是,这可能这些值对其他处理器可见的顺序不符。在典型的(当前(CPU 上,该评估只会将值写入缓存。硬件可以从那里重新排序,因此(例如(写出到主内存的顺序完全不同。同样,如果另一个处理器尝试使用这些值,它可能会认为它们以不同的顺序更改。

是的,这是完全允许的 - CPU仍然完全按照标准规定的顺序评估分配,因此满足了要求。该标准根本没有对评估后发生的事情提出任何要求,这就是这里发生的事情。

我应该补充一点:在某些硬件上就足够了。例如,x86 使用缓存侦听,因此,如果另一个处理器尝试读取一个处理器已更新的值(但仍仅在缓存中(,则具有当前值的处理器将保留另一个处理器的读取,直到可以写出当前值,以便另一个处理器看到当前值。

不过,并非所有硬件都是如此。虽然保持严格的模型使事情变得简单,但当你有很多处理器时,在额外的硬件方面也相当昂贵,以确保一致性和简单的速度。

编辑:如果我们暂时忽略线程,问题会变得简单一些 - 但并不多。根据 C++11, §1.9/12:

当返回对库 I/O 函数的调用或评估对易失性

对象的访问时,即使调用(如 I/O 本身(或易失性访问隐含的某些外部操作可能尚未完成,副作用也被视为已完成。

因此,对易失性对象的访问必须按顺序启动,但不一定按顺序完成。不幸的是,通常是外部可见的完成。因此,我们几乎回到了通常的假设规则:编译器可以根据需要重新排列内容,只要它不产生外部可见的更改。

看起来它可能会发生。

此页面上有一个讨论:

http://gcc.gnu.org/ml/gcc/2003-11/msg01419.html

这取决于您的编译器。例如,从Visual Studio 2005开始,MSVC++保证*易失性不会被重新排序(实际上,Microsoft所做的是放弃并假设程序员将永远滥用volatile - MSVC++现在围绕volatile的某些用法添加了内存屏障(。其他版本和其他编译器可能没有此类保证。

长话短说:不要赌它。正确设计代码,不要滥用易失性。根据需要改用内存屏障或成熟的互斥锁。C++11的atomic类型会有所帮助。

C++98 并没有说指令不能重新排序。

抽象机器的可观察行为是其对易失性数据的读取和写入以及对库 I/O 函数的调用序列

这表示这是读取和写入本身的实际顺序,而不是生成它们的指令。任何说指令必须按程序顺序反映读取和写入的论点都同样可以争辩说,对RAM本身的读取和写入必须按程序顺序进行,显然这是对要求的荒谬解释。

简而言之,这并不意味着什么。没有"一个正确的地方"来观察读写顺序(RAM总线?CPU 总线?在 L1 和 L2 缓存之间?从另一个线程?从另一个核心?(,所以这个要求基本上是没有意义的。

线程的任何引用之前的C++版本显然没有指定从另一个线程看到的易失性变量的行为。C++11(明智地,IMO(并没有改变这一点,而是引入了具有明确定义的线程间语义的合理原子操作。

至于内存映射硬件,这始终是特定于平台的。C++标准甚至没有假装解决如何正确完成此操作的问题。例如,该平台可能只有一部分内存操作在该上下文中是合法的,例如绕过可以重新排序的写入发布缓冲区的操作,并且C++标准肯定不会强制编译器为该特定硬件设备发出正确的指令 - 怎么可能?

更新:我看到一些反对票,因为人们不喜欢这个事实。不幸的是,这是真的。

如果C++标准禁止编译器对不同易失物的访问重新排序,理论上这种访问的顺序是程序可观察行为的一部分,那么它还要求编译器发出禁止 CPU 这样做的代码。该标准不区分编译器做什么和编译器生成的代码使 CPU 做什么。

由于没有人相信

该标准要求编译器发出指令以防止CPU对易失性变量的访问重新排序,并且现代编译器不这样做,因此没有人应该相信C++标准禁止编译器对不同可变变量的访问进行重新排序。