年薪百万程序员告诉你为什么说C语言不是低级语言?
在相继出现 Meltdown 和 Spectre 漏洞之后,花一些时间研究造成漏洞的根本原因是值得的。这两个漏洞都涉及处理器绕过某种访问检查直接执行指令,让攻击者可以通过侧通道观察执行结果。导致这些漏洞的原因让 C 语言程序员相信他们正在使用的是一门低级的编程语言,但几十年来,情况并非如此。
计算机科学先驱 Alan Perlis 对低级编程语言的定义:
“当一门编程语言的程序要求把注意力放在不相关的内容上时,那它就是低级的编程语言”。
或许这个定义适用于 C 语言,但它并不能准确表达人们对低级语言的认识。人们通过多种属性来判断一门编程语言是否是低级的。我们假设将编程语言视为一个连续的整体,一端是汇编,另一端是星际级的计算机接口。低级语言“接近金属”,而高级语言更接近人类的思维方式。
对于“接近金属”的语言,必须提供一个抽象机,以便轻松映射到目标平台公开的抽象上。人们很容易认为 C 语言是 PDP-11 的低级语言。在 C 语言模型中,程序都是按顺序执行,内存是一个扁平的空间,甚至预增量和后增量运算符都与 PDP-11 寻址模式完全一致。
Spectre 和 Meltdown 漏洞的根本原因在于,处理器架构师不仅试图构建出快速的处理器,他们还试图构建与 PDP-11 一样的抽象机。这样就可以让 C 语言程序员相信他们的语言是接近底层硬件的。
C 语言的代码提供了一个几乎串行的抽象机(直到 C11,如果排除非标准的厂商扩展,那么它就是完全串行的抽象机)。众所周知,创建新线程是一种昂贵的库操作,因此希望保持执行单元忙于运行 C 语言代码的处理器不得不依赖 ILP(指令级并行)。它们检查相邻的操作,然后并行执行独立的操作。在这种情况下,为了让程序员编能够按照串行的方式编写代码,增加复杂性(和功耗)是不可避免的。相比之下,GPU 在没有这种逻辑的情况下实现了非常高的性能,但代价是要求程序代码必须是并行的。
追求高 ILP 是导致 Spectre 和 Meltdown 漏洞的直接原因。现代英特尔处理器一次最多可以执行 180 条指令(与串行 C 语言抽象机形成鲜明的对比,后者希望每个操作在下一个操作开始之前完成)。C 语言代码的典型规则是平均每七个指令就会有一个分支。如果你希望在单个线程中保留这样的管道,那么就必须猜测接下来的 25 个目标分支。这再次增加了复杂性,也意味着不正确的猜测会导致已经完成的操作被丢弃,造成资源浪费。丢弃操作具有明显的副作用,Spectre 和 Meltdown 攻击就是利用了这些副作用。
现代高端 CPU 上的寄存器重命名引擎是晶模和功率的最大消耗者之一。更糟糕的是,在运行指令时我们无法将其关闭或对其进行功率门控。但这个单元在 GPU 上显然是不存在的,GPU 的并行性源于多个线程,而非标量代码的指令。如果指令不需要对依赖项进行重拍序,那么寄存器重命名就不是必需的。
让我们来看一下 C 语言抽象机内存模型的另一个核心部分:平面内存。为了降低延迟,现代处理器通常在寄存器和主存储器之间使用了三级高速缓存。
顾名思义,缓存对程序员是透明的,因此对 C 语言是不可见的。使用缓存是让代码在现代处理器上快速运行的最重要的方法之一,但这完全被抽象机隐藏了起来,程序员必须了解高速缓存的实现细节(例如,两个 64 字节的值可能会处在同一个高速缓存行中)才能写出高效的代码。
低级语言的一个常见属性是运行速度快,它们应该能够在不使用特别复杂的编译器的情况下编译成快速执行的代码。足够聪明的编译器可以加快一门语言的运行速度,C 语言支持者在谈论其他编程语言时却常常忽略了这一点。
通过简单编译就能获得快速执行的代码,但对 C 语言来说并不是这么一回事。尽管处理器架构师努力设计可以快速运行 C 语言代码的芯片,但 C 语言程序员所期望的性能水平只能通过非常复杂的编译器转换来实现。Clang 编译器(包括 LLVM 的相关部分)大约有 200 万行代码。即使只算上为了让 C 语言代码运行更快所需的分析和转换,也会增加近 20 万行代码(不包括注释和空行)。
例如,在 C 语言中,在处理大量数据时需要使用循环来串行地处理每个元素。要在现代 CPU 上以最佳方式运行,编译器必须先确定循环是独立的。这个时候可以借助 C 语言的 restrict 关键字,它可以保证对一个指针的写入不会干扰对另一个指针的读取。但这些信息比 Fortran 要少得多,这也是 C 语言在高性能计算方面未能取代 Fortran 的重要原因。
一旦编译器确定循环是独立的,那么下一步就是尝试对结果进行矢量化,因为现代处理器的矢量代码吞吐量是标量代码的四到八倍。这类处理器的低级语言将具有任意长度的原生矢量类型。LLVM IR(中间表示)就是这样,因为将大型矢量运算分成较小的矢量运算总是比构造更大的矢量运算更容易。
在这种情况下,优化器必须与 C 语言内存布局保证作斗争。C 语言保证具有相同前缀的结构体可以互换使用,并且它将结构体字段的偏移量暴露给了语言。这意味着编译器不能随意重新排序字段或通过插入填充来改进矢量化(例如,将数组结构体转换为一组结构体,或者反过来)。对于低级语言来说,这不一定是个问题。在低级语言中,对数据结构体布局的细粒度控制是一个特性,但它确实会让快速运行 C 语言代码变得更难。
C 语言还需要在结构体的末尾进行填充。填充是 C 语言规范中特别复杂的部分,并且与语言的其他部分没有太多交互。例如,你必须使用对类型不敏感的方式(例如 memcmp)来比较两个结构体,因此结构体的副本必须保留填充。在一些实验中,一些工作负载花费了大量的运行时间来复制填充。
现在让我们来看一下 C 语言编译器的两个文凭核心优化:SROA(聚合的标量替换)和循环测试外提(unswitching)。SROA 尝试使用个体变量来替换结构体(和具有固定长度的数组)。然后,如果可以证明结果永远不可见,那么编译器完全可以将其视为独立的省略操作。在某些情况下,这对删除填充来说具有一些副作用。
第二种优化将包含条件的循环转换为带有循环的条件。这改变了流程控制,而且与程序员应该知道代码执行顺序的想法相矛盾。它还可能导致 C 语言的未指定值和未定义行为的概念出现严重错乱。
在 C 语言中,未初始化的变量就是未指定的值,每次读取时可能为任意值。这点很重要,因为这样可以进行内存页的惰性回收:例如,在 FreeBSD 上,malloc 会告诉操作系统说当前页未被使用,然后操作系统根据第一次页写入来判断 malloc 的通知是否有效。读取新 malloc 内存可能会读取到旧值,然后操作系统可能会重用底层物理页,下一次在页中的其他位置写入时将其替换为新的归零页。然后,第二次读取相同位置将得到零值。
如果使用了流程控制的未指定值(例如,将其用在 if 判断中),就会得到未定义的行为:任何情况都有可能发生。对于循环测试外提,最终循环会执行零次。在原始版本中,整个循环体是死代码。在测试外提版本中,变量上有一个分支,可能是未初始化的。一些死代码现在变成了未定义行为。
总之,要 C 语言代码快速运行是有可能的,但可能需要花费数千人年来构建足够智能的编译器,而且要违反 C 语言的某些规则。编译器开发者让 C 语言程序员假装他们正在编写“接近金属”的代码,但如果他们希望 C 语言程序员继续相信他们使用的是一门快速的语言,则必须生成具有不同行为的机器码。
低级语言的一个关键属性是程序员可以很容易地理解语言的抽象机如何映射到底层物理机。在 PDP-11 上肯定是这样的,因为每个 C 语言表达式都映射到一至两个指令。类似地,编译器将局部变量直接降到栈槽,并将原始类型映射成 PDP-11 可以直接操作的元素。
从那时起,为了维护人们对 C 语言可以很容易映射到底层硬件并能够提供快速执行的代码的印象,C 语言的实现变得越来越复杂。2015 年的一份针对 C 语言程序员、编译器开发者和标准委员会成员的调查指出了几个有关 C 语言可理解性的问题。例如,C 语言的实现可以填充结构体(不是数组),以确保所有字段都能够对齐目标。如果将结构体归零,然后设置一些字段,那么填充位是否都为零?根据调查结果,36%的人表示会是零,29%的人表示不知道。事实上,根据编译器(和优化级别)的不同,它可能是也可能不是。
这是一个相当简单的例子,但很大一部分程序员的理解要么是错的,要么不确定。当你在使用指针时,C 语言的语义就会变得更加混乱。BCPL 模型非常简单:值就是单词。每个单词就是某些数据或某些数据的地址。内存是通过地址进行索引的扁平存储单元阵列。
相比之下,C 语言模型允许在各种目标平台上实现,包括分段式架构(指针可能是分段 ID 和偏移量),甚至是基于垃圾回收机制的虚拟机。C 语言规范十分谨慎地限制对指针的操作,以避免此类系统出现问题。人们对 C 语言缺陷报告 260 的反应就包括了指针起源(provenance)的概念。
然而,“起源”这个词根本没有出现在 C11 规范中,所以完全由编译器来决定它的含义。例如,GCC(GNU 编译器集合)和 Clang 在对指针进行强制转换是否保留起源这个问题上存在不同的理解。编译器可以*决定指向不同 malloc 结果或栈分配的两个指针总是不相等,即使指针的按位比较操作可能显示它们描述的是相同的地址。
这些误解本质上不是纯粹的学术问题。例如,我们已经从有符号整数溢出(C 语言的未定义行为)和一些代码中找到了安全漏洞。对于后者,在进行空值检查之前取消引用指针是在告诉编译器指针不能为空,因为取消引用空指针在 C 语言中是未定义行为,因此可以假定这种情况不会发生(
因此,我们很难说程序员可以准确理解 C 语言程序将如何映射到底层架构。
针对 Spectre 和 Meltdown 漏洞的修复方案将显著降低性能,在很大程度上抵消了过去十年在微架构方面所取得的进步。或许,现在是时候停止尝试如何让 C 语言代码变得更快,而是想办法设计出能够在快速处理上运行的编程模型。
我们有很多设计的例子,它们并没有专注于从传统的 C 语言代码中获得灵感。例如,高度多线程芯片(Sun/Oracle 的 UltraSPARC Tx 系列)不需要那么多的缓存来保持执行单元的满载。研究处理器已经将这个概念扩展到非常大量的硬件调度线程。这些设计背后的关键思想是,通过足够的高级并行度,我们可以将等待内存数据的线程挂起,并使用其他指令填充执行单元。这种设计的问题在于 C 语言程序往往没有繁忙的线程。
ARM 的 SVE(标量向量扩展)为程序和硬件之间的接口提供了另一个视角。常规向量单元提供固定大小的向量操作,并让编译器尝试将算法映射到可用的单元大小。相反,SVE 接口会让程序员描述可用的并行度,并依赖硬件将其映射到可用的执行单元数。要在 C 语言中使用这个相当复杂,因为自动向量器必须在循环结构体中推断出可用的并行度。通过函数式的映射操作为它生成代码非常简单:映射数组的长度就是可用的并行度。
缓存很大,但大小并不是造成复杂性的唯一因素。缓存一致性协议是造成现代 CPU 难以实现速度和正确性的重要原因。其中大多数复杂性来自于为既能共享数据又能修改数据的编程语言提供支持。相反,在 Erlang 风格的抽象机中,每个对象都是线程局部或不可变的(Erlang 甚至可以进一步简化,让每个线程只拥有一个可变对象)。用于这种系统的高速缓存一致性协议需要处理两种情况:可变或共享。将一个线程迁移到另一个不同的处理器上,需要显式地让其缓存失效,但这不是一种常见的操作。
不可变对象可以进一步简化缓存,并降低一些操作开销。Sun Labs 的 Maxwell 项目指出,缓存中的对象和将在年轻代中分配的对象几乎属于同一类。如果对象在被逐出缓存之前就已经死亡,那么不将它们写回主存储器就可以极大地降低开销。Maxwell 项目提出了一个年轻代垃圾回收器(和分配器),它将在缓存中运行并实现内存快速回收。利用堆上的不可变对象和可变栈,垃圾回收器变成了一个非常简单的状态机,可在硬件中实现,并且可以更有效地使用相对较小的缓存。
纯粹为速度而不是为在速度和对 C 语言的支持之间做出折衷而设计的处理器,可能会支持大量线程,具有宽矢量单元和更简单的内存模型。在这样的系统上运行 C 语言代码会有问题,因此,考虑到世界上存在大量的遗留 C 语言代码,这种设计不太可能在商业上取得成功。
在软件开发领域存在一个迷思,就是认为并行编程很难。对于能够教会儿童使用基于 actor 模型的语言的 Alan Kay 来说,可能会感到很吃惊,因为他的学生们可以写出支持 200 多个线程的程序。对于 Erlang 程序员来说,他们也会感到很吃惊,因为他们通常使用数千个并行组件开发程序。更准确地说,使用带有类似 C 语言抽象机这样的语言进行并行编程是很困难的,并且随着并行硬件的普及,从多核 CPU 到多核 GPU,只能说,C 语言无法很好地映射到现代硬件上。