View on GitHub

Go Build 的 Cache 机制分析

本文依据的 Go 版本是 1.21.3。

Go Build 过程

当我们输入 go build 时,会先发生这些事:

  1. 初始化各种编译参数,解析来自 flags、gowork、环境变量等的参数配置。
  2. 初始化一个 Action 0,这个 action 是最终目标生成文件。
  3. 根据 Action 0 倒推依赖项,从程序入口到所有依赖的文件编译都作为一个 action,最终得出一个 Action Graph。
  4. 根据 Action Graph,并行地执行编译项目。

生成 Action Graph

Action Graph 是一个有向无环图,它的节点是一个个 Action,边是 Action 之间的依赖关系。一般来说,Action 0 是 install 操作,Action 1 是 链接操作,Action 2 是程序入口 main 函数的编译操作。从入口文件开始会依赖各种文件,比如依赖了 fmt 包,那么就会有一个 Action 3 是编译。依 次类推能够生成整个 Action Graph。

为了方便理解,我写了一个 Hello World 程序并把它的 Action Graph 画了出来:

package main

import (
	"fmt"
)

func main() {
	fmt.Println("Hello, World!")
}

编译并打印 Action Graph。加上 -debug-actiongraph 参数即可,这个参数的值不能是 *.go 文件,否则会报错。

go build -debug-actiongraph graph.json main.go

打印出来的 JSON 文件即包含了整个编译的过程描述,以及每一步消耗的时间。我们可以使用一些分析工具如 https://github.com/icio/actiongraph 来 进行可视化的分析。比如使用 dot 把它转换成 png 图片:

actiongraph -f graph.json graph | dot -Tpng -o graph.png

Action Graph

执行 build

在执行 Action Graph 的时候,会依次对每个 package 进行 build。build 的过程主要是在 compile 包下,已经有很多文章对它进行了分析,这里就不 再赘述了。本文着重分析的是 build 过程中的 cache 机制。

Cache 是以每个 build package 作为粒度的。也就是说如果一个 package 有文件修改会整个 package 都会重新编译。

Action ID

Action ID 就是 Cache key,Action ID 表示这次操作。Action ID 在 Action Graph JSON 文件里面能看到。它会把影响当前编译结果的参数都加入 到计算 Hash 的内容里,比如 Work Dir、Go 版本、编译的文件及 hash 值、import 文件及 hash 值等,最后计算 Sha256 作为 ActionID。源码参考 这里。 举例 encoding/xml 包编译的 时候 Action ID 计算原始内容如下:

go1.21.3compile
goos darwin goarch arm64
import "encoding/xml"
omitdebug false standard true local false prefix ""
compile compile version go1.21.3 ["-shared"] []
=
file marshal.go conuJ8J1MEUuLq2uujo-
file read.go tvMve1Vd1U36izSxcCy1
file typeinfo.go Zs8lVQIj7cRWSJnWTzaj
file xml.go MDzDa-JUzR1r4nuTH-Pj
import bufio qLTB7-XUv1aCDTDmKY-A
import bytes v-cVrFN7Afn0U8ofJd5u
import encoding 7gAbpNIiBKO5DzYzb7ci
import errors v8rX5C73RxgMtYOcUvpu
import fmt Nj7P-kyZPfpNhvOCuuow
import io WjOZAkZJBCJawJzfPzBi
import reflect AMo_hSvtyFHYzJ5_JyNl
import runtime oxUb6wae6PImOpNw94D3
import strconv 18ls3SU-u_B_QjNFrzsL
import strings CqBf8iBXgOtHD8g0WpG9
import sync GKpE9PBmNsxiKw68PAn4
import unicode IAPzHz0ujq22SFAL1AtQ
import unicode/utf8 1vIB_nvoS529Zym1l-y-

这段 SHA256 结果是 6ea9258d348b21fa20b9831bac1a40dd6663827fc605bd49029ef191d9482040。打印时会使用 buildid.HashToString, 这个函数会截取前 120bits 并转成 base64 作为 ActionID 来减少长度,即截取 hex hash 结果的前 30 个字符串并重新转成 base64 编码,即 bqkljTSLIfoguYMbrBpA,这个值在 Action Graph JSON 文件里面能看到。

这里每个 file 文件末尾都有一个 hash,这个是整个文件的 sha256 截取 160bit 后 base64 的结果。

关于 Cache Key 生成的计算,可以在 build 的时候增加环境变量 GODEBUG=gocachehash=1,它就会把 Key 的计算过程打印出来。

Cache.GetFile

对于缓存不存在的项目,需要执行编译,编译出来的结果会写入到 CACHE 目录中。这个目录是在 go env GOCACHE 中能看到的。缓存的内容使用二级结构。 Cache.GetFile() 会先调用 Cache.Get() 方法,去读取 entry。Entry 会有个 -a 结尾,比如上述的文件,对应的 entry 文件存储路径是 6e/6ea9258d348b21fa20b9831bac1a40dd6663827fc605bd49029ef191d9482040-a。这个文件是固定长度的,文件内容如下:

v1 <hex id> <hex out> <decimal size space-padded to 20 bytes> <unixnano space-padded to 20 bytes>\n

得到的 Entry 的 struct 定义如下:

type Entry struct {
    OutputID OutputID
    Size     int64
    Time     time.Time // when added to cache
}

然后回调用 Cache.OutputFile()OutputIDhex out,得到这个 OutputID 后会再读取一次缓存文件,文件名加上 -d 结尾。如上文提到 的文件路径则为:8a/8a1f4d4e47ca514c50cda68b17789a291fc661a67057cbdd438ad17898c0bf8c-d。读取文件出来后会校验文件的大小。

Cache.Put

编译后产生的 .a 文件,会使用 Cache.Put() 方法把这个临时文件缓存起来。这个方法会生成(实际上是重新写入,前面生成 Action ID 的时候会临时 生成一个)Build ID。Build ID 实际上就是 {ActionID}/{ContentID},ActionID 就是上面提到的东西;ContentID 就是编译结果的 sha256。

写入的过程如读取的过程,会先写入实际的 .a 文件,然后再写入一个 entry 文件。

缓存的清理

在缓存使用时,每次执行 Cache 对象的 Close() 方法,它会调用 Trim() 方法进行过期 Cache 清理。Trim 的策略为:

这里可能有问题,为啥是 1 小时?因为 Go 使用了标记 mtime 的方法来标记文件最近在使用。编译时使用了哪个文件,就会使用 os.Chtimes 来修改文件 的上次修改时间为现在。这里有个小细节,如果这个文件的上次修改时间小于 1 小时内,则不会重新修改这个时间,以减少磁盘 IO。因此可以在编译某个项目后 通过清理修改时间大于 1 小时的文件来清理这个项目不依赖的缓存文件。