158

已更新,见下文!

我听说并读到 C++0x 允许编译器为以下代码段打印“Hello”

#include <iostream>

int main() {
  while(1) 
    ;
  std::cout << "Hello" << std::endl;
}

它显然与线程和优化能力有关。在我看来,这可能会让很多人感到惊讶。

有人对为什么有必要允许这样做有很好的解释吗?作为参考,最新的 C++0x 草案在6.5/5

在 for 语句的情况下,在 for-init 语句之外的循环,

  • 不调用库 I/O 函数,并且
  • 不访问或修改易失性对象,并且
  • 不执行同步操作(1.10)或原子操作(第 29 条)

可以假定由实现终止。[ 注意:这旨在允许编译器转换,例如删除空循环,即使无法证明终止也是如此。——尾注]

编辑:

这篇有见地的文章谈到了标准文本

不幸的是,没有使用“未定义的行为”一词。但是,只要标准说“编译器可能假定 P”,就暗示具有非 P 属性的程序具有未定义的语义。

这是正确的,编译器是否允许为上述程序打印“Bye”?


这里有一个更有洞察力的线程,它是关于对 C 的类似更改,由完成上述链接文章的 Guy 开始。在其他有用的事实中,他们提出了一个似乎也适用于 C++0x 的解决方案(更新:这将不再适用于 n3225 - 见下文!)

endless:
  goto endless;

似乎不允许编译器对其进行优化,因为它不是循环,而是跳转。另一个人总结了 C++0x 和 C201X 中的提议更改

通过编写循环,程序员要么断言循环做了一些具有可见行为的事情(执行 I/O、访问易失性对象,或者执行同步或原子操作), 要么它最终终止。如果我通过编写一个没有副作用的无限循环来违反该假设,那么我就是在对编译器撒谎,并且我的程序的行为是未定义的。(如果我很幸运,编译器可能会警告我。)该语言不提供(不再提供?)一种在没有可见行为的情况下表达无限循环的方法。


更新 3.1.2011 与 n3225:委员会将文本移至 1.10/24 并说

实现可能假设任何线程最终都会执行以下操作之一:

  • 终止,
  • 调用库 I/O 函数,
  • 访问或修改 volatile 对象,或
  • 执行同步操作或原子操作。

这个goto技巧将不再起作用!

4

8 回答 8

46

对我来说,相关的理由是:

这旨在允许编译器转换,例如删除空循环,即使无法证明终止。

据推测,这是因为机械地证明终止是困难的,并且无法证明终止阻碍了编译器,否则编译器可能会进行有用的转换,例如将非依赖操作从循环之前移动到之后,反之亦然,在一个线程中执行循环后操作,而循环在另一个中执行,依此类推。如果没有这些转换,循环可能会在等待一个线程完成所述循环时阻塞所有其他线程。(我松散地使用“线程”来表示任何形式的并行处理,包括单独的 VLIW 指令流。)

编辑:愚蠢的例子:

while (complicated_condition()) {
    x = complicated_but_externally_invisible_operation(x);
}
complex_io_operation();
cout << "Results:" << endl;
cout << x << endl;

complex_io_operation在这里,一个线程执行而另一个线程执行循环中的所有复杂计算会更快。但是如果没有您引用的子句,编译器必须证明两件事才能进行优化:1)complex_io_operation()不依赖于循环的结果,2)循环将终止。证明 1) 很容易,证明 2) 是停机问题。使用该子句,它可以假设循环终止并获得并行化胜利。

我还想象设计者认为生产代码中发生无限循环的情况非常罕见,通常是事件驱动的循环以某种方式访问​​ I/O。结果,他们悲观了罕见的情况(无限循环),转而优化更常见的情况(非无限,但难以机械地证明非无限循环)。

然而,这确实意味着学习示例中使用的无限循环将因此受到影响,并且会在初学者代码中引发陷阱。我不能说这完全是一件好事。

编辑:关于您现在链接的有见地的文章,我想说“编译器可能假设 X 关于程序”在逻辑上等同于“如果程序不满足 X,则行为未定义”。我们可以这样证明:假设存在一个不满足性质 X 的程序。该程序的行为将在哪里定义?该标准仅定义假设属性 X 为真的行为。尽管标准没有明确声明行为未定义,但它已通过省略声明它未定义。

考虑一个类似的论点:“编译器可能假设变量 x 在序列点之间最多只分配一次”等价于“在序列点之间多次分配给 x 是未定义的”。

于 2010-08-28T22:01:26.160 回答
36

有人对为什么有必要允许这样做有很好的解释吗?

是的,Hans Boehm 在N1528 中为此提供了一个基本原理:Why undefined behavior for无限循环?,虽然这是 WG14 文档,但其基本原理也适用于 C++,并且该文档同时引用了 WG14 和 WG21:

正如 N1509 正确指出的那样,当前草案本质上为 6.8.5p6 中的无限循环提供了未定义的行为。这样做的一个主要问题是它允许代码在潜在的非终止循环中移动。例如,假设我们有以下循环,其中 count 和 count2 是全局变量(或已获取其地址),而 p 是一个局部变量,其地址尚未被获取:

for (p = q; p != 0; p = p -> next) {
    ++count;
}
for (p = q; p != 0; p = p -> next) {
    ++count2;
}

这两个循环可以合并并替换为以下循环吗?

for (p = q; p != 0; p = p -> next) {
        ++count;
        ++count2;
}

如果没有 6.8.5p6 中对无限循环的特殊规定,这将是不允许的:如果第一个循环没有终止,因为 q 指向一个循环列表,则原始循环永远不会写入 count2。因此它可以与另一个访问或更新 count2 的线程并行运行。尽管存在无限循环,但对于确实访问 count2 的转换版本来说,这不再安全。因此,转换可能会引入数据竞争。

在这种情况下,编译器不太可能证明循环终止;它必须理解 q 指向一个非循环列表,我认为这超出了大多数主流编译器的能力,而且如果没有整个程序信息,通常是不可能的。

非终止循环施加的限制是对编译器无法证明终止的终止循环的优化以及对实际非终止循环的优化的限制。前者比后者更常见,并且通常更有趣的优化。

显然还有带有整数循环变量的 for 循环,其中编译器很难证明终止,因此编译器很难在没有 6.8.5p6 的情况下重组循环。甚至像

for (i = 1; i != 15; i += 2)

或者

for (i = 1; i <= 10; i += j)

处理起来似乎不简单。(在前一种情况下,需要一些基本的数论来证明终止,在后一种情况下,我们需要知道 j 的可能值才能做到这一点。无符号整数的环绕可能会使某些推理进一步复杂化。 )

这个问题似乎适用于几乎所有循环重组转换,包括编译器并行化和缓存优化转换,这两者都可能变得越来越重要,并且对于数字代码已经很重要。为了能够以最自然的方式编写无限循环,这似乎可能会变成一笔可观的成本,尤其是因为我们大多数人很少故意编写无限循环。

与 C 的一个主要区别是C11 提供了一个例外来控制与 C++ 不同的常量表达式,并使您的特定示例在 C11 中得到良好定义。

于 2015-06-22T19:22:47.913 回答
16

我认为正确的解释是您的编辑:空的无限循环是未定义的行为。

我不会说这是特别直观的行为,但这种解释比另一种解释更有意义,编译器被任意允许忽略无限循环而不调用 UB。

如果无限循环是 UB,它只是意味着非终止程序被认为没有意义:根据 C++0x,它们没有语义。

这也确实有一定的意义。它们是一种特殊情况,其中一些副作用不再发生(例如,从未从 中返回任何内容main),并且由于必须保留无限循环而阻碍了许多编译器优化。例如,如果循环没有副作用,则在循环中移动计算是完全有效的,因为最终,无论如何都会执行计算。但是如果循环永远不会终止,我们就不能安全地重新排列代码,因为我们可能只是在更改程序挂起之前实际执行的操作。除非我们将挂起的程序视为 UB,否则就是这样。

于 2010-08-29T04:44:44.510 回答
10

相关问题是允许编译器重新排序副作用不冲突的代码。即使编译器为无限循环生成非终止机器代码,也可能会出现令人惊讶的执行顺序。

我相信这是正确的做法。语言规范定义了强制执行顺序的方法。如果您想要一个无法重新排序的无限循环,请写下:

volatile int dummy_side_effect;

while (1) {
    dummy_side_effect = 0;
}

printf("Never prints.\n");
于 2010-08-29T05:20:17.440 回答
8

我认为这与这种类型的问题类似,它引用了另一个线程。优化有时可以删除空循环。

于 2010-08-28T21:47:05.137 回答
1

我认为这个问题可能最好表述为“如果后面的一段代码不依赖于前面的一段代码,并且前面的一段代码对系统的任何其他部分没有副作用,编译器的输出可以在执行前者之前、之后或混合执行后面的代码,即使前者包含循环,也不考虑前面的代码何时或是否真正完成。例如,编译器可以重写:

void testfermat(int n)
{
  诠释 a=1,b=1,c=1;
  而(pow(a,n)+pow(b,n) != pow(c,n))
  {
    如果 (b > a) a++; 否则如果 (c > b) {a=1; b++}; 否则 {a=1; b=1;c++};
  }
  printf("结果是");
  printf("%d/%d/%d", a,b,c);
}

作为

void testfermat(int n)
{
  如果(fork_is_first_thread())
  {
    诠释 a=1,b=1,c=1;
    而(pow(a,n)+pow(b,n) != pow(c,n))
    {
      如果 (b > a) a++; 否则如果 (c > b) {a=1; b++}; 否则 {a=1; b=1;c++};
    }
    signal_other_thread_and_die();
  }
  else // 第二个线程
  {
    printf("结果是");
    wait_for_other_thread();
  }
  printf("%d/%d/%d", a,b,c);
}

通常不是不合理的,尽管我可能会担心:

  整数=0;
  对于 (i=0; num_reps > i; i++)
  {
    update_progress_bar(i);
    总计+=do_something_slow_with_no_side_effects(i);
  }
  显示结果(总计);

会成为

  整数=0;
  如果(fork_is_first_thread())
  {
    对于 (i=0; num_reps > i; i++)
      总计+=do_something_slow_with_no_side_effects(i);
    signal_other_thread_and_die();
  }
  别的
  {
    对于 (i=0; num_reps > i; i++)
      update_progress_bar(i);
    wait_for_other_thread();
  }
  显示结果(总计);

通过让一个 CPU 处理计算,另一个处理进度条更新,重写将提高效率。不幸的是,它会使进度条更新变得不那么有用。

于 2010-09-01T19:20:38.357 回答
0

如果它根本是一个无限循环,那么编译器对于非平凡的情况是无法确定的。

在不同的情况下,您的优化器可能会为您的代码达到更好的复杂度等级(例如,它是 O(n^2) 并且您在优化后得到 O(n) 或 O(1))。

因此,在 C++ 标准中包含不允许删除无限循环的规则将使许多优化变得不可能。大多数人不希望这样。我认为这很好地回答了你的问题。


另一件事:我从来没有见过任何有效的例子,你需要一个什么都不做的无限循环。

我听说过的一个例子是一个丑陋的黑客攻击,应该以其他方式解决:它是关于嵌入式系统,触发重置的唯一方法是冻结设备,以便看门狗自动重启它。

如果你知道任何有效/好的例子,你需要一个什么都不做的无限循环,请告诉我。

于 2010-09-03T18:36:04.820 回答
-1

我认为值得指出的是,除了它们通过非易失性、非同步变量与其他线程交互之外,无限循环现在可以使用新编译器产生不正确的行为。

换句话说,让你的全局变量变得易变——以及通过指针/引用传递到这样一个循环中的参数。

于 2011-08-25T10:13:21.747 回答