引言
本文主要围绕
go build
的缓存hash计算与获取缓存文件来编写。
笔者是Windows系统用户,在 go build
或go list -export
一些需要编译(但已存在编译缓存)场景下执行的很慢。网上有很多说法大多都是说关闭杀毒软件、关闭磁盘扫描等,并未清楚的描述为什么。
接下来我将围绕go build这个命令阅读了go build的相关代码与文档,从中得知缓存是如何做到的,又是如何命中缓存的。
缓存机制的基本原理
构建缓存,是指对go build编译的内容进行缓存,它会缓存到GOCACHE env中,它的缓存目录如下:
|go-build
|--[hash[:2]] (文件哈希值前2位,减少太碎片化)
|----[hash]-d (compile file: package or bin)
|----[hash]-a (action)
- 以上文件,在编译包后会将内容从 builder workDir 复制到go-build(GOCACHE)目录。
- 这些个缓存文件中也写入了在构建时生成的buildID,而buildID,它是根据actionID和contentID组合得来的。
<actionId>/<contentId>、<actionID>/<dep[0].buildID>/<contentID>
如何生成hash的?
我们要构建go项目前,是需要得到所有的依赖的包(ImportPath),最终针对每个包进行编译。
在生成hash前,我们先了解下面两点
生成操作图:操作图是指对每个包的操作,如何操作、该操作依赖了什么等,是对操作的描述。在最后执行这些操作图时,会以去找到该这些操作图中没有依赖项的先执行。
编译包:然后根据操作图执行。我们都知道包之间是有依赖关系的,我们需要提前知道谁依赖谁,最终对它们生成一个排序号的编译顺序的项,让构建时保证了包编译时所依赖的包是已编译的。
而缓存则就是在编译前通过操作图计算出hash去寻找缓存,如果没有缓存则才去编译,编译完后会根据当前操作图的hash去写出缓存到go-build(其实他在编译时会存放到当前builder.workDir,最后移动到GOCACHE目录。
缓存包: go/internal/cache
该包内是针对缓存文件的读取、hash的生成、以及对缓存文件的修改时间等操作。
- 通过写入byte内容,然后计算该内容得到哈希。
- 通过指定文件,读取文件内容来计算得到哈希。
- 读取指定hash的缓存内容,如读缓存的包、读缓存的gofiles等。
获取操作hash: (Builder).builderActionID(action)
获得当前操作的哈希(此哈希可去找到缓存的包)
操作id: buildAction
阅读该源码可:view codes
使用cache.NewHas 来创建一个hash,例如当前是计算buildAction则:cache.NewHash("build " + p.ImportPath) ,然后再根据当前操作图的内容和go版本等信息向当前的hash流里写入内容,如:fmt.Fprintf(h, "compile\n")
根据编译动作的内容生成hash: 它是通过不同的操作、go版本、系统版本和命令的自身需要的数据而组合计算得出的哈希则是它的操作ID。
for example: 构建包: build fyne.io/fyne/v2/container
HASH NAME: build fyne.io/fyne/v2/container
HASH Body:
"go1.21.5"
"compile\n"
"dir /home/liuscraft/go/pkg/mod/fyne.io/fyne/[email protected]/container\n"
"go 1.17\n"
"goos linux goarch amd64\n"
"import \"fyne.io/fyne/v2/container\"\n"
"omitdebug false standard false local false prefix \"\"\n"
"compile compile version go1.21.5 [] []\n"
"GOAMD64=v1\n"
"file apptabs.go 0-0RazvPT20JpC1aFUKV\n"
"file container.go Utx1rP_vPvYe_OUB19Lf\n"
"file doctabs.go cYVyCdCSNxjkDqVHsU6U\n"
"file layouts.go wCNQmhu3VqxjUX7yb4Cx\n"
"file scroll.go j_xTEDYDq-fgmgyj78Y-\n"
"file split.go a6dGVhpLhUcb70jDbV57\n"
"file tabs.go Cipr6syoIG1ar0a9Ov2m\n"
"import fyne.io/fyne/v2 eASK30olqGqtSeoxyrQ1\n"
"import fyne.io/fyne/v2/canvas G4FlPwPnYXXw7dxFB0hj\n"
"import fyne.io/fyne/v2/driver/desktop Hw0IFMuFUgmW1JYrCVxz\n"
"import fyne.io/fyne/v2/internal xUhHtYQsqkgm0m6rMPSd\n"
"import fyne.io/fyne/v2/internal/widget MSnJkoFbIzwRUaqJ6OHT\n"
"import fyne.io/fyne/v2/layout LkN4E60WNeLZkNtASg0j\n"
"import fyne.io/fyne/v2/theme iY_BtWGjYnHtGt7hxWkM\n"
"import fyne.io/fyne/v2/widget oXRt7xbBrqP4DYzCAZ_r\n"
"import image/color 5KY_2W7V9mJ4XIGT_c6u\n"
"import sync oHEoZ3U7NHMHs26WzTVq\n"
SUM:41cde5b3a2504ac5d3bd960869a40a0c96fde013ee77e88eb882276b9501b3c4
上文中,这些import开头Utx1rP_vPvYe_OUB19Lf
类似的文本,他是当前编译包的所依赖的操作id,也就是说,上文中的这些id都是通过相同方式生成的。
file部分则是通过使用cache.FileHash计算得来的,它计算的是文件内容。
从这里我们就可以看出,这个生成id的操作相当的复杂,大量的IO最终导致在磁盘性能底、文件系统不行的状况下,会导致一小段的耗时。
最终拿着上文中的内容,再利用cache.Sum()来计算这个内容的hash值,来代表当前buildAction的hash(也就是操作id)
获取操作: linkActionID
link操作的id,也是差不多的操作,细节可自行阅读源码:
func (b *Builder) linkActionID(a *Action) cache.ActionID: view code
contentId如何得到
这个目前只看到直接等值为 actionId,说是零时占位。
buildID如何获得
buildid:可阅读源码
buildid.go
builid组成: <actionID>/<contentId>
、<actionId>/<deps[0].buildId>/<contentID>
注意: <deps[0].buildId>
- 其实就是依赖的操作图id,例如我link操作依赖一个包的编译操作,那么这里就是这个包的buildid,他展开其实最终结果是:
<actionID>/<actionID>/<contentId>/<contentId>
如果我们知道buildId,那我们肯定知道ActionID和contentID,他其实就是buildid的前半段或后半段。
// actionID returns the action ID half of a build ID.
func actionID(buildID string) string {
i := strings.Index(buildID, buildIDSeparator)
if i < 0 {
return buildID
}
return buildID[:i]
}
// contentID returns the content ID half of a build ID.
func contentID(buildID string) string {
return buildID[strings.LastIndex(buildID, buildIDSeparator)+1:]
}
buildIDSeparator: 他就是buildId中的
/
符号
使用action ID寻找缓存
存在的target(已编译的包、或二进制)
这个的前提也是需要计算hash的
阅读: 读文件的buildID的实现: view codes
这个target,是pkg、bin这些里的标准库等内容(在加载importpath生成操作图的时候就已经得到了)
这里是指编译的包和go的二进制文件中其实是存储了当前文件的buildid的.
// compile
buildID, _ := buildid.ReadFile(target)
if strings.HasPrefix(buildID, actionID+buildIDSeparator) {
a.buildID = buildID
if a.json != nil {
a.json.BuildID = a.buildID
}
a.built = target
// Poison a.Target to catch uses later in the build.
a.Target = "DO NOT USE - " + a.Mode
return true
}
// link(当前编译的包是被父级link的,那可以看看这个是不是被父级link的,是的话也不需要编译)
if !b.NeedExport && a.Mode == "build" && len(a.triggers) == 1 && a.triggers[0].Mode == "link" {
if id := strings.Split(buildID, buildIDSeparator); len(id) == 4 && id[1] == actionID {
oldBuildID := a.buildID
a.buildID = id[1] + buildIDSeparator + id[2]
linkID := buildid.HashToString(b.linkActionID(a.triggers[0]))
if id[0] == linkID {
// Best effort attempt to display output from the compile and link steps.
// If it doesn't work, it doesn't work: reusing the cached binary is more
// important than reprinting diagnostic information.
if printOutput {
showStdout(b, c, a, "stdout") // compile output
showStdout(b, c, a, "link-stdout") // link output
}
// Poison a.Target to catch uses later in the build.
a.Target = "DO NOT USE - main build pseudo-cache Target"
a.built = "DO NOT USE - main build pseudo-cache built"
if a.json != nil {
a.json.BuildID = a.buildID
}
return true
}
// Otherwise restore old build ID for main build.
a.buildID = oldBuildID
}
}
存在的操作输出缓存(读GOCACHE中的-d, output文件)
这个的前提也是需要计算hash的
阅读: 查看缓存文件是否存在,存在则需要读该输出文件的buildID(与上面同样)
这个其实也是编译后的包或者二进制文件,没啥区别,只不过这个是第三方包的缓存在GOCACHE
// Check to see if the action output is cached.
if file, _, err := cache.GetFile(c, actionHash); err == nil {
if buildID, err := buildid.ReadFile(file); err == nil {
if printOutput {
showStdout(b, c, a, "stdout")
}
a.built = file
a.Target = "DO NOT USE - using cache"
a.buildID = buildID
if a.json != nil {
a.json.BuildID = a.buildID
}
if p := a.Package; p != nil && target != "" {
p.Stale = true
// Clearer than explaining that something else is stale.
p.StaleReason = "not installed but available in build cache"
}
return true
}
}
补充 tool buildId工具
通过go tool buildid [-w] <file>
来得到一个文件的buildid,然后跟当前操作哈希进行判断是否一直,如果一直则直接使用中国编译的包或者二进制文件。
Windows下的性能问题
通过hash的生成的过程,其实就可以得知Windows慢的原因了:大量的IO操作,文件读写。
- 磁盘速度
- Windows文件系统,它会比macos和Linux慢
- 杀毒软件干扰
- 磁盘扫描软件干扰
这就是为什么,网上只要有人提起 go build慢,就会有人回答关闭杀软、关闭磁盘扫描等。
测试windows/linux文件递归速度
测试代码:
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
)
func recursiveFiles(directory string) error {
err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode().IsRegular() {
// fmt.Println(path)
}
return nil
})
return err
}
func main() {
start := time.Now()
directory := `wokdir`
err := recursiveFiles(directory)
if err != nil {
fmt.Println(err)
}
log.Printf("%.2f", time.Since(start).Seconds())
}
结果(使用了大量的文件、文件夹):
linux: 0.06s
windows: 2.51s
结论
- 构建缓存的文件是存储在GOCACHE ENV的目录下
- 缓存哈希是通过actionID来进行的,缓存单位是包(编译单位也是包)
- 缓存在GOCACHE中时,它其实是被2个字符名称的文件夹所包裹的,其实就是这些缓存文件名的前两位值(这里是防止大量缓存文件都在一个文件夹下,而是让他们分散到不同的文件夹)
- 计算缓存hash它存在大量的IO操作,依赖越多,那么IO操作就越多。
- 无论是否标准库还是第三方库,都会计算hash。
参考资料
[1]. pkgsite: https://pkg.go.dev/cmd/go/internal/work
[2]. go sources(1.22.1): https://cs.opensource.google/go/go/+/refs/tags/go1.22.1:src/cmd/go/internal/work