未定义的行为追溯是否意味着不能保证早期可见的副作用?

Pet*_*des 15 c gcc compiler-optimization undefined-behavior language-lawyer

在 C++ 中,如果我正确理解措辞,编译器可以假设不会发生 UB,从而影响将遇到 UB 但尚未遇到的执行路径中的行为(甚至是 I/O 等可见的副作用)。

在抽象机遇到 UB 之前,C 是否需要“正确”执行程序直至最后可见的副作用?编译器似乎以这种方式运行,但在 C++ 模式和 C 模式下都是如此,因此这可能只是错过了优化,或者是有意选择减少“对程序员的敌意”。

ISO C 标准是否允许这样的优化? (出于各种原因,编译器仍然可能合理地选择不这样做,包括在不错误编译任何其他情况的情况下实现困难,或“实现质量”因素。)


ISO C++ 标准对于这一点相当明确

这个问题(主要)是关于 C 的,但 C++ 至少是一个有趣的比较点,因为 UB 的概念在两种语言中至少是相似的。我在 ISO C 中没有看到任何类似的显式语言,因此提出了这个问题。

ISO C++ [intro.abstract]/5是这么说的(至少从 C++11 开始,可能更早):

执行格式良好的程序的一致实现应产生与具有相同程序和相同输入的抽象机的相应实例的可能执行之一相同的可观察行为。但是,如果任何此类执行包含未定义的操作,则本文档对使用该输入执行该程序的实现没有任何要求(甚至不涉及第一个未定义操作之前的操作)。

我认为对使用该输入执行该程序的实现没有要求的预期含义是,即使在抽象机遇到 UB 之前排序的可见副作用(例如访问volatile或包括 unbuffered 的 I/O fprintf(stderr, ...))也不需要即将发生。

“用该输入执行该程序”这句话是指整个程序,从执行开始就开始。(有些人谈论“时间旅行”,但这实际上是一个问题,例如后来的代码允许值范围假设(例如非空)影响编译时决策中的早期分支,正如其他人将其放在上一个SO问题。编译器可以假设整个程序的执行不会遇到UB。)

真实编译器行为的测试用例

我试图让编译器来完成我想知道的优化。这非常明确地表明根据编译器开发人员对该标准的解释是允许的。(除非它实际上是一个编译器错误。)但到目前为止我所尝试的一切都表明编译器保留了可见的副作用。

我只尝试过volatile访问(不是putcharstd::cout<<或其他),假设优化器应该更容易查看和理解。对非内联函数的调用通常printf是优化器的黑匣子,除非它们是基于函数名称的特殊情况,例如一些非常重要的函数,例如memcpy. 此外,假设对 I/O 函数的调用可能会永远阻塞,甚至可能中止,因此在以后的代码中永远不会遇到 UB。

实际上我只尝试过volatile商店,没有尝试过volatile阅读。出于某种原因,编译器可能会以不同的方式处理该问题,尽管您不希望如此。

编译器确实假设volatile访问不会陷入陷阱,例如,它们会在其周围进行死存储消除(Godbolt)。因此,volatile加载或存储不应阻止优化器看到此执行路径中将发生 UB。(更新:这可能没有我想象的那么多,因为如果它确实捕获到该程序内的信号处理程序,ISO C 和 C++ 都说只有volatile sig_atomic_t变量才会在信号处理程序中具有其“预期”值。所以死了- 存储消除volatile可能引发信号的非全局对象,然后仍然允许恢复或不恢复。但它仍然表明假设volatile访问不会太奇怪。)

之前的一些示例(例如导致时间旅行的未定义行为)围绕 if/else 示例,其中一侧会遇到 UB,因此编译器可以假设采用另一侧。

但那些在肯定会导致 UB 的执行路径中没有明显的副作用,只有在另一条路径中。这个例子确实有这样的内容:

volatile int sink;     // same code-gen with plain   int sink;
void foo(int *p) {
    if (p)         // null pointer check *could* be deleted due to unconditional deref later.
        sink = 1;      // but GCC / clang / MSVC don't

    *p = 2;
}
Run Code Online (Sandbox Code Playgroud)

GCC13 和 clang16 以与 x86-64 相同的方式编译它(带有-O3)。(Godbolt:我正在编译-xc++告诉他们将其视为 C++。)还有 MSVC19.37,但使用pRCX 而不是 RDI 中的 arg。

foo(int*):
        test    rdi, rdi
        je      .LBB0_2                      #  if (!p)  goto .LBB0_2, skipping the if body
        mov     dword ptr [rip + sink], 1    # then fall-through, rejoining the other path
.LBB0_2:
        mov     dword ptr [rdi], 2
        ret
Run Code Online (Sandbox Code Playgroud)

使用if(!p)作为循环条件,MSVC 的代码生成是相同的,除了jne代替je。GCC 和 Clang 进行尾部复制,制作两个块,每个块都以 结尾ret,第一个块只是存储*p=2;,第二个块执行两个存储。(这很有趣,因为 clang 编译*(int*)0为零指令,但通过尾部重复,它会创建一个块,其中被证明为p空,但仍然发出实际的存储指令。)

如果我们放在之前*p = 2; if()空指针检查确实会被删除。(baz()在 Godbolt 链接中:编译为 2 个无条件存储。)

事实上,即使对于非易失性(使用-xc++-xc)也不会发生“预期”优化,这可能表明编译器通常会尝试避免追溯效应,以此作为避免在达到 UB 之前更改可见副作用的方法。或者它可能只是告诉我们编译器还不够激进,无法证明我的观点。在非 UB 情况下发明存储是一种棘手的线程安全违规,因此我可以想象编译器对此会持谨慎态度。

至少对于非商店而言,一些成功的例子volatile是:

volatile int sink;
void bar_nv(int *p) {
    /*volatile*/ int sink2;
    if (p) {
        sink = 3;    // volatile
    }else{
        sink2 = 4;   // non-volatile
        *p = 4;  // reachable only with p == NULL, so compilers can assume it's *not* reached.  Only clang takes advantage
    }
}
Run Code Online (Sandbox Code Playgroud)

Clang16 -O3,编译为 C 或 C++。(与仍然分支的 GCC 不同)。

bar_nv(int*):
        mov     dword ptr [rip + sink], 3
        ret
Run Code Online (Sandbox Code Playgroud)

sink2这优化了包含非易失性副作用的整个分支。

如果我们制作sink2volatile,那么它会分支并且仍然会产生可见的副作用,sink2即在脱离函数末尾之前存储到该执行路径中(实际上并没有解引用p,已知在函数的那一侧为空)if)。请参阅bar_vGodbolt 链接。

我正在研究的另一个案例:https://godbolt.org/z/vjqeb59TG在 if/else 的两侧放置*pderefs,导致类似的结果bar_nvvs.类似的结果bar_v

因此,我无法让编译器优化执行路径中的易失性副作用,即使在 C++ 中,该执行路径也肯定会导致 UB。但这并不能证明 ISO C++ 标准不允许这样做。(我仍然有点好奇这是否是故意的,或者是否存在发生这种优化的情况。)

正在做可见的副作用是不同的:null-deref 是 UB,因此没有任何保证,甚至没有实际的故障。它是 UB,所以任何事情都可能发生,包括什么都不做或进行随机 I/O。


早期的问答(主要是我发现了 C++ 问题,而不是 C):

  • 这个问题是由 @user541686 最近的问答评论中的讨论引发的,他声称即使是 C++ 措辞也不允许编译器在达到未定义操作之前忽略可见的副作用(特别printf是访问)。volatile在后面的讨论中,他们可能将他们的论点缩小到这样一种说法:这种优化是不可能的,因为 I/O 可能会出现故障或永远阻塞,从而实际上不会达到未定义的操作。但我能够证明 GCC 和 clang确实可以确实假设volatile出错,或者至少它们不会陷入该程序中可以观察其他全局变量状态的其他代码。

    所以我认为他们对 C++ 的看法是错误的,但认为 ISO C 至少可以被解释为在未定义的操作实际发生之前需要所有可见的副作用,这是合理的。(这就是编译器实际上为 C 和 C++ 所做的事情。)但这常见吗?或者通常被解释为不需要这样做?

  • 导致时间旅行的未定义行为- ,询问Raymond Chen 的文章 未定义行为可能导致时间旅行。该示例在遇到 UB 的执行路径中的 UB 之前没有任何可见的副作用,因此假定较早的分支不会到达。该问题的答案描述了允许编译器假设 UB 不可访问,但在这种情况下,它并没有讨论忽略未定义操作之前可能发生的可见副作用。

  • C++ 最早可以表现出来的未定义行为是什么?- ,大多数答案都同意程序的整个执行是未定义的,而不仅仅是在达到 UB 之后。

  • 时间旅行未定义行为是否存在无法跨越的障碍?-这个问题的版本,具有类似的石蕊测试。仅在评论中回答,但意见是可见的副作用不一定会发生。

  • 如果程序的一部分表现出未定义的行为,它会影响程序的其余部分吗?- hacks的答案引用了C标准(n1570-3.4.3(P2))关于UB后果的内容,然后毫无理由地断言它适用于整个程序。从 C 标准中的措辞来看,这一点并不明显,如果还有其他相关的话,我也不知道。芭丝谢芭的回答是:“矛盾的是,在此之前运行的语句的行为也是未定义的。” 但没有指定这是在谈论 C 还是 C++ 还是两者,并且没有引用任何标准语言来支持它。

  • 具有未定义行为且从未实际执行的表达式是否会使程序出错? 问题,但 @supercat 发布了一个答案说

    一旦程序进入没有定义事件序列的状态,AC 编译器就可以做任何它喜欢做的事情,这将允许程序在将来的某个时刻避免调用未定义的行为

    他们不支持这一点并引用标准,但他们评论道了另一个问题:

    不要使用术语“一旦发生未定义行为”,而应使用“一旦建立了导致未定义行为不可避免的条件”。C 标准中的语言可能旨在使未定义行为相对于其他代码无序,但一些编译器编写者却将其解释为暗示它应该不受时间法则和因果律的约束。

    因此,对于会遇到 UB 的执行缺乏追溯性要求,听起来 C 比 C++ 没有那么明确。ISO C 标准中具体包含哪种语言,以及对此解释的论点是什么,假设这实际上是编译器编写者的想法,但仍然选择不让编译器沿着已经通向 UB 的路径优化掉可见的副作用。

    (@supercat 值得注意的是,基于没有 UB 假设的现代 C 和 C++ 激进优化已经错过了标准原始作者的意图。特别是当其中包括有符号整数溢出或比较不相关的指针之类的内容时我们正在编译的机器上的 asm 中存在问题。这当然不是很好,但是将int循环中的变量提升为指针宽度对于 64 位机器来说是一个相当重要的优化,因此有明显的理由开始走这条路,这使得现代 C而 C++ 则对程序员来说充满了地雷。)

在这个问题中,我问的是书面的 ISO C 标准允许什么,无论是明确的还是根据任何普遍同意的解释。 尤其是这是否比编译器在我的测试用例中实际执行的操作更宽松。我并不是在争论真正的编译器是否应该进行更多优化;不这样做似乎是合理的。

sup*_*cat 0

The Standard is designed to, among other things, allow implementations to arbitrarily interleave strings of processing steps which have no observable sequencing relationship with operations outside their own string. If an action which has no specified observable side effects follows some other action which does have side effects, a compiler might reorder the action which has no side effects ahead of the preceding actions. If attempting to perform that action triggers an unexpected side effect, such side effect may manifest itself ahead of the "preceding" other actions which had documented side effects.

Nothing in the Standard, however, would forbid an implementation from processing code in ways that are inconsistent not only with code execution sequence, but even with normal laws of causality. Given a sequence like:

unsigned test(unsigned x, unsigned mask)
{
  unsigned i=1;
  while((i & mask) != x)
    i*=3;
  if (x < someValue)
    doSomething(x);
  return i;
}
Run Code Online (Sandbox Code Playgroud)

nothing within the loop would seem capable of affecting the value of x, or consequently affect the behavior of the following if statement, but if the clang or gcc compilers can show that the return value of the above function will never be used, and that someValue is larger than the largest possible value of mask, they may decide to eliminate both the code for the loop and the conditional check, changing the function so it unconditionally passes x to doSomething().