【问题标题】:Optimizing away a "while(1);" in C++0x优化“while(1);”在 C++0x 中
【发布时间】:2011-04-05 06:54:09
【问题描述】:

已更新,见下文!

我听说过 C++0x 允许编译器为以下 sn-p 打印“Hello”

#include <iostream>

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

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

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

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

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

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

编辑:

This insightful article 谈到标准文本

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

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


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

endless:
  goto endless;

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

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


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

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

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

goto 技巧将不再起作用了!

【问题讨论】:

  • while(1) { MyMysteriousFunction(); } 必须在不知道那个神秘函数的定义的情况下独立编译,对吧?那么我们如何确定它是否调用了任何库 I/O 函数呢?换句话说:第一个项目符号肯定可以表述为不调用函数
  • @Daniel:如果它可以访问函数的定义,它可以证明很多事情。有过程间优化之类的东西。
  • 现在,在 C++03 中,是否允许编译器将 int x = 1; for(int i = 0; i &lt; 10; ++i) do_something(&amp;i); x++; 更改为 for(int i = 0; i &lt; 10; ++i) do_something(&amp;i); int x = 2;?或者可能是另一种方式,在循环之前将x 初始化为2。它可以告诉do_something 不关心x 的值,所以它是一个非常安全的优化,if do_something 不会导致i 的值改变,这样你最终会陷入无限循环。
  • 那么这是否意味着main() { start_daemon_thread(); while(1) { sleep(1000); } } 可能会立即退出,而不是在后台线程中运行我的守护进程?
  • “这篇有见地的文章”假设特定行为是未定义行为,仅仅是因为没有明确的、已定义的行为。这是一个错误的假设。一般而言,当标准开放有限数量的行为时,实现必须选择其中任何一个(未指定行为)。这不必是确定性的。无所事事的循环是否终止可以说是一个布尔选择;要么有,要么没有。不允许做其他事情。

标签: c++ loops c++11 language-lawyer undefined-behavior


【解决方案1】:

我认为这与这种类型的question 类似,它引用了另一个thread。优化有时可以去除空循环。

【讨论】:

  • 好问题。似乎那个人正是本段允许编译器引起的问题。在其中一个答案的链接讨论中,写到 “不幸的是,没有使用‘未定义的行为’这个词。但是,只要标准说‘编译器可能假定 P’,就暗示程序具有属性 not-P 具有未定义的语义。” 。这让我很惊讶。这是否意味着我上面的示例程序具有未定义的行为,并且可能会突然出现段错误?
  • @Johannes:文本“可能假设”不会出现在我手头的草稿中的其他任何地方,并且“可能假设”只会出现几次。尽管我使用无法跨换行符匹配的搜索功能对此进行了检查,但我可能错过了一些。所以我不确定作者的概括是否符合证据作为一名数学家,我必须承认论证的逻辑,即如果编译器假设某些错误,那么通常它可能会推断出任何东西。 ..
  • ...允许编译器对程序的推理存在矛盾肯定非常强烈地暗示了 UB,因为特别是它允许编译器对于任何 X 推断该程序等价于 X。肯定允许编译器推断 允许它执行。我也同意作者的观点,如果 UB 是有意使用的,则应明确说明,如果不是有意使用,则规范文本是错误的,应予以修复(也许通过规范语言的等价物,“编译器可能会替换使用无效的代码循环”,我不确定)。
  • @SteveJessop:如果简单地说任何一段代码的执行——包括无限循环——可能会被推迟到这段代码所做的某事会影响可观察程序时,你会怎么想行为,并且出于该规则的目的,执行一段代码所需的时间——即使是无限的——也不是“可观察到的副作用”。如果编译器可以证明如果变量没有持有某个值,循环就无法退出,则该变量可能会被视为持有该值,甚至也可以表明循环无法在持有该值的情况下退出。
  • @supercat:正如你所说的那样,我认为它不会改善事情。如果循环可证明永远不会退出,那么对于任何对象X 和位模式x,编译器可以证明没有X 保持位模式x 循环不会退出。这是空洞的真实。所以X 可以被认为持有任何位模式,这和UB 一样糟糕,因为错误的Xx 会迅速导致一些。所以我相信你需要更精确地表达你的标准。很难谈论无限循环“结束时”会发生什么,并表明它等同于一些有限操作。
【解决方案2】:

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

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

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

【讨论】:

  • “证明 1) 非常简单” - 事实上,它不是立即从 3 个条件中得出的,允许编译器在 Johannes 所询问的条款下假设循环终止吗?我认为它们相当于,“循环没有可观察到的效果,除了可能永远旋转”,并且该子句确保“永远旋转”不是此类循环的保证行为。
  • @Steve:如果循环不终止,这很容易;但是如果循环确实终止了,那么它可能会有影响complex_io_operation 处理的重要行为。
  • 哎呀,是的,我错过了它可能会修改 IO 操作中使用的非易失性本地/别名/任何内容。所以你是对的:虽然不一定遵循,但在很多情况下编译器可以并且确实证明没有发生这种修改。
  • “但是,这确实意味着学习示例中使用的无限循环将因此受到影响,并且会在初学者代码中引发陷阱。我不能说这完全是一件好事。”只需在关闭优化的情况下进行编译,它仍然可以工作
  • @supercat:您描述的是实践中会发生什么,但这不是标准草案所要求的。我们不能假设编译器不知道循环是否会终止。如果编译器确实知道循环不会终止,它可以做任何它喜欢的事情。 DS9K 将为任何无 I/O 等无限循环创建鼻恶魔。(因此,DS9K 解决了停机问题。)
【解决方案3】:

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

我不会说这是特别直观的行为,但这种解释比另一种解释更有意义,编译器可以在不调用 UB 的情况下任意忽略无限循环。

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

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

【讨论】:

  • “空的无限循环是未定义的行为”?艾伦·图灵会请求不同,但只有当他在坟墓里转过身时。
  • @Donal:我从来没有说过它在图灵机中的语义。我们正在讨论在 C++ 中没有副作用的无限循环的语义。当我读到它时,C++0x 选择说这样的循环是未定义的。
  • 空的无限循环是愚蠢的,没有理由为它们制定特殊的规则。该规则旨在处理无限(希望不是无限)持续时间的有用循环,这些循环会计算将来需要但不是立即需要的东西。
  • 这是否意味着 C++0x 不适合嵌入式设备?几乎所有嵌入式设备都是非终止的,并且在一个大胖子while(1){...} 内完成它们的工作。他们甚至经常使用while(1); 来调用看门狗辅助重置。
  • @vsz:第一种形式很好。无限循环的定义非常明确,只要它们具有某种可观察的行为。第二种形式比较棘手,但我可以想到两个非常简单的方法:(1)针对嵌入式设备的编译器可以选择在这种情况下定义更严格的行为,或者(2)您创建一个调用一些虚拟库函数的主体.只要编译器不知道那个函数做了什么,它就必须假设它可能有一些副作用,所以它不能搞乱循环。
【解决方案4】:

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

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

volatile int dummy_side_effect;

while (1) {
    dummy_side_effect = 0;
}

printf("Never prints.\n");

【讨论】:

  • @JohannesSchaub-litb:如果循环——无论是否无限——在执行期间不读取或写入任何易失性变量,并且不调用任何可能这样做的函数,则编译器是免费的推迟循环的任何部分,直到第一次尝试访问其中计算的内容。给定unsigned int dummy; while(1){dummy++;} fprintf(stderror,"Hey\r\n"); fprintf(stderror,"Result was %u\r\n",dummy);,第一个fprintf 可以执行,但第二个不能执行(编译器可以在两个fprintf 之间移动dummy 的计算,但不能超过打印其值的那个)。
【解决方案5】:

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

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 处理计算,另一个处理进度条更新,重写将提高效率。不幸的是,这会使进度条更新变得没有应有的用处。

【讨论】:

  • 我认为你的进度条案例不能分开,因为显示进度条是一个库I/O调用。优化不应以这种方式改变可见行为。
  • @Philip Potter:如果缓慢的例程有副作用,那肯定是真的。在我之前的示例中,如果没有,那将毫无意义,因此我对其进行了更改。我对规范的解释是,允许系统推迟执行慢速代码,直到其效果(除了执行所需的时间)变得可见,即 show_result() 调用。如果进度条代码使用了运行总数,或者至少假装这样做,那将迫使它与慢代码同步。
  • 这解释了所有从 0 到 100 的快速进度条,然后挂了很长时间;)
【解决方案6】:

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

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

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


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

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

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

【讨论】:

  • 您可能需要无限循环的示例:出于性能原因您不想休眠并且所有代码都挂起一两个中断的嵌入式系统?
  • @JCx 在标准 C 中,中断应设置主循环检查的标志,因此在设置标志的情况下主循环将具有可观察的行为。在中断中运行大量代码是不可移植的。
【解决方案7】:

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

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

【讨论】:

  • 如果它们正在与其他线程交互,则不应使它们成为易失性的,使它们成为原子的或用锁保护它们。
  • Thjs 是个糟糕的建议。将它们设为volatile 既没有必要也不够充分,而且会极大地损害性能。
【解决方案8】:

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

是的,Hans Boehm 在N1528: Why undefined behavior for infinite loops? 中为此提供了一个基本原理,虽然这是 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。因此 它可以与另一个访问或访问的线程并行运行 更新计数2。转换后的版本不再安全 尽管无限循环,它确实访问了 count2 。就这样 转换可能会引入数据竞争。

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

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

显然也有带有整数循环变量的 for 循环 编译器很难证明终止,并且 因此编译器很难重构循环 没有 6.8.5p6。甚至像

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

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

似乎处理起来并不简单。 (在前一种情况下,一些基本数字 需要理论来证明终止,在后一种情况下,我们需要 了解一些关于 j 的可能值的信息。环绕 对于无符号整数可能会使某些推理进一步复杂化。)

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

与 C 的一个主要区别是 C11 provides an exception for controlling expressions that are constant expressions 与 C++ 不同,并使您的特定示例在 C11 中得到良好定义。

【讨论】:

  • 是否有任何安全和有用的优化可以由当前语言促进,而这些优化不能通过说“如果循环的终止取决于任何对象的状态,所需的时间执行循环不被视为可观察到的副作用,即使这样的时间恰好是无限的”。鉴于 do { x = slowFunctionWithNoSideEffects(x);} while(x != 23); 在不依赖于 x 的循环之后提升代码似乎安全合理,但允许编译器在此类代码中假设 x==23 似乎比有用更危险。
猜你喜欢
  • 2011-02-10
  • 1970-01-01
  • 2016-06-29
  • 1970-01-01
  • 2020-06-24
  • 2018-03-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多