C 和 C++ 中的编译器优化和临时分配

Compiler optimizations and temporary assignments in C and C++

请参阅以下在 C 和 C++ 中有效的代码:

extern int output;
extern int input;
extern int error_flag;

void func(void)
{
  if (0 != error_flag)
  {
    output = -1;
  }
  else
  {
    output = input;
  }
}
  1. 是否允许编译器像下面这样编译上面的代码?

    extern int output;
    extern int input;
    extern int error_flag;
    
    void func(void)
    {
      output = -1;
      if (0 == error_flag)
      {
        output = input;
      }
    }
    

    换句话说,是否允许编译器生成(从第一个代码片段)始终将 -1 临时分配给 output 然后将 input 值分配给 [=12] 的代码=] 取决于 error_flag 状态?

  2. 如果将 output 声明为 volatile,是否允许编译器执行此操作?

  3. 如果 output 被声明为 atomic_int (stdatomic.h),编译器会被允许这样做吗?

在 David Schwartz 发表评论后更新:

如果编译器可以自由地向变量添加额外的写入,似乎无法从 C 代码中判断是否存在数据竞争。这个怎么判断?

  1. 是的,推测赋值是可以的。 non-volatile 变量的修改不是程序可观察行为的一部分,因此允许虚假写入。 (请参阅下文了解 "observable behaviour" 的定义,它实际上并不包括您可能观察到的所有行为。)

  2. 没有。如果 outputvolatile,则不允许推测性或虚假突变,因为突变 可观察行为的一部分。 (写入或读取硬件寄存器可能产生的后果不仅仅是存储一个值。这是 volatile 的主要用例之一。)

  3. (已编辑) 不,atomic output 不可能进行推测赋值。 atomic 变量的加载和存储是同步操作,因此不可能加载未显式存储到变量中的此类变量的值。

可观察到的行为

尽管程序可以做很多明显可见的事情(例如,由于段错误而突然终止),但 C 和 C++ 标准仅保证有限的结果集。 可观察行为 在 C11 草案的 §5.1.2.3p6 和当前的 C++14 草案的 §1.9p8 [intro.execution] 中定义,措辞非常相似:

The least requirements on a conforming implementation are:

— Access to volatile objects are evaluated strictly according to the rules of the abstract machine.

— At program termination, all data written into files shall be identical to one of the possible results that execution of the program according to the abstract semantics would have produced.

— The input and output dynamics of interactive devices shall take place in such a fashion that prompting output is actually delivered before a program waits for input. What constitutes an interactive device is implementation-defined.

These collectively are referred to as the observable behavior of the program.

以上摘自C++标准; C 标准的不同之处在于第二点它不允许多个可能的结果,第三点它明确引用了标准库要求的相关部分。但抛开细节不谈,定义是 co-ordinated;出于这个问题的目的,相关点是只能访问 volatile 变量(直到 non-volatile 变量的值被发送到输出设备或文件)。

数据竞赛

还需要在 C 和 C++ 标准的整体上下文中阅读本段,如果程序产生未定义的行为,这些标准将实现从所有要求中解放出来。这就是为什么在上面的 可观察行为 的定义中没有考虑段错误的原因:段错误是一种可能的未定义行为,但不是一致程序中的可能行为。所以在只有一致的程序和一致的实现的世界中,没有段错误。

这很重要,因为具有数据竞争的程序 符合。数据竞争具有未定义的行为,即使它看起来无害。由于程序员有责任避免未定义的行为,因此可以在不考虑数据竞争的情况下优化实现。

C 和 C++ 标准中对内存模型的阐述是密集的和技术性的,可能不适合作为概念的介绍。 (在 Hans Boehm's site 上浏览 material 可能会证明不那么困难。)从标准中提取引用是有风险的,因为细节很重要。但是,从当前的 C++14 标准 §1.10 [intro.multithread]:

开始,这里有一个小小的飞跃。
  1. Two expression evaluations conflict if one of them modifies a memory location and the other one reads or modifies the same memory location.

  1. Two actions are potentially concurrent if

    — they are performed by different threads, or

    — they are unsequenced, and at least one is performed by a signal handler.

    The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

这里的take-away是同一个变量的读写需要同步;否则就是数据竞赛,结果是未定义的行为。一些程序员可能会反对这种禁令的严格性,认为某些数据竞争是 "benign"。这是Hans Boehm's 2011 HotPar paper "How to miscompile programs with "benign" data races" (pdf)(作者总结:"There are no benign data races")的主题,他解释得比我好得多。

这里的同步包括atomic类型的使用,因此并发读取和修改atomic变量不是数据竞争。 (读取的结果是不可预测的,但它必须是修改前的值或修改后的值。)这可以防止编译器在没有显式同步的情况下对原子变量执行 "piecemeal" 修改。

经过一些思考和更多的研究,我的结论是编译器也不能对原子变量执行推测性写入。因此,我修改了问题 3 的答案,我最初回答的是 "no".

其他有用的参考资料:

  • 巴尔托斯·米莱夫斯基:Dealing with Benign Data Races the C++ Way

    Milewski 处理了对原子变量的推测性写入的精确问题,并得出结论:

    Can’t the compiler still do the same dirty trick, and momentarily store 42 in the owner variable? No, it can’t! Since the variable is declared atomic the compiler can no longer assume that the write can’t be observed by other threads.

  • Herb Sutter Thread Safety and Synchronization

    像往常一样,通俗易懂的 well-written 解释。

是的,允许编译器进行这种优化。通常,您可以假设编译器(以及 CPU 也是)可以重新排序您的代码,假设它在单个线程中是 运行。如果你有多个线程,你需要同步。如果您不同步并且您的代码写入到另一个线程写入或读取的内存位置,则您的代码包含数据竞争,在 C++ 中这是未定义的行为。

volatile 不会改变数据竞争问题。但是 IIRC,不允许编译器重新排序读取和写入 volatile 变量。

当使用atomic_int时,编译器仍然可以执行某些优化。我不认为编译器可以发明写入(这可能会破坏多线程程序)。但是,它仍然可以重新排序操作,所以要小心。