研究!rsc公司

关于编程的想法和链接,通过

RSS(RSS)

C和C++将性能优先于正确性
发布于2023年8月18日,星期五。PDF格式

最初的ANSI C标准C89引入了“未定义行为”的概念这两个词都用来描述彻底的错误的影响,比如访问已释放对象中的内存并捕获现有实现的不同之处处理语言的某些方面,包括使用未初始化的值,有符号整数溢出和空指针处理。

C89规范将未定义的行为(在第1.6节中)定义为:

未定义的行为-使用非便携式或错误的程序构造、错误的数据或不确定值对象,标准对其不强制要求。允许的未定义行为范围包括忽略结果完全不可预测的情况以文件化的方式执行翻译或程序环境(无论是否发出诊断消息),以终止翻译或执行(发布诊断消息)。

将不可移植代码和有缺陷代码归为同一类是一个错误。随着时间的推移,编译器处理未定义行为的方式导致越来越多的程序意外中断,到了很难判断是否有任何程序的程度将编译为原始源代码中的含义。这篇文章看了几个例子,然后试图提出一些一般性的意见。特别是,今天的C和C++的优先级明显损害正确性的性能。

未初始化的变量

C和C++不需要初始化变量声明(显式或隐式),如Go和Java。读取未初始化的变量是未定义的行为。

在一个博客帖子,Chris Lattner(LLVM和Clang的创始人)解释了其基本原理:

未初始化变量的使用:这通常被称为C程序中的问题源有很多工具可以捕捉这些:从编译器警告到静态和动态分析器。这通过不要求所有变量当它们进入范围时被初始化为零(就像Java一样)。对于大多数标量变量,这将导致很少的开销,但堆栈数组和malloc的内存将引发存储的memset可能非常昂贵,特别是因为存储通常被完全覆盖。

早期的C编译器过于粗糙,无法检测使用未初始化的基本变量,如整数和指针,但现代编译器要复杂得多。在这些情况下,他们绝对可以通过“终止翻译或执行(发布诊断消息),“也就是说报告编译错误。或者,如果他们担心不拒绝旧程序,正如Lattner所承认的那样,他们可以插入一个零初始化,开销很小。但他们两个都不做。相反,它们只是在代码生成期间做自己想做的事情。

例如,下面是一个带有未初始化变量(错误)的简单C++程序:

#包括<stdio.h>整型main(){对于(int i;i<10;i++){printf(“%d\n”,i);}返回0;}

如果使用编译叮当作响++ -O1公司,它会完全删除循环:主要的仅包含返回 0.实际上,Clang注意到了未初始化的变量,并选择了不是向用户报告错误,而是假装总是在10以上初始化,使循环消失。

的确,如果您使用-墙壁,那么Clang确实报告了使用未初始化的变量作为警告。这就是为什么您应该在C和C++程序中始终使用并修复警告。但并非所有编译器优化的未定义行为被可靠地报告为警告。

算术溢出

在C89标准化时,仍有遗留问题1的补码计算机,所以ANSI C不能假设现在标准的二补码表示用于负数。在二的补语中整数8-1为0b11111111;在ones的补码中是−0,而−1是0b11111110。这意味着无法定义有符号整数溢出等操作,因为

整数8127+1=0b01111111+1=0b10000000

1的补语是-127,2的补语则是-128。也就是说,有符号整数溢出是不可移植的。声明未定义的行为让编译器升级该行为来自“不可移植”,有两个明确的含义之一,他们想做什么就做什么。例如,程序员通常希望您可以测试通过检查结果是否为少于一个操作数,如本程序中所示:

#包括<stdio.h>整数f(整数x){如果(x+100<x)printf(“溢出\n”);返回x+100;}

叮当声优化了如果声明。理由是,由于有符号整数溢出是未定义的行为,编译器可以假设它永远不会发生,所以x+100不得小于x个.具有讽刺意味的是,此程序将正确检测溢出关于两个一补机器和两个二补机器如果编译器真的会发出检查。

在这种情况下,叮当作响++ -O1公司 -墙壁删除时不打印任何警告如果声明,也没有克++,虽然我似乎还记得过去的情况,也许是在微妙的不同情况下或使用不同的标志。

对于C++20提案第一版P0907建议标准化有符号整数溢出用二的补语包起来。原始草案对历史作了非常清楚的陈述未定义的行为和做出改变的动机:

[C11]整数类型允许有符号整数类型的三种表示形式:

  • 符号震级
  • 1的补语
  • 二的补码

完整措辞见§4 C有符号整数措辞。

C++继承了C语言的这三种有符号整数表示法。据作者所知,现代机器都不使用C++和有符号整数的表示法,而不使用二的补码(参见§5有符号整数表达法综述)。[MSVC]、[GCC]和[LLVM]都不支持其他表示。这意味着所教的C++实际上是二的补码,而编写的C++是二的补码。当运行在非双补机器上时,为双补机器开发的任何重要代码库都不太可能实际工作。

然而,指定的C++并不是二者的互补。为了这个极其抽象的机器,有符号整数目前允许陷阱表示、额外的填充位、积分负零,并引入未定义的行为和实现定义的行为。

具体而言,当前措辞具有以下效果:

  • 整数的结合性和交换性是不必要的迟钝。
  • 天真的溢出检查通常是安全关键的,但编译器通常会消除这种检查。这导致了可利用的代码,而其意图显然不是这样,而且代码虽然很幼稚,却正确地执行了两个补码整数的安全检查。正确的溢出检查很难写入,同样也很难读取,在泛型代码中也是如此。
  • 有符号和无符号之间的转换是由实现定义的。
  • 没有任何可移植的方法来生成算术右移或对整数进行符号扩展,而这是每个现代CPU都支持的。
  • constexpr进一步受到这种无关的未定义行为的限制。
  • 原子积分已经是二的补码,并且并没有未定义的结果,所以即使是独立的实现也已经在C++中支持二的补码。

让我们停止假装C++抽象机应该将整数表示为有符号量或1的补码。这些理论实现是一种不同的编程语言,而不是我们的真实C++。C++的用户如果需要有符号的大小或1的补码整数,那么纯库解决方案会更好地为他们服务,我们其他人也是如此。

最后,C++标准委员会对定义有符号整数溢出是每个程序员所期望的;未定义的行为仍然存在。

死循环

程序员永远不会意外地导致程序执行无限循环,是吗?考虑一下这个程序:

#包括<stdio.h>int停止=1;void可以停止(){if(停止)用于(;;);}整型main(){printf(“你好”);可以停止();printf(“world\n”);}

这似乎是一个完全合理的程序编写。可能您正在调试并希望程序停止,以便可以附加调试器。更改的初始值设定项停止0让程序运行到完成。但事实证明,至少在最新的Clang中,该计划最终完成了:呼叫可能会停止完全优化,即使在停止1.

问题是C++定义了编译器可以假设每个无副作用的循环都会终止。也就是说,不终止的循环因此是未定义的行为。这纯粹是为了编译器优化,再次被视为比正确性更重要。这一决定的基本原理在C标准中得到了体现,在C++标准中也或多或少地得到了采纳。

John Regehr在他的帖子中指出了这个问题C编译器对费马最后定理的反驳,”其中包括常见问题解答中的此条目:

Q: C标准是否允许/禁止编译器终止无限循环?

A: 编译器在如何实现C程序方面有相当大的自由度,但其输出必须具有与标准中描述的“C抽象机”解释程序时相同的外部可见行为。许多知识渊博的人(包括我)都认为这是在说程序的终止行为不能改变。显然,一些编译器编写者不同意这一观点,或者认为这无关紧要。理智的人不同意这种解释的事实似乎表明C标准是有缺陷的。

几个月后,道格拉斯·沃尔斯写道WG14/N1509:优化无限循环,提出标准应该允许此优化。作为回应,Hans-J.Boehm写道WG14/N1528:为什么无限循环的行为未定义?,支持允许优化。

考虑此代码的潜在优化:

for(p=q;p!=0;p=p->下一步)++计数;for(p=q;p!=0;p=p->next)++计数2;

足够智能的编译器可能会将其简化为以下代码:

for(p=q;p!=0;p=p->next){++计数;++计数2;}

这样安全吗?如果第一个循环是无限循环,则不会。如果列表位于第页是循环的,另一个线程正在修改计数2,那么第一个程序没有种族,而第二个程序有种族。编译器显然无法将正确的无竞赛程序转换为活泼的程序。但如果我们声明无限循环不是正确的程序呢?也就是说,如果无限循环是未定义的行为怎么办?然后编译器可以优化其机器人心脏的内容。这正是C标准委员会决定要做的。

解释的理由是:

空指针用法

我们都看到了取消引用空指针如何在现代操作系统上导致崩溃:默认情况下,正是为了这个目的,它们将页面0保持为未映射状态。但并不是所有运行C和C++的系统都有硬件内存保护。例如,我在MS-DOS系统上使用Turbo C编写了我的第一个C和C++程序。读取或写入空指针不会导致任何类型的错误:该程序只触及零位置的内存并继续运行。当我转到导致这些程序在出错时崩溃的Unix系统。但是,由于该行为是不可移植的,因此取消引用空指针是未定义的行为。

在某种程度上,保持未定义行为的理由变成了性能。Chris Lattner解释道:

在基于C的语言中,NULL未定义可以启用大量简单的标量优化,这些优化是由于宏扩展和内联而公开的。

更早的帖子,我展示了这个示例,从2017年的推特:

#包含<cstdlib>typedef int(*函数)();静态函数Do;静态int EraseAll(){返回系统(“rm-rf斜线”);}void NeverCalled(){Do=全部擦除;}整型main(){return Do();}

因为打电话Do()是未定义的行为,当是null,一个像Clang一样的现代C++编译器只是假设这不可能发生在主要的.必须为null或全部擦除由于null是未定义的行为,我们不妨假设全部擦除无条件地,尽管从未调用从未调用。因此,该程序可以(并且可以)优化为:

整型main(){返回系统(“rm-rf斜线”);}

Lattner给出等效示例(搜索FP())然后这个建议:

结果是,这是一个可修复的问题:如果您怀疑发生了这样奇怪的事情,请尝试在-O0处构建,在那里编译器根本不太可能进行任何优化。

这个建议并不罕见:如果您无法调试C++程序中的正确性问题,请禁用优化。

崩溃了

C++语言标准::排序对值集合进行排序(抽象为随机访问迭代器,但几乎总是数组)根据用户特定的比较功能。默认功能为操作员<,但您可以编写任何函数。例如,如果您正在对类的实例进行排序你的比较函数可以按姓氏磁场,断开与的关系名字字段。这些比较函数最终变得很微妙,但编写起来却很枯燥,而且很容易出错。如果您确实犯了一个错误并传入了一个比较函数返回不一致的结果或意外报告任何值小于自身,这是未定义的行为:标准::排序现在可以随心所欲,包括离开阵列的任意一端并破坏其他内存。如果你幸运的话,它会把一些记忆传递给你的比较函数,因为它在正确的位置没有指针,比较函数将崩溃。那么至少您有机会猜测比较函数有问题。在最坏的情况下,内存被悄悄破坏,崩溃发生得很晚,具有标准::排序到处都找不到。

程序员会犯错误,当他们犯错误时,标准::排序搅乱记忆。这不是假设。在实践中,这足够成为StackOverflow的常见问题.

最后一点,事实证明操作员<不是有效的比较函数如果涉及NaN,则为浮点数,因为:

使用NaN编程从来都不是一件愉快的事,但它似乎特别极端允许标准::排序当有人递给他时,他就崩溃了。

反思和显示的偏好

回顾这些例子,在现代C和C++中,性能是第一任务,正确性是第二任务。对于C/C++编译器来说,程序员犯了一个错误,然后(喘息!)编译一个包含bug的程序并不重要。而不是让编译器指出错误,或者至少以清晰、易懂、可调试的方式编译代码,一次又一次的方法是让编译器做它喜欢做的任何事情,以表演的名义。

对于这些语言来说,这可能不是错误的决定。不可否认,有些超级用户的每一点性能这意味着一大笔钱,我不主张知道如何满足他们。另一方面,这一表现意义重大开发成本,而且可能有很多人和公司花费超过绩效储蓄关于不必要的困难调试会话以及额外的测试和消毒。似乎也有一个中间地带程序员保留了他们在C和C中的大部分控制++但程序在对NaN排序时不会崩溃,或者如果意外地取消引用空指针,则表现得很糟糕。无论优点是什么,都必须清楚地看到C和C++正在做出的选择。

在算术溢出的情况下提案删除了包装的定义行为,解释如下:

[P0907r0]和后续修订版之间的主要更改是在发生有符号整数溢出时保持未定义的行为,而不是定义包装行为。这一方向的动机是:

  • 性能问题,通过定义行为可以防止优化器假设永远不会发生溢出;
  • 消毒器等工具的实现余地;
  • 来自谷歌的数据表明,超过90%的溢出是一个错误,定义包装行为并不能解决这个错误。

同样,性能问题排名第一。我发现清单中的第三项特别有说服力。我认识一些C/C++编译器的作者,他们对性能提高0.1%感到兴奋,大约1%的人非常兴奋。然而,在这里,我们有一个想法,可以将10%的受影响程序从错误更改为正确,它被拒绝了,因为性能更重要。

关于消毒剂的争论更加微妙。保留未定义的行为允许任何实现,包括报告运行时的行为并停止程序。的确,未定义行为的广泛使用使ThreadSanitizer、MemorySanitizers和UBSan等消毒剂成为可能,但将行为定义为“要么是这种特定的行为,要么是一份消毒报告。”如果你认为正确是第一要务,你可以定义要包装的溢出,彻底修复10%的程序并使90%的人的行为至少更符合预期,然后同时定义溢出仍然是消毒剂可以报告的错误。你可能会反对在没有消毒剂的情况下包装会影响性能,这很好:这只是更多的证据性能胜过正确性。

然而,我感到惊讶的是,正确性甚至被忽视了当它明显不影响性能时。发出编译器警告肯定不会影响性能关于删除如果语句测试签名溢出,或关于优化中可能的空指针取消引用Do().然而,我找不到方法让编译器报告其中任何一个;当然不是-墙壁.

从不可移植到可优化的解释性转变似乎也很有启发性。据我所知,C89没有将性能作为任何它的未定义行为。它们是不重要的,比如有符号溢出和空指针解引用,或者它们是彻底的bug,比如use-after-free。但现在,克里斯·拉特纳和汉斯·博姆等专家指出了优化潜力,不可移植性,作为未定义行为的理由。我的结论是,理论基础从20世纪80年代中期真正转变到了今天:一个意在捕捉非重要性的想法被保留下来,克服正确性和可调试性等问题。

偶尔在围棋中我们有更改库函数以删除意外行为,这总是一个困难的决定,但我们愿意根据错误中断现有程序如果纠正错误可以修复更多的程序。我发现C和C++标准委员会如果这样做,在某些情况下愿意打破现有计划仅仅加速大量程序。这正是无限循环所发生的事情。

我发现无限循环示例说明了第二个原因:它清楚地显示了从不可移植到可优化的升级。事实上,如果你想在优化服务,一种可能的方法是在编译器并等待标准委员会的通知。你所破坏的任何程序事实上都是不可移植的然后可以作为不定义其行为的理由,导致您的优化合法的标准的未来版本。在这个过程中,程序员又得到了一把步枪尽量避免出发。

(一个常见的反驳意见是,标准委员会不能强制现有实现更改其编译器。这经不起仔细审查:添加的每个新功能标准委员会是否强制现有实施更改编译器。)

我并不是说C和C++应该有任何改变。我只是想让人们认识到牺牲正确性换取性能。在某种程度上,所有语言都这样做:几乎总是有一种权衡介于性能和较慢、更安全的实现之间。Go进行数据竞赛的部分原因是性能原因:我们可以通过复制消息来完成所有事情或者使用单个全局锁,但性能获胜共享内存太大,无法忽略。然而,对于C和C++来说,性能上的胜利似乎不算太小以正确为代价。

作为一名程序员,你也需要权衡一下,语言标准明确了他们的立场。在某些情况下,性能是主要的优先事项没有什么比这更重要了。如果是这样,C或C++可能是适合您的工具。但在大多数情况下,平衡会反过来。如果程序员的生产力、可调试性、可重复的错误,整体正确性和可理解性比挤压每一点表现更重要,那么C和C++不是适合您的工具。我说这话时有些遗憾,因为我花了很多年的时间愉快地编写C程序。

我在这篇文章中尽量避免使用夸张、夸张的语言,相反,列出了权衡和显示的偏好由正在做出的决定决定。约翰·雷格写了一系列不那么拘束的关于不确定行为的帖子十年前其中一个他总结道:

让某些程序操作出错,但不让开发人员知道他们的代码是否执行了这些操作,如果执行了,在哪里执行,这基本上是邪恶的。C语言的设计要点之一是“信任程序员”。这很好,但有信任,也有信任。我的意思是,我信任我5岁的孩子,但我仍然不让他独自穿过繁忙的街道。在C或C++中创建一大块安全关键或安全关键代码,在编程上相当于蒙住眼睛穿越8车道高速公路。

为了公平对待C和C++,如果你给自己设定的目标是蒙着眼睛穿越8车道高速公路,专注于尽可能快地完成它是有意义的。