优化了“ while(1);” 在C ++ 0x中


153

更新,请参见下文!

我听说过,C ++ 0x允许编译器为以下代码段打印“ 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条)

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

编辑:

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

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

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


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

endless:
  goto endless;

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

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


在2011年3月31日更新了n3225:委员会将文本移至1.10 / 24并说

该实现可以假定任何线程最终都将执行以下操作之一:

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

goto把戏,工作了!


4
while(1) { MyMysteriousFunction(); }在不知道该神秘函数的定义的情况下必须可以独立编译,对吗?那么我们如何确定它是否对任何库I / O函数进行调用?换句话说:确保第一个项目符号可以表达为不会调用function
Daniel Earwicker 2010年

19
@Daniel:如果可以访问该函数的定义,则可以证明很多事情。有过程间优化之类的东西。
Potatoswatter

3
现在,在C ++ 03中,是否允许编译器更改int x = 1; for(int i = 0; i < 10; ++i) do_something(&i); x++;for(int i = 0; i < 10; ++i) do_something(&i); int x = 2;?或者可能以另一种方式,将x其初始化为2循环之前。它可以告诉您do_something不在乎的值x,因此,如果 do_something不引起值的i改变而导致最终陷入无限循环,它是完全安全的优化。
丹尼斯·齐克福斯

4
那么这是否意味着main() { start_daemon_thread(); while(1) { sleep(1000); } }可能立即退出而不是在后台线程中运行我的守护进程?
加布

2
“这篇有见地的文章”假定仅由于没有明确的定义的行为,所以特定行为是“未定义的行为”。这是一个错误的假设。通常,当标准开放有限数量的行为时,实现必须选择任何行为(未指定行为)。这不必是确定的。无为循环是否终止可以说是布尔选择。它会或不会。禁止做其他事情。
MSalters 2010年

Answers:


33

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

是的,汉斯·勃姆(Hans Boehm)在N1528中为此提供了理由:为什么无限循环会出现未定义的行为?,尽管这是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中得到了很好的定义。


1
是否存在本语言所促成的任何安全和有用的优化,而这些优化也不会像这样说:“如果循环的终止取决于任何对象的状态,则执行循环所需的时间不被视为可观察到的副作用,即使这种时间恰好是无限的”。给定do { x = slowFunctionWithNoSideEffects(x);} while(x != 23);不依赖循环的提升代码,这x看起来是安全合理的,但是允许编译器采用x==23这样的代码似乎危险而不是有用。
超级猫

47

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

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

据推测,这是因为难以机械地证明终止,并且无法证明终止会妨碍编译器,否则编译器可能会进行有用的转换,例如将非依赖操作从循环前移到循环后,反之亦然,在一个线程中执行循环后操作,同时循环在另一个循环中执行,依此类推。如果没有这些转换,则循环可能会阻塞所有其他线程,同时它们等待一个线程完成所述循环。(我宽松地使用“线程”来表示任何形式的并行处理,包括单独的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为true的情况下定义行为。尽管标准没有明确声明行为未定义,但它已通过遗漏声明了行为未定义。

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


“证明1)非常简单”-实际上,它不是紧随Johannes要求的条件允许编译器假定循环终止的3个条件吗?我认为它们的含义是,“循环没有观察到的效果,除非可能永远旋转”,并且该子句确保“永久旋转”不能保证此类循环的行为。
史蒂夫·杰索普

@Steve:如果循环不终止很容易;但是如果循环确实终止了,那么它可能会产生不平凡的行为,从而影响到complex_io_operation
菲利普·波特

糟糕,是的,我想念它可能会修改IO op中使用的非易失性本地/别名/其他内容。因此,您是对的:尽管不一定遵循,但在许多情况下编译器可以并且确实证明没有发生此类修改。
史蒂夫·杰索普

“但是,这确实意味着学习示例中使用的无限循环会因此而受苦,并且会增加初学者代码中的陷阱。我不能说这完全是一件好事。” 只需编译优化即可,它仍然可以正常工作
KitsuneYMG

1
@supercat:您所描述的是实际情况,但这不是标准草案所要求的。我们不能假设编译器不知道循环是否会终止。如果编译器知道循环将不会终止,它可以为所欲为。该DS9K 用于创建鼻恶魔任何不带I / O等无限循环(因此,DS9K解决了停机问题。)
菲利普·波特

15

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

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

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

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


7
“空的无限循环是未定义的行为”?艾伦·图灵(Alan Turing)可能会有所不同,但只有当他在坟墓中翻身时才可以。
Donal Fellows 2010年

11
@Donal:我从没在图灵机上谈论它的语义。我们正在讨论C ++中没有副作用的无限循环的语义。在我阅读本书时,C ++ 0x选择说这样的循环是未定义的。
jalf

空的无限循环是很愚蠢的,没有理由为其设置特殊的规则。该规则旨在处理无界(希望不是无限)持续时间的有用循环,这些循环计算出将来将需要但并非立即需要的东西。
超级猫

1
这是否意味着C ++ 0x不适合嵌入式设备?几乎所有的嵌入式设备都是无止境的,并且在很大的范围内发挥作用while(1){...}。它们甚至经常while(1);用来调用看门狗辅助的复位。
vsz

1
@vsz:第一种形式很好。只要无限循环具有某种可观察的行为,它们就可以很好地定义。第二种形式比较棘手,但是我可以想到两种非常简单的解决方法:(1)针对嵌入式设备的编译器可以在这种情况下选择定义更严格的行为,或者(2)创建一个调用一些伪库函数的主体。只要编译器不知道该函数的功能,就必须假定它可能会产生一些副作用,因此它不会弄乱循环。
2014年

8

我认为这与此类问题类似,它引用了另一个主题。优化有时可以消除空循环。


3
好问题。好像那个家伙确实有这个段落允许编译器引起的问题。在通过答案之一进行的链接讨论中,写道:“不幸的是,未使用单词“未定义行为”。但是,只要标准说“编译器可以假定为P”,就意味着程序具有属性not-P具有未定义的语义。” 。这让我感到惊讶。这是否意味着我上面的示例程序具有未定义的行为,并且可能会在所有地方出现段错误?
Johannes Schaub-litb

@约翰内斯:在我必须提交的草案中,“可能会假设”文本不会出现,而“可能会假设”仅出现几次。尽管我使用搜索功能检查了此功能,但该功能无法在换行符之间进行匹配,所以我可能会错过一些功能。因此,我不确定证据是否能证明作者的概括性,作为数学家,我不得不承认该论点的逻辑,即如果编译器假定某些错误,则通常可以推断出任何东西……
Steve Jessop

...允许被编译的推理矛盾有关程序肯定在UB强烈暗示,因为尤其是它让编译器,对于任何X,推断出该方案相当于X.当然允许编译器推断是允许它这样。我也同意作者的看法,即如果要使用UB,则应明确说明,如果不打算使用UB,则说明文本是错误的,应予以修复(也许用相当于spec的语言,“编译器可以替换循环无效的代码”(我不确定)。
史蒂夫·杰索普

@SteveJessop:简单地说,任何代码段(包括无限循环)的执行可能会推迟到某段代码会影响可观察到的程序行为的时候,您会怎么想?规则来说,执行一段代码(即使是无限的)所需的时间也不是“可观察到的副作用”。如果编译器可以证明没有变量保存某个值就无法退出循环,那么即使该变量也可以表明该循环保存该值无法退出,也可以认为该变量保存了该值。
超级猫

@supercat:正如您所说的结论,我认为它不会改善任何情况。如果证明循环永远不会退出,那么对于任何对象X和位模式x,编译器都可以证明,如果不X持有bit-pattern ,循环就不会退出x。完全是虚假的。因此X,可以认为它持有任何位模式,在某种程度上来说,它X和UB一样糟糕,因为它错误并x会迅速引起某些错误。因此,我认为您的标准语需要更加精确。很难说出“无限循环结束时”发生的情况,并证明它等效于某些有限运算。
史蒂夫·杰索普

8

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

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

volatile int dummy_side_effect;

while (1) {
    dummy_side_effect = 0;
}

printf("Never prints.\n");

2
@ JohannesSchaub-litb:如果循环(无论是否无限)在执行期间不读取或写入任何易失性变量,并且不调用任何可能这样做的函数,则编译器可以自由地将循环的任何部分推迟到首先尝试访问其中计算的内容。给定unsigned int dummy; while(1){dummy++;} fprintf(stderror,"Hey\r\n"); fprintf(stderror,"Result was %u\r\n",dummy);,第一个fprintf可以执行,但是第二个则不能执行(编译器可以在dummy两者之间移动计算fprintf,但不能超过显示其值的那个)。
超级猫

1

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

无效testfermat(int n)
{
  整数a = 1,b = 1,c = 1;
  while(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);
}

无效testfermat(int n)
{
  如果(fork_is_first_thread())
  {
    整数a = 1,b = 1,c = 1;
    while(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);
}

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

  int total = 0;
  对于(i = 0; num_reps> i; i ++)
  {
    update_progress_bar(i);
    total + = do_something_slow_with_no_side_effects(i);
  }
  show_result(总计);

会成为

  int total = 0;
  如果(fork_is_first_thread())
  {
    对于(i = 0; num_reps> i; i ++)
      total + = 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();
  }
  show_result(总计);

通过让一个CPU处理计算,而另一个CPU处理进度条更新,重写将提高效率。不幸的是,这会使进度条更新的实用性大大降低。


我认为您的进度条情况无法分开,因为显示进度条是库I / O调用。优化不应以这种方式改变可见的行为。
菲利普·波特

@菲利普·波特:如果慢的程序有副作用,那肯定是正确的。在我之前的示例中,如果没有,它将毫无意义,因此我对其进行了更改。我对规范的解释是,允许系统将慢速代码的执行推迟到其效果(而不是执行所花费的时间)可见之前,即show_result()调用。如果进度条代码使用了运行总计,或者至少假装这样做,则将迫使它与慢速代码同步。
超级猫

1
这解释了所有这些进度条,它们从0迅速上升到100,然后挂了好久;)
paulm 2016年

0

如果编译器根本不是无限循环,则对于非平凡的情况,它是不可决定的。

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

因此,要包含不允许在C ++标准中删除无限循环的规则,将使许多优化工作变得不可能。而且大多数人都不想要这个。我认为这完全可以回答您的问题。


另一件事:我从未见过任何有效的示例,其中您需要一个不执行任何操作的无限循环。

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

如果您知道任何有效/良好的示例,而您需要一个不执行任何操作的无限循环,请告诉我。


1
您可能需要无限循环的示例:一个嵌入式系统,出于性能原因,您不想在其中休眠,并且所有代码都挂在一两个中断之间?
JCx

在标准C中,@ JCx中断应设置一个主循环检查的标志,因此在设置标志的情况下,主循环将具有可观察到的行为。在中断中运行大量代码是不可移植的。
MM

-1

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

换句话说,使您的全局变量易变-以及通过指针/引用传递到这样的循环中的参数。


如果它们正在与其他线程进行交互,则不应使它们易变,使其成为原子或使用锁来保护它们。
BCoates

1
这是个糟糕的建议。制作它们volatile既不必要也不充分,并且极大地损害了性能。
David Schwartz
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.