21

考虑下面的 C++ 11 片段。对于 GCC 和 clang,这编译为两个(顺序一致的)foo 负载。(编者注:编译器不优化原子,有关更多详细信息,请参阅此 Q&A,特别是http://wg21.link/n4455标准讨论这可能产生的问题,标准没有为程序员提供解决工具。这种语言-lawyer Q&A 是关于当前标准的,而不是编译器所做的。)

C++ 内存模型是否允许编译器将这两个加载合并为一个加载并为 x 和 y 使用相同的值?

(编者注:这是标准组正在研究的内容: http ://wg21.link/n4455和http://wg21.link/p0062。目前纸上的标准允许不受欢迎的行为。)


我认为它无法合并这些负载,因为这意味着轮询原子不再起作用,但我在内存模型文档中找不到相关部分。

#include <atomic>
#include <cstdio>

std::atomic<int> foo;

int main(int argc, char **argv)
{
    int x = foo;
    int y = foo;

    printf("%d %d\n", x, y);
    return 0;
}
4

2 回答 2

18

是的,因为我们无法观察到差异!

允许实现将您的代码段转换为以下代码(伪实现)。

int __loaded_foo = foo;

int x = __loaded_foo;
int y = __loaded_foo;

原因是您无法观察到上述内容之间的差异,并且考虑到顺序一致性的保证,两个单独的foo负载。

注意:不仅仅是编译器可以进行这种优化,处理器可以简单地推断出你无法观察到差异并加载foo一次的值——即使编译器可能已经要求它这样做两次。





解释

给定一个以增量方式不断更新foo的线程yx.

// thread 1 - The Writer
while (true) {
  foo += 1;
}
// thread 2 - The Reader
while (true) {
  int x = foo;
  int y = foo;

  assert (y >= x); // will never fire, unless UB (foo has reached max value)
}                  

想象一下,由于某种原因,编写线程在每次迭代时都会暂停其执行(因为上下文切换或其他实现定义的原因);您无法证明这是导致两者x具有y相同值的原因,或者是因为“合并优化”。


换句话说,我们必须给定本节中的代码的潜在结果:

  1. 在两次读取 ( )之间没有向foo写入新值。x == y
  2. 在两次读取 ( )之间将一个新值写入foo 。x < y

由于这两种情况中的任何一种都可能发生,因此实现可以自由地缩小范围以简单地始终执行其中一个;我们无法观察到差异。





标准是怎么说的?

只要我们无法观察到我们表达的行为与执行期间的行为之间的任何差异,实现就可以进行任何它想要的更改。

这包括在[intro.execution]p1

本国际标准中的语义描述定义了一个参数化的非确定性抽象机。本国际标准对一致性实现的结构没有要求。特别是,它们不需要复制或模仿抽象机器的结构。相反,需要符合要求的实现来模拟(仅)抽象机的可观察行为,如下所述。

另一部分使其更加清晰[intro.execution]p5

执行格式良好的程序的一致实现应产生 与具有相同程序和相同输入的抽象机的相应实例的可能执行之一相同的可观察行为。

延伸阅读





循环轮询呢?

// initial state
std::atomic<int> foo = 0;
// thread 1
while (true) {
  if (foo)
    break;
}
// thread 2
foo = 1

问题:根据前面几节的推理,一个实现是否可以简单地在线程 1foo中读取一次,然后即使线程 2写入也永远不会跳出循环?foo

答案; 不。

在顺序一致的环境中,我们保证在线程 2中写入foo将在线程 1中可见;这意味着当写入发生时,线程 1必须观察这种状态变化。

注意:一个实现可以将两个读取变成一个读取,因为我们无法观察到差异(一个栅栏与两个栅栏一样有效),但它不能完全忽略本身存在的读取。

:本节内容由[atomics.order]p3-4.





如果我真的想阻止这种形式的“优化”怎么办?

如果您想强制实现在您编写它的每个点实际读取某个变量的值,您应该研究使用volatile(请注意,这绝不会增强线程安全性)。

但在实践中,编译器不会优化 atomics,标准组建议不要使用volatile atomic这种原因,直到尘埃落定这个问题。看

于 2015-10-14T14:32:31.093 回答
-1

是的,在您的特定示例中(否则没有)。

您的特定示例具有单个执行线程,foo具有静态存储持续时间和初始化(即,main输入之前),否则在程序的生命周期内永远不会对其进行修改。
换句话说,没有外部可观察到的差异,并且可以合法地应用as-if规则。事实上,编译器可以完全取消原子指令。从来没有任何不同的价值xy可能有任何不同。

在具有修改的并发程序中foo情况并非如此

您没有指定内存模型,因此使用默认模型,即顺序一致性。顺序一致性被定义为提供与释放/获取相同的发生前/内存排序保证,并建立所有原子操作的单个总修改顺序。最后一点是重要的部分。

单个总修改顺序意味着如果您有三个(原子)操作,例如 A、B 和 C,它们按该顺序发生(可能同时发生在两个线程中),并且 B 是写操作,而 A 和 C 是读操作,那么 C必须看到 B 建立的状态,而不是其他更早的状态。也就是说,在 A 点和 C 点看到的值会有所不同

就您的代码示例而言,如果另一个线程foo在您将其读入后立即进行修改x(但在您将值读入 之前y),则放入的值y 必须是写入的值。因为如果操作按该顺序发生,它们也必须按该顺序实现。

当然,恰好发生在两个连续加载指令之间的写入是不太可能发生的事情(因为时间窗口非常小,仅仅是一个滴答声),但它是否不太可能并不重要。
编译器必须生成代码,以确保如果出现这种情况,操作仍然可以按照它们发生的顺序被看到。

于 2015-10-14T15:20:20.180 回答