【深度解析】'go build'缓存机制:揭秘Windows下缓慢的原因
引言
本文主要围绕
go build
的缓存hash计算与获取缓存文件来编写。阅读时可根据提供的源代码一起看。
笔者是Windows系统用户,在七牛实训营开发的GO+ build在Windows下慢的问题中发现 go build
或go list -export
在一些需要编译的场景下执行的很慢,即使是有缓存的情况。网上有很多说法大多都是说关闭杀毒软件、关闭磁盘扫描等,但并未清楚的描述为什么。
带着这个疑问,接下来我将围绕go build这个命令,基于源码和文档,来分析其执行过程,看看其缓存到底是如何做到的,又是如何命中缓存的,以及为什么这么慢。
go build编译的内容,会缓存到GOCACHE env中,它的缓存目录如下:
|go-build
|--[hashDir[:2]] (文件哈希值前2位,减少太碎片化)
|----[hashFile]-d (compile file: package or bin)
|----[hashFile]-a (action)
-
以上文件,在编译包后会将内容从 builder workDir 复制到go-build(GOCACHE)目录。
-
这些个缓存文件中也写入了在构建时生成的buildID,而buildID,它是根据actionID和contentID组合得来的。
<actionId>/<contentId>、<actionID>/<dep[0].buildID>/<contentID>
缓存机制的基本原理
我们要构建go项目前,是需要得到所有的依赖的包(ImportPath),最终针对每个包进行编译。
在生成hash前,我们先了解下面两点:
生成操作图:操作图是指对每个包的操作,如何操作、该操作依赖了什么的实际描述。在最后执行这些操作图时,会去找到这些操作图中没有依赖的项,先执行。
编译包:根据操作图执行。我们都知道包之间是有依赖关系的,我们需要提前知道谁依赖谁,最终对它们生成一个排序好的编译顺序的项,让构建时保证包编译时所依赖的包是已编译的。
而缓存则就是在编译前通过操作图计算出hash去寻找缓存,如果没有缓存则才去编译,编译完后会根据当前操作图的hash去写出缓存到go-build(其实他在编译时会存放到当前builder.workDir,最后移动到GOCACHE目录。
缓存包: go/internal/cache
该包内包含了对缓存文件的读取、hash的生成、以及对缓存文件的修改时间等操作。具体为:
- 通过写入byte内容,然后计算该内容得到哈希。
- 通过指定文件,读取文件内容来计算得到哈希。
- 读取指定hash的缓存内容,如读缓存的包、读缓存的gofiles等。
缓存中的hash
获取操作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/v2@v2.4.3/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操作。而这也会最终导致在磁盘性能低、文件系统不行的状况下,比较耗时。(另外Windows会存在一些文件扫描等行为,也会大大降低性能)
最终拿着上文中的内容,再利用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中的
/
符号
如何寻找缓存文件
我们得到actionId后,就可以去获取缓存文件了。
下面有两种方式:
- 读取target文件中的buildId,判断buildId中是否存在actionId,如果存在则代表拿到了缓存!
- 通过actionId读取action缓存(go-build中的-a文件), 直接找到对应的actionid的
<actionid>-a
文件,然后读到内存判断文件大小是否合理,合理就读取里面的outputID,去找到对应的<outputID>-d
文件,它就是最终要拿到的缓存文件了
下面分别是对已知target文件的actionID对比判断缓存文件是否有效,与通过actionID去go-build缓存目录中读取缓存文件
存在的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的
阅读: cache.GetFile: view codes
查看缓存文件是否存在,存在则需要读该输出文件的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文件递归速度
我们准备了相同的大量的文件和文件夹,通过相同go代码,在windows和linux下,来实际测试下go处理的速度
测试代码参见:
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来进行的,缓存单位是包(编译单位也是包)
- 计算缓存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