14

考虑以下写入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 的措辞

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

但这是关于volatiles (1.9/8) 的:

对 volatile 对象的访问严格按照抽象机的规则进行评估。

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

是我忽略了什么,还是 Chisnall 弄错了?

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

4

6 回答 6

8

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

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

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

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

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

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

于 2013-02-09T11:45:51.217 回答
4

你是对的,他是错的。对不同 volatile 变量的访问不能由编译器重新排序,只要它们出现在单独的完整表达式中,即由 C++98 所谓的序列点分隔,或者在 C++11 术语中,一个访问在另一个之前排序。

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

请参阅 Sutter 的信息volatile 与 volatile文章中表 1 的右下角单元格。

于 2013-02-09T15:03:19.243 回答
1

目前,我将假设您a=3的 s 只是复制和粘贴中的一个错误,而您的本意是c=3.

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

不过,这可能与这些值对其他处理器可见的顺序对应。在典型的(当前)CPU 上,该评估只会将值写入缓存。但是,硬件可以从那里重新排序,因此(例如)写入主存储器的顺序完全不同。同样,如果另一个处理器尝试使用这些值,它可能会看到它们以不同的顺序发生变化。

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

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

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

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

当对库 I/O 函数的调用返回或对 volatile 对象的访问被评估时,副作用被认为是完整的,即使调用(例如 I/O 本身)或 volatile 访问隐含的一些外部操作可能还没有完成。

因此,对 volatile 对象的访问必须按顺序启动,但不一定按顺序完成。不幸的是,它通常是外部可见的完成。因此,我们几乎回到了通常的 as-if 规则:编译器可以根据需要重新排列事物,只要它不会产生外部可见的变化。

于 2013-02-09T06:57:38.550 回答
0

看起来它可能会发生。

这个页面上有一个讨论:

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

于 2013-02-09T06:39:36.113 回答
0

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

长话短说:不要赌它。正确设计代码,不要滥用 volatile。必要时使用内存屏障或成熟的互斥锁。C++11 的atomic类型会有所帮助。

于 2013-02-09T06:56:23.413 回答
-2

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

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

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

简单地说,这并不意味着什么。没有“一个正确的地方”来观察读取和写入的顺序(RAM 总线?CPU 总线?L1 和 L2 缓存之间?来自另一个线程?来自另一个内核?),所以这个要求本质上是没有意义的。

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

至于内存映射硬件,这总​​是特定于平台的。C++ 标准甚至没有假装解决如何正确完成。例如,该平台可能只有一部分内存操作在该上下文中是合法的,比如绕过可以重新排序的写发布缓冲区,C++ 标准当然不会强制编译器发出正确的指令那个特定的硬件设备——它怎么可能呢?

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

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

由于没有人认为标准要求编译器发出指令来阻止 CPU 重新排序对 volatile 变量的访问,而现代编译器不这样做,所以没有人应该相信 C++ 标准禁止编译器重新排序对不同 volatile 的访问。

于 2013-02-09T06:52:38.017 回答