【深度解析】'go build'缓存机制:揭秘Windows下缓慢的原因

引言

本文主要围绕 go build 的缓存hash计算与获取缓存文件来编写。

阅读时可根据提供的源代码一起看。

笔者是Windows系统用户,在七牛实训营开发的GO+ build在Windows下慢的问题中发现 go buildgo list -export在一些需要编译的场景下执行的很慢,即使是有缓存的情况。网上有很多说法大多都是说关闭杀毒软件、关闭磁盘扫描等,但并未清楚的描述为什么。

带着这个疑问,接下来我将围绕go build这个命令,基于源码和文档,来分析其执行过程,看看其缓存到底是如何做到的,又是如何命中缓存的,以及为什么这么慢。

go build编译的内容,会缓存到GOCACHE env中,它的缓存目录如下:

|go-build
|--[hashDir[:2]]  (文件哈希值前2位,减少太碎片化)
|----[hashFile]-d  (compile file: package or bin)
|----[hashFile]-a  (action)
  1. 以上文件,在编译包后会将内容从 builder workDir 复制到go-build(GOCACHE)目录。

  2. 这些个缓存文件中也写入了在构建时生成的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的生成、以及对缓存文件的修改时间等操作。具体为:

  1. 通过写入byte内容,然后计算该内容得到哈希。
  2. 通过指定文件,读取文件内容来计算得到哈希。
  3. 读取指定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后,就可以去获取缓存文件了。

下面有两种方式:

  1. 读取target文件中的buildId,判断buildId中是否存在actionId,如果存在则代表拿到了缓存!
  2. 通过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操作,文件读写。具体影响因素包括:

  1. 磁盘速度
  2. Windows文件系统,它会比macos和Linux慢
  3. 杀毒软件干扰
  4. 磁盘扫描软件干扰

这就是为什么,网上只要有人提起 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

结论

  1. 构建缓存的文件是存储在GOCACHE ENV的目录下
  2. 缓存哈希是通过actionID来进行的,缓存单位是包(编译单位也是包)
  3. 计算缓存hash它存在大量的IO操作,依赖越多,那么IO操作就越多。
  4. 无论是否标准库还是第三方库,都会计算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

posted @ 2024-03-08 21:00  LiusCraft  阅读(755)  评论(0编辑  收藏  举报