本文依据的 Go 版本是 1.21.3。
Go Build 过程
当我们输入 go build
时,会先发生这些事:
- 初始化各种编译参数,解析来自 flags、gowork、环境变量等的参数配置。
- 初始化一个
Action 0
,这个 action 是最终目标生成文件。 - 根据
Action 0
倒推依赖项,从程序入口到所有依赖的文件编译都作为一个 action,最终得出一个 Action Graph。 - 根据 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
执行 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
- hex id 即这个 entry 的 id,如上提到的则就是
6ea9258d348b21fa20b9831bac1a40dd6663827fc605bd49029ef191d9482040
。 - hex out 即对应缓存结果的 sha256,如上提到的则就是
8a1f4d4e47ca514c50cda68b17789a291fc661a67057cbdd438ad17898c0bf8c
。 - decimal size 即缓存结果的大小,如上提到的则就是
1231512
,单位 Bytes。 - unixnano 即缓存结果的最后修改时间,如
1634960180000000000
,单位纳秒。这个时间一定是等于或早于文件的修改时间。
得到的 Entry 的 struct 定义如下:
type Entry struct {
OutputID OutputID
Size int64
Time time.Time // when added to cache
}
然后回调用 Cache.OutputFile()
,OutputID
即 hex 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 的策略为:
- 在 trim.txt 记录了上次清理的时间戳,如果不存在可以立即清理。如果存在,且距上次清理时间小于 24 小时,则不清理。
- 如果距上次清理时间大于 24 小时,则遍历所有 Cache 的文件夹,使用 stat 读取对应 mtime(上次修改 时间)。如果修改时间大于 1 小时则清理文件。
这里可能有问题,为啥是 1 小时?因为 Go 使用了标记 mtime 的方法来标记文件最近在使用。编译时使用了哪个文件,就会使用 os.Chtimes
来修改文件
的上次修改时间为现在。这里有个小细节,如果这个文件的上次修改时间小于 1 小时内,则不会重新修改这个时间,以减少磁盘 IO。因此可以在编译某个项目后
通过清理修改时间大于 1 小时的文件来清理这个项目不依赖的缓存文件。