Go语言为何编译如此迅速?

251

我在谷歌上搜索并查找了Go网站,但是我无法找到关于Go不同寻常的编译时间的解释。它们是语言特性(或缺乏特性)、高度优化的编译器还是其他原因导致的?我并不想宣传Go,只是好奇。


12
@支持团队,我知道这一点。我认为以这样的方式实现编译器,使其具有显著的速度编译不是过早优化,很可能代表了良好的软件设计和开发实践的结果。此外,我无法忍受看到Knuth的话被断章取义并应用不当。 - Adam Crossland
67
这个问题的悲观者版本是:“为什么C++编译这么慢?”。 - dan04
23
我投票支持重新开启这个问题,因为它并非基于观点。可以给出一个良好的技术(非意见性)概述语言和/或编译器选择,以便提高编译速度。 - Martin Tournoij
3
对我来说,对于小项目而言,Go 似乎比较慢。这是因为我记得在一台可能慢了数千倍的计算机上,Turbo-Pascal 的速度要快得多得多。每次我输入 "go build",然后几秒钟什么也没发生时,我就会想起那些老旧的 Fortran 编译器和穿孔卡片。你的情况可能有所不同。简而言之:"慢"和"快"是相对的概念。 - RedGrittyBrick
强烈推荐阅读https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast,以获取更详细的见解。 - Karthik
11个回答

213

依赖分析。

Go FAQ曾经包含以下语句:

Go提供了一种软件构建模型,使得依赖关系分析变得容易,并避免了C风格包含文件和库带来的许多开销。

尽管这个短语已经不在FAQ中了,但这个主题在Google中的Go演讲中有详细描述,比较了C/C++和Go的依赖分析方法。

这就是快速编译的主要原因。而这是有意设计的。


3
这句话已经不在Go FAQ中了,但是有一份更详细的说明“依赖分析”主题的内容,比较了C/C++和Pascal/Modula/Go方法。这个内容可以在Go at Google的演讲中找到。 - rob74

82

我认为Go编译器之所以看起来快速,是因为其他编译器较慢

C和C++编译器需要解析大量的头文件 - 例如,编译C++的“Hello World”程序需要编译18k行代码,这几乎相当于半兆字节的源代码!

$ cpp hello.cpp | wc
  18364   40513  433334

Java和C#编译器运行在虚拟机中,这意味着在它们可以编译任何东西之前,操作系统必须加载整个虚拟机,然后它们必须从字节码JIT编译成本地代码,所有这些都需要一些时间。

编译速度取决于几个因素。

有些语言被设计为编译速度快。例如,Pascal是使用单通道编译器进行编译的。

编译器本身也可以进行优化。例如,Turbo Pascal编译器是用手写优化汇编语言编写的,与语言设计相结合,可在286级硬件上运行非常快的编译器。我认为,即使现在,现代的Pascal编译器(如FreePascal)也比Go编译器更快。


20
微软的C#编译器不在虚拟机中运行。它仍然是用C++编写的,主要出于性能原因。 - blucz
23
Turbo Pascal 和后来的 Delphi 是编译器速度极快的最佳示例。在两者的构建者移民到微软之后,我们看到了微软编译器和语言方面的巨大改进。这不是一个偶然的巧合。 - TheBlastOne
9
代码行数为18,000行(准确为18,364行),文件大小为433,334字节(约0.5MB)。 - el.pescado - нет войне
11
自2011年起,C#编译器已经使用C#进行编译。这只是一个更新,以防任何人在以后阅读此内容时会有所不同。 - Kurt Koller
4
C# 编译器和运行生成的 MSIL 的 CLR 是两个不同的东西。然而,我相当确定 CLR 不是用 C# 写的。 - jocull
显示剩余4条评论

45

Go编译器比大多数C/C++编译器快得多的原因有很多:

  • 最主要的原因:大多数 C/C++ 编译器在编译速度方面设计得非常糟糕。此外,从编译速度的角度来看,C/C++ 生态系统中的某些部分(例如程序员编写代码的编辑器)并没有考虑到编译速度。

  • 最主要的原因:快速编译速度是 Go 编译器和 Go 语言有意为之的选择。

  • 与 C++ 不同,Go 没有模板和内联函数。这意味着 Go 不需要执行任何模板或函数实例化。

  • Go 编译器比 C/C++ 编译器具有更简单的优化器。

  • Go 编译器更早地生成低级汇编代码,并且优化器在汇编代码上工作,而在典型的 C/C++ 编译器中,优化传递在原始源代码的内部表示上工作。C/C++ 编译器中的额外开销来自于需要生成内部表示的事实。

  • Go 程序的最终链接(5l/6l/8l)可能比链接 C/C++ 程序慢,因为 Go 编译器正在浏览所有使用的汇编代码,而且它可能还在执行其他额外的操作,而 C/C++ 链接器不会执行这些操作。

  • 一些 C/C++ 编译器(如 GCC)以文本形式生成指令(以传递给汇编程序),而 Go 编译器以二进制形式生成指令。需要进行额外的工作(但不多)才能将文本转换为二进制。

  • Go 编译器只针对少数 CPU 架构,而 GCC 编译器则针对大量 CPU 进行优化。

  • 旨在实现高速编译的编译器(例如 Jikes)是快速的。在 2GHz 的 CPU 上,Jikes 可以每秒编译 20000 多行 Java 代码(增量编译模式甚至更有效率)。


22
Go的编译器会内联小型函数。我不确定针对少量CPU的目标如何使你变得更快或更慢……我假设在我编译为x86时,gcc不会生成PPC代码。 - Brad Fitzpatrick
1
@BradFitzpatrick,不想再翻旧账,但是针对较少的平台进行开发,编译器的开发者可以花更多时间为每个平台进行优化。 - ScottishTapWater
使用中间形式可以支持更多的架构,因为现在你只需要为每个新的架构编写一个新的后端。 - phuclv
优化器适用于汇编代码。汇编代码听起来与平台有关,他们真的为每个支持的平台单独设计了优化器吗? - Mark
1
@Mark 我的理解是,他们有一个平台无关的汇编语言,将Go代码编译成该语言,然后将其转换为特定于架构的指令集。https://golang.org/doc/asm - Student
@学生 这听起来很像“内部表示”,而这个答案声称Go不会这样做。也许他们在IR和Go正在做的事情之间进行了一些任意的区分? - Chinoto Vokro

36

编译效率是主要的设计目标:

最终,旨在实现快速构建:在单台计算机上构建大型可执行文件最多只需要几秒钟时间。为了达到这些目标,需要解决许多语言问题:一个富有表现力但轻量级的类型系统; 并发和垃圾回收; 严格的依赖说明等等。常见问题

关于与解析相关的具体语言特性,语言常见问题非常有趣:

其次,该语言被设计为易于分析并且可以在没有符号表的情况下解析。


6
不能完全解析 Go 代码而不使用符号表是不正确的。 - user811773
14
我不认为垃圾回收会提高编译时间,它根本不会。 - TheBlastOne
4
以下是来自常见问题解答(FAQ)页面的引用:http://golang.org/doc/go_faq.html 我无法确定它们是否未能达到其目标(符号表),或者它们的逻辑有问题(GC)。 - Larry OBrien
7
请访问http://golang.org/ref/spec#Primary_expressions,考虑两个序列[Operand,Call]和[Conversion]。示例Go源代码:identifier1(identifier2)。没有符号表,无法确定此示例是调用还是转换。| 任何语言都可以在某种程度上在没有符号表的情况下进行解析。虽然大多数Go源代码的大部分可以在没有符号表的情况下解析,但不能识别golang规范中定义的所有语法元素是正确的。 - user811773
5
你努力避免解析器成为报告错误的代码片段。通常,解析器在报告一致的错误消息方面表现不佳。在此,你创建一个解析树来表示表达式,好像aType是一个变量引用,并且在语义分析阶段发现它不是时,你会在那时打印一个有意义的错误信息。 - Sam Harwell
显示剩余7条评论

34

虽然上面的大部分是正确的,但有一个非常重要的点没有被提到:依赖管理。

Go只需要包含您直接导入的软件包(因为它们已经导入了它们所需的内容)。这与C/C++形成了鲜明对比,因为每个单独的文件都开始包含x头文件,而这些头文件又包含y头文件等等。总之,Go的编译时间与导入的软件包数量成线性关系,而C/C++则需要呈指数增长。


26
一个编译器翻译效率的好测试是自我编译: 给定的编译器编译自身需要多长时间? 对于C++来说,这需要很长时间(几个小时?)。相比之下,对于Pascal/Modula-2/Oberon编译器来说,在现代计算机上只需不到一秒钟 [1]。
Go语言从这些语言中汲取了灵感,但其中一些提高效率的主要原因包括:
  1. 清晰定义的语法,数学上严谨,便于高效扫描和解析。

  2. 类型安全和静态编译语言,使用带有依赖和跨模块边界类型检查的分离编译,以避免不必要的重读头文件和重新编译其他模块 - 与C/C++中的独立编译相反,编译器不执行此类跨模块检查 (因此需要一遍又一遍地重读所有这些头文件,即使对于一个简单的一行"hello world"程序)。

  3. 高效的编译器实现(例如单遍、递归下降自顶向下解析)——当然,以上的第1和第2点会大大帮助这一点。

这些原则已经在20世纪70年代和80年代的语言中得到充分实现,例如 Mesa、Ada、Modula-2/Oberon 等等,并且现在(在2010年代)正被引入到现代语言中,例如 Go (Google)、Swift (Apple)、C#(Microsoft) 等等。

让我们希望这将很快成为常规而不是例外。要达到这个目标,需要发生两件事:

  1. 首先,软件平台提供者如 Google、Microsoft 和 Apple 应该开始鼓励应用程序开发者使用新的编译方法,同时使他们能够重用他们现有的代码库。这就是苹果现在试图做的事情,他们正在使用 Swift 编程语言,它可以与 Objective-C 共存(因为它使用相同的运行时环境)。

  • 其次,这些原则最终应该被用于重新编写底层软件平台,并在此过程中重新设计模块层次结构,以使它们不再是单olithic的。当然,这是一项艰巨的任务,可能需要大部分十年左右的时间(如果他们足够勇敢去实现它 - 我对谷歌这样做并不确定)。

  • 无论如何,是平台推动了语言的采用,而不是相反。

    参考文献:

    [1] http://www.inf.ethz.ch/personal/wirth/ProjectOberon/PO.System.pdf,第6页:“编译器自身的编译大约需要3秒钟”。这个引用是针对低成本的Xilinx Spartan-3 FPGA开发板,在25MHz的时钟频率下运行,并拥有1MB的主存储器。从这个引用可以轻松地推断出,“ less than 1 second ”适用于现代处理器,它们的时钟频率远高于1 GHz,并且具有几GB的主内存(即比Xilinx Spartan-3 FPGA板强大几个数量级),即使考虑到I/O速度也是如此。即使在1990年,当Oberon在一台25MHz的NS32X32处理器上运行,并配有2-4MB的主内存时,编译器自身也只需要几秒钟就可以完成编译。即使那时候,等待编译器完成编译周期的概念对于Oberon程序员来说也是完全未知的。对于典型的程序,从触发编译命令到等待编译器完成编译而要花费的时间是总是比从鼠标按钮中删除手指所需的时间更长。这是真正的即时满足,几乎没有等待时间。尽管产生的代码质量并不总是完全达到当时最好的编译器的水平,但对于大多数任务来说,它已经相当好,一般情况下也是可以接受的。


    1
    一款 Pascal/Modula-2/Oberon/Oberon-2 编译器在现代计算机上自我编译只需要不到一秒钟的时间。[需要引用] - RamblingMad
    3
    引用已添加,参见参考文献[1]。 - Andreas
    1
    “...原则...正在被运用到现代编程语言中,例如Go(Google)、Swift(Apple)”。不确定Swift如何进入该列表:Swift编译器非常缓慢。在最近的柏林CocoaHeads聚会上,有人提供了一个中等规模框架的数字,结果为每秒16行代码。” - mpw

    17

    Go旨在快速设计,而它也确实表现出了这一点。

    1. 依赖管理:没有头文件,您只需要查看直接导入的软件包(无需担心它们导入了什么),因此您具有线性依赖关系。
    2. 语法:该语言的语法很简单,因此容易被解析。尽管功能数量减少,但编译器代码本身很紧密(路径很少)。
    3. 不允许重载:您看到一个符号,就知道它指的是哪个方法。
    4. 可以轻松地并行编译Go,因为每个软件包可以独立编译。

    请注意,Go不是唯一具有这些功能的语言(模块在现代语言中是常见的),但它们做得很好。


    第四点并不完全正确。依赖于彼此的模块应按照依赖关系编译,以允许跨模块内联等操作。 - fuz
    1
    @FUZxxl:这只涉及到优化阶段,你可以在后端IR生成之前实现完美的并行性;只有跨模块优化才会受到影响,这可以在链接阶段完成,而链接本身并不是并行的。当然,如果你不想重复工作(重新解析),最好以“格点”方式编译:1/没有依赖关系的模块,2/仅依赖于(1)的模块,3/仅依赖于(1)和(2)的模块,... - Matthieu M.
    2
    使用基本工具,如Makefile,这非常容易实现。 - fuz

    15
    引用自The Go Programming Language这本书,作者是Alan Donovan和Brian Kernighan:
    Go语言的编译速度比大多数其他编译语言要快,即使是从头开始构建。这种编译速度有三个主要原因。第一,每个源文件必须明确列出所有导入项,因此编译器不必读取和处理整个文件以确定其依赖项。第二,一个包的依赖关系形成一个有向无环图,因为没有循环,所以包可以单独编译,甚至可以并行编译。最后,编译后的Go包的目标文件记录了导出信息,不仅适用于包本身,还适用于它的依赖项。在编译包时,编译器必须读取每个导入的对象文件,但不需要查看这些文件之外的内容。

    10
    编译的基本思想其实非常简单。递归下降解析器原则上可以以I/O绑定速度运行。代码生成基本上是一个非常简单的过程。符号表和基本类型系统不需要太多计算。
    然而,让编译器变慢并不困难。
    如果有预处理器阶段,带有多级include指令、宏定义和条件编译,尽管这些都很有用,但这也很容易使其负载加重。(举个例子,我想到了Windows和MFC头文件。)这就是为什么需要预编译头文件。
    在优化生成的代码方面,添加的处理过程没有限制。

    8

    简单来说,因为语法非常易于分析和解析。

    例如,没有类型继承意味着无需繁琐的分析以查找新类型是否遵循基础类型所强制的规则。

    例如,在此代码示例中:"interfaces"编译器在分析该类型时不会去检查预定类型是否实现了给定接口。只有在使用它(并且如果使用了它)时才执行检查。

    另一个例子是,编译器会告诉您是否声明了一个变量但未使用它(或者如果您应该保存返回值但未使用)。

    以下代码无法编译:

    package main
    func main() {
        var a int 
        a = 0
    }
    notused.go:3: a declared and not used
    

    这些强制和原则使得生成的代码更安全,编译器不必执行程序员可以完成的额外验证。
    总的来说,所有这些细节使语言更容易解析,从而实现快速编译。
    再次用我的话来说。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接