【译文】C 和 C++ 优先考虑性能而非正确性

最初的 ANSI C 标准(C89)引入了 “未定义行为(undefined behavior) “的概念,它既用于描述在释放对象中访问内存等明确错误的影响,也用于捕捉现有实现在处理语言某些方面(包括使用未初始化值、有符号整数溢出和空指针处理)上存在差异的事实。

C89 规范(第 1.6 节)将未定义行为定义为

未定义的行为(undefined behavior)–在使用不可移植或错误的程序构造、错误数据或不确定值对象时的行为,标准对这些行为不作要求。允许的未定义行为包括:完全忽略情况并产生不可预知的结果、在编译或程序执行过程中以记录的方式表现出环境特征(发出或不发出诊断信息)、终止编译或执行(发出诊断信息)。

将非可移植性代码和错误代码归为一类是错误的。随着时间的推移,编译器处理未定义行为的方式导致越来越多的程序出现意外破坏,以至于很难判断任何程序是否能编译成原始源代码中的意思。本篇文章将列举几个例子,然后尝试提出一些一般性的看法。尤其是,当今的 C 和 C++ 将性能放在首位,这显然不利于程序的正确性。

未初始化变量

C 和 C++ 并不像 Go 和 Java 那样要求在声明时对变量进行初始化(显式或隐式)。读取未初始化的变量是未定义的行为。

Chris Lattner(LLVM 和 Clang 的创建者)在一篇博文中解释了其中的道理:

使用未初始化变量:这通常是 C 程序中的问题根源,有许多工具可以捕捉这些问题:从编译器警告到静态和动态分析器。这种情况下,所有变量在进入作用域时都无需初始化为零(Java 就是这样做的),从而提高了性能。对于大多数标量变量来说,这几乎不会造成什么开销,但堆栈数组和 malloc’d 内存会导致存储空间的 memset,这可能会造成相当大的损失,尤其是因为存储空间通常会被完全覆写。

早期的 C 语言编译器过于简陋,无法检测整数和指针等未初始化基本变量的使用情况,但现代编译器的复杂程度大大提高。在这种情况下,编译器完全可以通过 “终止编译或执行(并发出诊断信息)”来做出反应,也就是报告编译错误。或者,如果它们担心不拒绝旧程序,也可以插入一个零初始化,正如 Lattner 所承认的那样,开销很小。但他们并没有这样做。相反,他们只是在代码生成过程中随心所欲。

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

#include <stdio.h>

int main() {
    for(int i; i < 10; i++) {
        printf("%d\n", i);
    }
    return 0;
}

如果使用 clang++ -O1 进行编译,就会完全删除循环:main 只包含返回值 0。实际上,Clang 已经注意到了未初始化的变量,并选择不向用户报错,而是假装 i 的初始化值始终高于 10,从而使循环消失。

的确,如果使用 -Wall 编译,Clang 会以警告的形式报告未初始化变量的使用。这就是为什么在编译 C 和 C++ 程序时应始终使用并修复警告的原因。但并非所有经过编译器优化的未定义行为都会被可靠地报告为警告。

算术溢出

在 C89 标准化时,仍有传统的反码计算机,因此 ANSI C 无法使用现在标准的补码表示负数。在补码中,int8 -1是0b11111111;在反码中是-0,而-1是0b11111110。这意味着无法定义带符号整数溢出等操作,因为

int8 127+1 = 0b01111111+1 = 0b10000000

在 反码中为 -127,但在补码中为 -128。也就是说,有符号整数溢出是不可移植的。声明其为未定义行为,可以让编译器将其从 “不可移植”(有两种明确含义)升级为他们想做的任何事情。例如,程序员通常希望通过检查结果是否小于其中一个操作数来测试有符号整数溢出,就像这个程序一样:

#include <stdio.h>

int f(int x) {
    if(x+100 < x)
        printf("overflow\n");
    return x+100;
}

Clang 优化了 if 语句。其理由是,由于带符号整数溢出是未定义的行为,编译器可以假定它从未发生过,因此 x+100 必须永远不小于 x。具有讽刺意味的是,如果编译器真的会发出检查,那么这个程序在反码和补码机器上都能正确检测到溢出。

在这种情况下,clang++ -O1 -Wall 在删除 if 语句时不会打印任何警告,g++ 也不会,不过我记得它似乎曾经打印过警告,也许是在微妙不同的情况下或使用不同的标志。

对于 C++20,提案 P0907 的第一版建议将带符号整数溢出以补码包装标准化。最初的草案非常清楚地说明了未定义行为的历史和做出改变的动机:

[C11] 整数类型允许有符号整数类型有三种表示方法:

  • 带符号量
  • 反码
  • 补码

完整措辞请参见§4 C 带符号整数措辞。

C++ 继承了 C 语言的这三种带符号整数表示法。据笔者所知,没有一台现代机器同时使用 C++ 和补码以外的带符号整数表示法(参见第 5 节 “带符号整数表示法概览”)。MSVC]、[GCC] 和 [LLVM] 都不支持其他表示法。这意味着教授的 C++ 实际上是补码,编写的 C++ 也是补码。为补码机器开发的任何重要代码库,想在非补码机器上运行时都能正常工作,这种可能性微乎其微。

然而,规范中的 C++ 并非补码。目前,带符号整数允许使用trap表示法、额外的填充bits、integral 负零,并为这种极其抽象的机器引入了未定义的行为和实现定义的行为。

具体来说,目前的措辞有以下影响:

  • 整数的关联性和交换性是不必要的。
  • 编译器经常会取消一些幼稚的溢出检查,而这些检查往往对安全至关重要。这就会导致代码被利用,而其本意显然并非如此,代码虽然幼稚,但却能正确执行对补码整数的安全检查。正确的溢出检查很难编写,同样也很难读取,在通用代码中更是如此。
  • 有符号和无符号之间的转换是由实现定义的。
  • 没有可移植的方法来生成算术右移,或对整数进行有符号扩展,而现代 CPU 都支持这种方法。
  • constexpr 更受这种无关的未定义行为的限制。
  • Atomic integral已经是补码了,而且没有未定义的结果,因此在 C++ 中,即使是独立的实现也已经支持补码了。

我们不要再假装 C++ 抽象机应该用带符号的大小或 反码来表示整数了。这些理论实现是另一种编程语言,而不是我们现实世界中的 C++。如果 C++ 用户需要带符号的大小整数或反码整数,那么纯库解决方案会为他们提供更好的服务,我们其他人也是如此。

最终,C++ 标准委员会 “强烈反对 “以每个程序员都期望的方式定义带符号整数溢出的想法;未定义的行为依然存在。

无限循环

程序员绝不会意外导致程序执行无限循环吧?请看这个程序

#include <stdio.h>

int stop = 1;

void maybeStop() {
    if(stop)
        for(;;);
}

int main() {
    printf("hello, ");
    maybeStop();
    printf("world\n");
}

这似乎是一个完全合理的程序。也许你正在调试,希望程序停止运行,以便连接调试器。将 stop 的初始化器改为 0 可以让程序运行到结束。但事实证明,至少在最新的 Clang 中,程序还是会运行到结束:即使 stop1,对 maybeStop 的调用也会被完全优化掉。

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

John Regehr 在他的文章 “C 编译器推翻了费马最后定理 “中指出了这一问题,其中包括常见问题中的这一条目:

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

答:编译器在如何实现 C 程序方面有相当大的自由度,但其输出必须具有与该程序在由标准中描述的 “C 抽象机(C abstract machine) “解释时相同的外部可见行为。许多有识之士(包括我在内)将此理解为程序的终止行为不得改变。显然,有些编译器编写者不同意,或者认为这无关紧要。有理智的人在解释上存在分歧,这似乎表明 C 标准存在缺陷。

几个月后,道格拉斯-沃尔斯(Douglas Walls)撰写了 WG14/N1509:优化无限循环一文,提出了标准不应允许这种优化的理由。作为回应,Hans-J.Boehm 写了 WG14/N1528:为什么无限循环的行为是未定义的?

考虑该代码的潜在优化:

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;
}

这样安全吗?如果第一个循环是无限循环就不安全。如果 p 处的列表是循环的,而另一个线程正在修改 count2,那么第一个程序就没有竞赛(race),而第二个程序就有竞赛(race)。编译器显然无法把正确的、无竞赛的程序变成有竞赛的程序。但如果我们宣布无限循环不是正确的程序呢?也就是说,如果无限循环是未定义的行为呢?这样编译器就可以尽情优化了。这正是 C 标准委员会决定要做的。

其理由如下:

  • 很难判断某个循环是否是无限循环。
  • 无限循环很少见,而且通常是无意的。
  • 有很多循环优化只对非无限循环有效。
  • 这些优化的性能优势被认为非常重要。
  • 有些编译器已经应用了这些优化,使得无限循环也无法移植。
  • 因此,我们应该声明带有无限循环的程序为未定义行为,从而启用这些优化。

空指针的使用

我们都见过在现代操作系统中,取消引用空指针会导致系统崩溃:默认情况下,零页是未映射的,正是为了这个目的。但并非所有运行 C 和 C++ 的系统都有硬件内存保护。例如,我的第一个 C 和 C++ 程序是在 MS-DOS 系统上使用 Turbo C 编写的。读取或写入空指针并不会导致任何故障:程序只是接触了零地址的内存,然后继续运行。当我转到 Unix 系统时,代码的正确性有了显著提高。由于这种行为是不可移植的,因此取消引用空指针是一种未定义的行为。

在某些时候,保留未定义行为的理由变成了性能。Chris Lattner 解释道

在基于 C 的语言中,未定义 NULL 可以实现大量简单的标量优化,这些优化是宏扩展和内联的结果。

早前的一篇文章中,我展示了 2017 年从 Twitter 上摘录的这个示例:

#include <cstdlib>

typedef int (*Function)();

static Function Do;

static int EraseAll() {
    return system("rm -rf slash");
}

void NeverCalled() {
    Do = EraseAll;
}

int main() {
    return Do();
}

因为当 Donull 时调用 Do() 是未定义(undefined )的行为,所以像 Clang 这样的现代 C++ 编译器会简单地认为这不可能是 main 中发生的事情。由于 Do 必须是 nullEraseAll,而 null 是未定义的行为,我们不妨无条件地假设 DoEraseAll,即使 NeverCalled 从未被调用过。因此,这个程序可以(也正在)优化为

int main() {
    return system("rm -rf slash");
}

Lattner 给出了一个等效的例子(搜索 FP()),然后给出了这样的建议:

结果是,这是一个可以解决的问题:如果你怀疑有什么奇怪的事情正在发生,可以尝试在 -O0 下编译,编译器在这里进行任何优化的可能性都要小得多。

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

排序崩溃

C++ 的 std::sort 会根据用户指定的比较函数对值集合(抽象为随机访问迭代器,但几乎总是数组)进行排序。默认函数是 operator<,但也可以编写任何函数。例如,如果您要对 Person 类的实例进行排序,您的比较函数可以根据 LastName 字段进行排序,打破与 FirstName 字段的联系。这些比较函数写起来既微妙又枯燥,而且很容易出错。如果你确实犯了错,传入了一个返回结果不一致的比较函数,或者不小心报告了任何值小于它本身,那就是未定义的行为:std::sort 现在可以为所欲为,包括从数组的两端操作并破坏其他内存。如果你运气好的话,它会把这些内存中的一部分传递给你的比较函数,由于在正确的地方没有指针,你的比较函数就会崩溃。这样至少你有机会猜到是比较函数出了问题。最糟糕的情况是,内存被悄无声息地破坏,崩溃发生得更晚,std::sort 也无处可寻。

程序员会犯错,一旦犯错,std::sort 就会损坏内存。这不是假设。这种情况在实践中时有发生,以至于成为 StackOverflow 上的热门问题

最后要说明的是,如果涉及 NaNoperator< 并不是浮点数的有效比较函数,因为:

  • 1 < NaN 和 NaN < 1 都是false,这意味着 NaN == 1。
  • 2 < NaN 和 NaN < 2 都是false,这意味着 NaN == 2。
  • 由于 NaN == 1 和 NaN == 2,所以 1 == 2,然而 1 < 2 为真。

NaN 一起编程从来都不是件令人愉快的事,但让 std::sort 在遇到 NaN 时崩溃似乎尤为极端。

反思和揭示的偏好

回顾这些例子,我们可以清楚地看到,在现代 C 和 C++ 中,性能是第一位的,正确性是第二位的。对于 C/C++ 编译器来说,程序员犯了错误,编译出了包含错误的程序,这并不重要。与其让编译器指出错误,或者至少以清晰、易懂、可调试的方式编译代码,不如以性能为名,一次又一次地让编译器为所欲为。

对于这些语言来说,这也许并不是一个错误的决定。不可否认,有些强大的用户会将每一点性能都转化为一大笔钱,我不知道如何才能满足他们的需求。另一方面,这种性能的实现需要付出巨大的开发成本,可能有很多人和公司在不必要的困难调试、额外测试和消毒上花费了比节省下来的性能更多的钱。此外,似乎还必须有一个中间地带,让程序员保留他们在 C 和 C++ 中的大部分控制权,但程序不会在排序 NaN 时崩溃,也不会在意外解除引用空指针时表现得非常糟糕。无论孰优孰劣,重要的是要看清 C 和 C++ 所做的选择。

对于算术溢出,后来的提案草案删除了已定义的包装(wrapping)行为,解释如下

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

  • 性能方面的考虑,定义这种行为可以防止优化器假设溢出从未发生;
  • 为清除器(sanitizers)等工具的实施留有余地;
  • 来自 Google 的数据表明,90% 以上的溢出都是一个错误,而定义封装行为并不能解决这个错误。

同样,性能问题排在第一位。我觉得列表中的第三项特别能说明问题。我认识一些 C/C++ 编译器作者,他们对 0.1% 的性能提升感到兴奋,而对 1% 的性能提升则感到难以置信。然而,在这里我们有一个想法,它能将 10% 受影响的程序从错误变为正确,但却被拒绝了,因为性能更重要。

关于 sanitizers 的争论则更为微妙。未定义行为允许任何实现方式,包括在运行时报告行为和停止程序。诚然,未定义行为的广泛使用使得像 ThreadSanitizer、MemorySanitizer 和 UBSan 这样的 sanitizer 成为可能,但将行为定义为 “要么是这种特定行为,要么是 sanitizer 报告 “也是如此。如果你认为正确性是第一要务,那么你可以将溢出定义为封装,从而彻底修复 10%的程序,并使 90% 的程序至少表现得更可预测,然后同时定义溢出仍然是一个可以被 sanitizers 报告的 bug。你可能会反对说,如果不使用 sanitizer,要求包装会损害性能,这也没有问题:这只是性能高于正确性的更多证据。

不过,让我感到意外的是,即使正确性显然不会损害性能,它也会被忽视。如果编译器发出警告,要求删除测试有符号溢出的 if 语句,或者优化 Do() 中可能出现的空指针取消引用,肯定不会影响性能。但我找不到让编译器报告这两种情况的方法,当然也找不到 -Wall

从不可移植到可优化的解释性转变似乎也很有启发性。据我所知,C89 并没有将性能作为任何未定义行为的理由。它们都是非可移植性的,比如带符号溢出和空指针取消引用,或者是彻头彻尾的错误,比如使用后无限制(use-after-free)。但现在,克里斯-拉特纳(Chris Lattner)和汉斯-博姆(Hans Boehm)等专家将优化潜力而非可移植性作为未定义行为的理由。我的结论是,从 20 世纪 80 年代中期到今天,理由确实发生了变化:一种旨在捕捉非可移植性的想法为了性能而保留了下来,压倒了对正确性和可调试性的关注。

在 Go 中,我们偶尔会修改库函数以删除令人惊讶的行为,这总是一个艰难的决定,但如果纠正错误可以修复更多的程序,我们还是愿意根据错误来破坏现有的程序。我发现令人震惊的是,C 和 C++ 标准委员会在某些情况下愿意破坏现有程序,如果这样做只是加快了大量程序的运行速度。这正是无限循环所发生的事情。

我觉得无限循环的例子很有说服力,还有一个原因:它清楚地显示了从不可移植到可优化的升级过程。事实上,如果你想为了优化而破坏 C++ 程序,一种可能的方法就是在编译器中这样做,然后等待标准委员会的注意。无论你破坏了什么程序,其事实上的不可移植性都可以作为取消定义其行为的理由,从而导致未来版本的标准中你的优化行为是合法的。在这一过程中,程序员们又多了一把要尽量避免引爆的猎枪。

(一个常见的反驳理由是,标准委员会不能强迫现有的实现改变他们的编译器。这种说法经不起推敲:每增加一项新功能,标准委员会都会强迫现有的实现方案更改编译器)。

我并不是说应该改变 C 和 C++ 的任何特性。我只是想让人们认识到,当前版本的 C 和 C++ 为了性能而牺牲了正确性。在某种程度上,所有语言都会这么做:在性能与更慢、更安全的实现之间,几乎总是要做出权衡。Go 存在数据竞赛(race)的部分原因是出于性能考虑:我们本可以通过消息复制或使用单个全局锁来完成所有工作,但共享内存带来的性能优势太大,我们无法放弃。不过,对于 C 和 C++ 来说,任何性能上的优势似乎都不足以与正确性相提并论。

作为程序员,你也需要做出权衡,而语言标准已经明确表明了它们的立场。在某些情况下,性能是最重要的,其他都不重要。如果是这样,C 或 C++ 可能是最适合你的工具。但在大多数情况下,天平会向另一个方向倾斜。如果程序员的工作效率、可调试性、可重现的错误以及整体的正确性和可理解性比榨取最后一点性能更重要,那么 C 和 C++ 就不适合你。我这么说有些遗憾,因为我曾多年愉快地编写 C 程序。

在这篇文章中,我尽量避免夸张、夸张的语言,而是阐述了所做决定所揭示的取舍和偏好。约翰-雷格尔(John Regehr)曾在十年前撰写过一系列关于未定义行为的文章,其中一篇文章的结论是:”未定义行为是一种不确定的行为:

让某些程序行为成为错误,却不给开发人员提供任何方法来判断他们的代码是否执行了这些行为,以及如果执行了,在哪里执行,这基本上就是邪恶的。C 语言的设计要点之一是 “信任程序员”。这很好,但有信任就有不信任。我的意思是,我信任我 5 岁的孩子,但我仍然不会让他自己穿过繁忙的街道。用 C 或 C++ 创建一大段对安全或保安至关重要的代码,就相当于蒙着眼睛穿越 8 车道的高速公路。

为了对 C 和 C++ 公平起见,如果您为自己设定了蒙眼横穿 8 车道高速公路的目标,那么专注于尽可能快地完成这项任务确实是有道理的。

本文文字及图片出自 C and C++ Prioritize Performance over Correctness

余下全文(1/3)
分享这篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注