在Stream,我们广泛使用Go,它大大提高了我们的生产力。我们还发现,通过使用Go,速度非常快,自从我们开始使用它以来,我们已经实现了堆栈的任务关键部分,例如我们的内部存储引擎,由gRPC,Raft和RocksDB提供支持。
今天我们将看看Go 1.11编译器以及它如何将Go源代码编译成可执行文件,以便了解我们日常使用的工具是如何工作的。我们还将看到为什么Go代码如此之快以及编译器如何帮助。我们将看看编译器的三个阶段:
扫描程序,将源代码转换为令牌列表,供解析器使用。解析器,将标记转换为抽象语法树,供代码生成使用。代码生成,将抽象语法树转换为机器代码。注意:我们将要使用的包(go/scanner,go/parser,go/token,go/ast等)不是Go编译器使用的,而是主要供工具在Go上运行使用源代码。但是,实际的Go编译器具有非常相似的语义。它不使用这些包,因为编译器曾经用C编写并转换为Go代码,因此实际的Go编译器仍然让人想起该结构。
扫描器
每个编译器的第一步是将原始源代码文本分解为标记,这由扫描程序(也称为词法分析器)完成。标记可以是关键字,字符串,变量名,函数名等。每个有效程序“单词”由标记表示。对于Go来说,这可能意味着我们有一个令牌“package”,“main”,“func”等等。
每个标记由Go中的位置,类型和原始文本表示。Go甚至允许我们使用go/scanner和go/token包在Go程序中自己执行扫描程序。这意味着我们可以在扫描完成后检查Go程序的程序。为此,我们将创建一个打印Hello World程序的所有标记的简单程序。
该程序将如下所示:
我们将创建我们的源代码字符串并初始化scan.Scanner结构,它将扫描我们的源代码。我们尽可能多地调用Scan()并打印令牌的位置,类型和文字字符串,直到我们到达文件结束(EOF)标记。
当我们运行程序时,它将打印以下内容:
在这里,我们可以看到Go解析器在编译程序时使用的内容。我们还可以看到扫描器添加分号,其中通常将其放置在其他编程语言(如C)中。这解释了为什么Go不需要分号:它们由扫描仪智能放置。
分析器
扫描源代码后,它将被传递给解析器。解析器是编译器的一个阶段,它将标记转换为抽象语法树(AST)。AST是源代码的结构化表示。在AST中,我们将能够看到程序结构,例如函数和常量声明。
Go再次为我们提供了解析程序的包并查看AST:go/parser和go/ast。我们可以像这样使用它们来打印完整的AST:
输出:
在此输出中,你可以看到有关该程序的一些信息。在Decls字段中,有一个文件中所有声明的列表,例如导入,常量,变量和函数。在这种情况下,我们只有两个:我们导入fmt包和main函数。
为了进一步消化它,我们可以看一下这个图,它是上述数据的表示,但只包含类型,红色代表与节点对应的代码:
主要功能由三部分组成:名称,声明和正文。该名称表示为值为main的标识符。由Type字段指定的声明将包含参数列表和返回类型(如果我们指定了any)。正文包含一个包含我们程序所有行的语句列表,在这种情况下只有一行。
我们的单个fmt.Println语句由AST中的很多部分组成。该语句是一个ExprStmt,它表示一个表达式,例如,它可以是一个函数调用,就像它在这里一样,或者它可以是文字,二进制操作(例如加法和减法),一元操作(用于实例否定一个数字)等等。可以在函数调用的参数中使用的任何东西都是表达式。
我们的ExprStmt包含一个CallExpr,它是我们实际的函数调用。这又包括几个部分,其中最重要的部分是Fun和Args。Fun包含对函数调用的引用,在这种情况下,它是一个SelectorExpr,因为我们从fmt包中选择Println标识符。但是,在AST中,编译器还不知道fmt是一个包,它也可能是AST中的一个变量。
Args包含一个表达式列表,它是函数的参数。在这种情况下,我们将一个文字字符串传递给函数,因此它由一个类型为STRING的BasicLit表示。
很明显,我们能够从AST中推断出很多。这意味着我们还可以进一步检查AST并查找文件中的所有函数调用。为此,我们将使用ast包中的Inspect函数。此函数将递归遍历树,并允许我们检查来自所有节点的信息。
要提取所有函数调用,我们将使用以下代码:
我们在这里做的是查找所有节点以及它们是否为*ast.CallExpr类型,我们刚才看到它代表了我们的函数调用。如果是,我们将使用打印机包打印Fun成员中存在的函数的名称。
此代码的输出将是:
fmt.Println
这确实是我们简单程序中唯一的函数调用,所以我们确实找到了所有函数调用。
构建AST后,将使用GOPATH或Go 1.11及更高版本的模块解析所有导入。然后,将检查类型,并应用一些初步优化,这使得程序的执行更快。
代码生成
在解析导入并检查了类型之后,我们确定程序是有效的Go代码,我们可以开始将AST转换为(伪)机器代码的过程。
此过程的第一步是将AST转换为程序的较低级别表示,特别是转换为静态单一分配(SSA)表单。这个中间表示不是最终的机器代码,但它确实代表了最终的机器代码。SSA具有一组属性,可以更容易地应用优化,其中最重要的是变量在使用之前始终定义,并且每个变量只分配一次。
在生成SSA的初始版本之后,将应用许多优化过程。这些优化适用于某些代码,可以使处理器执行更简单或更快速。例如,可以消除死代码,例如if(false){fmt.Println(“test”)},因为它永远不会执行。另一个优化示例是可以删除某些nil检查,因为编译器可以证明这些检查永远不会出错。
现在让我们看一下这个简单程序的SSA和一些优化过程:
如你所见,此程序只有一个功能和一个导入。它会在运行时打印2。但是,此示例足以查看SSA。
注意:仅显示主要功能的SSA,因为这是有趣的部分。
为了显示生成的SSA,我们需要将GOSSAFUNC环境变量设置为我们想要查看SSA的函数,在本例中为main。我们还需要将-S标志传递给编译器,因此它将打印代码并创建HTML文件。我们还将编译Linux 64位文件,以确保机器代码与你在此处看到的相同。因此,要编译文件,我们将运行:
它将打印所有SSA,但它也会生成一个交互式的ssa.html文件,因此我们将使用它。
当你打开ssa.html时,将显示许多通道,其中大部分都已折叠。起始传递是从AST生成的SSA;较低的传递将非机器特定的SSA转换为机器特定的SSA,genssa是最终生成的机器代码。
开始阶段的代码如下所示:
这个简单的程序已经产生了相当多的SSA(总共35行)。然而,很多都是样板,可以消除很多(最终的SSA版本有28行,最终的机器代码版本有18行)。
每个v都是一个新变量,可以单击以查看其使用位置。b是块,所以在这种情况下,我们有三个块:b1,b2和b3。b1将始终执行。b2和b3是条件块,可以通过在b1结尾处的If v19→b2 b3(可能)看到。我们可以单击该行中的v19来查看v19的定义位置。我们看到它定义为IsSliceInBounds <bool> v14 v15,通过查看Go编译器源代码,我们可以看到IsSliceInBounds检查0 <= arg0 <= arg1。我们还可以单击v14和v15来查看它们的定义方式,我们将看到v14 = Const64 <int> [0];Const64是一个常量64位整数。v15被定义为相同但是1.因此,我们基本上有0 <= 0 <= 1,这显然是正确的。
编译器也能够证明这一点,当我们查看opt阶段(“机器无关优化”)时,我们可以看到它已经重写了v19为ConstBool <bool> [true]。这将用于opt deadcode阶段,其中b3被删除,因为前面显示的条件中的v19始终为true。
我们现在将看一下在将SSA转换为机器特定的SSA之后由Go编译器进行的另一个更简单的优化,因此这将是amd64体系结构的机器代码。为此,我们将比较较低和较低的死码。这是较低阶段的内容:
在HTML文件中,某些行是灰色的,这意味着它们将在下一个阶段中删除或更改。例如,v15(MOVQconst <int> [1])显示为灰色。通过点击它进一步检查v15,我们看到它在其他任何地方都没有使用,MOVQconst基本上和我们之前看到的相同,Const64,只是amd64的机器特定。因此,我们将v15设置为1.但是,v15在其他任何地方都没有使用,因此它是无用的(死的)代码并且可以被删除。
Go编译器应用了很多这样的优化。因此,虽然AST的第一代SSA可能不是最快的实现,但编译器将SSA优化为更快的版本。HTML文件中的每个阶段都是可能发生加速的阶段。
如果你有兴趣在Go编译器中了解有关SSA的更多信息,请查看Go编译器的SSA源代码。在这里,定义了所有操作以及优化。
结论
Go是一种非常高效且高性能的语言,由其编译器及其优化支持。要了解有关Go编译器的更多信息,源代码具有很好的自述文件。
举报/反馈

我们go

35获赞 50粉丝
全球go最新动态和博客等
关注
0
0
收藏
分享