
本文基于 Go 1.25.0 源码进行分析
前言
执行 go build 时,Go 主要做了三件事:
- 加载(Load):找到所有需要编译的包(包括第三方包),解析 import 关系
- 编译(Compile):把每个包的
.go文件编译为.a文件 - 链接(Link):把所有
.a文件链接成最终的可执行文件
第三方包在这三个阶段都以和标准库完全相同的方式参与编译。
1. go build 的入口
go build 的入口函数是 runBuild:
// src/cmd/go/internal/work/build.go
func runBuild(ctx context.Context, cmd *base.Command, args []string) {
modload.InitWorkfile()
BuildInit()
b := NewBuilder("")
defer func() {
if err := b.Close(); err != nil {
base.Fatal(err)
}
}()
pkgs := load.PackagesAndErrors(ctx, load.PackageOpts{AutoVCS: true}, args)
load.CheckPackageErrors(pkgs)
// ...
a := &Action{Mode: "go build"}
for _, p := range pkgs {
a.Deps = append(a.Deps, b.AutoAction(ModeBuild, depMode, p))
}
b.Do(ctx, a)
}
整个流程分三步:
load.PackagesAndErrors:加载所有包(递归解析 import,定位第三方包源码目录)b.AutoAction:为每个包构建 Action 依赖图(编译 + 链接的任务计划)b.Do:并发执行 Action 图(真正调用编译器和链接器)
2. Action:编译任务的抽象
Go 把整个构建过程建模为一个有向无环图(DAG),图中的每个节点叫 Action:
// src/cmd/go/internal/work/action.go
type Action struct {
Mode string // "build"、"link"、"install" 等
Package *load.Package // 对应的包
Deps []*Action // 前置依赖(必须先完成的 Action)
Actor Actor // 执行器(编译 or 链接)
Objdir string // 中间产物目录
Target string // 目标文件路径
built string // 实际构建产物路径
actionID cache.ActionID // 构建缓存 key
buildID string // 构建产物 ID
// ...
}
每个包(不管是标准库、你的代码还是第三方包)都对应一个 Mode="build" 的 Action。如果最终目标是 main 包,还会有一个 Mode="link" 的 Action。
3. 构建 Action 图:AutoAction → CompileAction / LinkAction
AutoAction 根据包名分类:
// src/cmd/go/internal/work/action.go
func (b *Builder) AutoAction(mode, depMode BuildMode, p *load.Package) *Action {
if p.Name == "main" {
return b.LinkAction(mode, depMode, p)
}
return b.CompileAction(mode, depMode, p)
}
3.1 CompileAction:递归构建编译依赖图
// src/cmd/go/internal/work/action.go
func (b *Builder) CompileAction(mode, depMode BuildMode, p *load.Package) *Action {
// ...
a := b.cacheAction("build", p, func() *Action {
a := &Action{
Mode: "build",
Package: p,
Actor: newBuildActor(p, p.Internal.Cover.GenMeta),
Objdir: b.NewObjdir(),
}
if p.Error == nil || !p.Error.IsImportCycle {
for _, p1 := range p.Internal.Imports {
a.Deps = append(a.Deps, b.CompileAction(depMode, depMode, p1))
}
}
// ...
return a
})
// ...
return a
}
遍历 p.Internal.Imports(这个包 import 的所有包),为每个依赖递归创建 CompileAction,并作为当前 Action 的 Deps。
如果 main 包 import 了 github.com/gin-gonic/gin,而 gin 又 import 了 net/http、encoding/json 等,所有这些包都会递归地生成各自的 build Action,形成一棵依赖树。
cacheAction 保证同一个包只创建一个 Action,避免重复编译。
3.2 LinkAction:构建链接 Action
// src/cmd/go/internal/work/action.go
func (b *Builder) LinkAction(mode, depMode BuildMode, p *load.Package) *Action {
a := b.cacheAction("link", p, func() *Action {
a := &Action{
Mode: "link",
Package: p,
}
a1 := b.CompileAction(ModeBuild, depMode, p)
a.Actor = ActorFunc((*Builder).link)
a.Deps = []*Action{a1}
a.Objdir = a1.Objdir
a.Target = a.Objdir + filepath.Join("exe", name) + cfg.ExeSuffix
a.built = a.Target
b.addTransitiveLinkDeps(a, a1, "")
return a
})
// ...
return a
}
Link Action 的 Deps[0] 是 main 包自身的 compile Action。然后 addTransitiveLinkDeps 把所有传递依赖的 compile Action 都加入 Deps,确保链接器能拿到所有 .a 文件。
3.3 addTransitiveLinkDeps:收集所有依赖
// src/cmd/go/internal/work/action.go
func (b *Builder) addTransitiveLinkDeps(a, a1 *Action, shlib string) {
workq := []*Action{a1}
haveDep := map[string]bool{}
if a1.Package != nil {
haveDep[a1.Package.ImportPath] = true
}
for i := 0; i < len(workq); i++ {
a1 := workq[i]
for _, a2 := range a1.Deps {
if a2.Package == nil || (a2.Mode != "build-install" && a2.Mode != "build") || haveDep[a2.Package.ImportPath] {
continue
}
haveDep[a2.Package.ImportPath] = true
a.Deps = append(a.Deps, a2)
// ...
workq = append(workq, a2)
}
}
}
BFS 遍历整棵 build Action 树,把所有 Mode=="build" 的 Action 展平为 link Action 的直接依赖。这样链接器就能通过 a.Deps 拿到每一个已编译包的 .a 文件路径。
3.4 最终的 Action 图结构
假设项目结构为:
main → github.com/foo/bar → github.com/baz/qux
→ fmt
→ net/http
生成的 Action 图:
Action(link, main)
├── Action(build, main)
│ ├── Action(build, github.com/foo/bar)
│ │ ├── Action(build, github.com/baz/qux)
│ │ └── Action(build, fmt)
│ └── Action(build, net/http)
│ └── ...
├── Action(build, github.com/foo/bar) ← 展平的传递依赖
├── Action(build, github.com/baz/qux) ← 展平的传递依赖
├── Action(build, fmt) ← 展平的传递依赖
├── Action(build, net/http) ← 展平的传递依赖
└── ...
4. 执行 Action 图:Builder.Do
Do 负责并发调度所有 Action:
// src/cmd/go/internal/work/exec.go
func (b *Builder) Do(ctx context.Context, root *Action) {
all := actionList(root)
for i, a := range all {
a.priority = i
}
b.readySema = make(chan bool, len(all))
for _, a := range all {
for _, a1 := range a.Deps {
a1.triggers = append(a1.triggers, a)
}
a.pending = len(a.Deps)
if a.pending == 0 {
b.ready.push(a)
b.readySema <- true
}
}
handle := func(ctx context.Context, a *Action) {
var err error
if a.Actor != nil && (a.Failed == nil || a.IgnoreFail) {
err = a.Actor.Act(b, ctx, a)
}
b.exec.Lock()
defer b.exec.Unlock()
for _, a0 := range a.triggers {
if a0.pending--; a0.pending == 0 {
b.ready.push(a0)
b.readySema <- true
}
}
}
par := cfg.BuildP // 默认 GOMAXPROCS
for i := 0; i < par; i++ {
wg.Add(1)
go func() {
for {
select {
case _, ok := <-b.readySema:
if !ok {
return
}
b.exec.Lock()
a := b.ready.pop()
b.exec.Unlock()
handle(ctx, a)
}
}
}()
}
wg.Wait()
}
actionList做深度优先后序遍历,展开所有 Action- 计算每个 Action 的
pending(未完成的依赖数) pending == 0的 Action 进入就绪队列- 启动
GOMAXPROCS个 goroutine 作为 worker,从优先队列中取出 Action 执行 - Action 完成后,递减所有
triggers的pending,归零的 Action 进入就绪队列
所以 go build 能同时编译没有依赖关系的多个包。
5. 编译单个包:Builder.build
每个 Mode="build" 的 Action 最终调用 b.build:
// src/cmd/go/internal/work/exec.go
func (b *Builder) build(ctx context.Context, a *Action) (err error) {
p := a.Package
// 1. 检查构建缓存
if !p.BinaryOnly {
if b.useCache(a, b.buildActionID(a), p.Target, need&needBuild != 0) {
// 缓存命中,直接返回
return nil
}
}
// 2. 生成 import 配置
var icfg bytes.Buffer
fmt.Fprintf(&icfg, "# import config\n")
for i, raw := range p.Internal.RawImports {
final := p.Imports[i]
if final != raw {
fmt.Fprintf(&icfg, "importmap %s=%s\n", raw, final)
}
}
for _, a1 := range a.Deps {
p1 := a1.Package
if p1 == nil || p1.ImportPath == "" || a1.built == "" {
continue
}
fmt.Fprintf(&icfg, "packagefile %s=%s\n", p1.ImportPath, a1.built)
}
// 3. 调用编译器
gofiles := str.StringList(p.GoFiles)
objpkg := objdir + "_pkg_.a"
ofile, out, err := BuildToolchain.gc(b, a, objpkg, icfg.Bytes(), embedcfg, symabis, len(sfiles) > 0, pgoProfile, gofiles)
// 4. 汇编 .s 文件
if len(sfiles) > 0 {
ofiles, err := BuildToolchain.asm(b, a, sfiles)
objects = append(objects, ofiles...)
}
// 5. 打包为 .a 归档
if len(objects) > 0 {
BuildToolchain.pack(b, a, objpkg, objects)
}
// 6. 更新构建 ID
b.updateBuildID(a, objpkg)
a.built = objpkg
return nil
}
5.1 import 配置(importcfg)
编译器不通过文件系统查找依赖包,而是通过 importcfg 文件得到精确的映射。b.build 遍历当前 Action 的所有 Deps,生成如下格式的配置:
# import config
packagefile fmt=/home/user/.cache/go-build/ab/ab1234.../fmt.a
packagefile github.com/foo/bar=/home/user/.cache/go-build/cd/cd5678.../bar.a
每行的格式是 packagefile <import路径>=<.a文件路径>。这里的 .a 文件路径来自依赖 Action 的 a1.built 字段,即依赖包编译完成后的产物路径。
第三方包和标准库在这里完全一视同仁,都是 packagefile 指令的一行。
5.2 调用 gc 编译器
BuildToolchain.gc 最终构建如下编译命令:
// src/cmd/go/internal/work/gc.go
func (gcToolchain) gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, pgoProfile string, gofiles []string) (ofile string, output []byte, err error) {
p := a.Package
// ...
args := []any{cfg.BuildToolexec, base.Tool("compile"), "-o", ofile, "-trimpath", a.trimpath(), defaultGcFlags, gcflags}
// ...
if importcfg != nil {
sh.writeFile(objdir+"importcfg", importcfg)
args = append(args, "-importcfg", objdir+"importcfg")
}
for _, f := range gofiles {
f := mkAbs(p.Dir, f)
args = append(args, fsys.Actual(f))
}
output, err = sh.runOut(base.Cwd(), cfgChangedEnv, args...)
return ofile, output, err
}
实际执行的命令类似于:
/usr/local/go/pkg/tool/linux_amd64/compile \
-o /tmp/go-build123/_pkg_.a \
-trimpath /tmp/go-build123=> \
-p github.com/foo/bar \
-lang=go1.21 \
-importcfg /tmp/go-build123/importcfg \
-pack \
/home/user/go/pkg/mod/github.com/foo/bar@v1.2.3/bar.go \
/home/user/go/pkg/mod/github.com/foo/bar@v1.2.3/util.go
注意 .go 文件的路径。对于第三方包,这些文件位于模块缓存目录($GOMODCACHE),即 $GOPATH/pkg/mod/ 下。
p.Dir 对于第三方包指向模块缓存中的解压目录(如 /home/user/go/pkg/mod/github.com/foo/bar@v1.2.3/),而 p.GoFiles 包含该目录下的 .go 文件名列表。mkAbs(p.Dir, f) 拼出完整路径,交给编译器。
5.3 产物:.a 归档文件
编译器直接输出 .a(archive)格式的文件(-pack 标志),包含编译后的目标代码。如果有汇编文件(.s),还会额外通过 asm 汇编后,由 pack 工具合并进 .a。
最终 a.built 指向这个 .a 文件的路径,供后续的 link Action 使用。
6. 链接:Builder.link
// src/cmd/go/internal/work/exec.go
func (b *Builder) link(ctx context.Context, a *Action) (err error) {
if b.useCache(a, b.linkActionID(a), a.Package.Target, !b.IsCmdList) || b.IsCmdList {
return nil
}
importcfg := a.Objdir + "importcfg.link"
if err := b.writeLinkImportcfg(a, importcfg); err != nil {
return err
}
if err := BuildToolchain.ld(b, a, a.Target, importcfg, a.Deps[0].built); err != nil {
return err
}
b.updateBuildID(a, a.Target)
a.built = a.Target
return nil
}
6.1 链接器的 importcfg
writeLinkImportcfg 生成链接阶段的包映射:
// src/cmd/go/internal/work/exec.go
func (b *Builder) writeLinkImportcfg(a *Action, file string) error {
var icfg bytes.Buffer
for _, a1 := range a.Deps {
p1 := a1.Package
if p1 == nil {
continue
}
fmt.Fprintf(&icfg, "packagefile %s=%s\n", p1.ImportPath, a1.built)
}
fmt.Fprintf(&icfg, "modinfo %q\n", modload.ModInfoData(info))
return b.Shell(a).writeFile(file, icfg.Bytes())
}
由于 addTransitiveLinkDeps 已经把所有传递依赖展平到 link Action 的 Deps 中,这里遍历 a.Deps 就能拿到全部包的 .a 路径。生成的 importcfg.link 类似:
packagefile main=/tmp/go-build123/main/_pkg_.a
packagefile github.com/foo/bar=/tmp/go-build123/bar/_pkg_.a
packagefile github.com/baz/qux=/tmp/go-build123/qux/_pkg_.a
packagefile fmt=/home/user/.cache/go-build/xx/fmt.a
packagefile net/http=/home/user/.cache/go-build/yy/http.a
...
6.2 调用链接器
// src/cmd/go/internal/work/gc.go
func (gcToolchain) ld(b *Builder, root *Action, targetPath, importcfg, mainpkg string) error {
// ...
return b.Shell(root).run(dir, root.Package.ImportPath, env, cfg.BuildToolexec,
base.Tool("link"), "-o", targetPath, "-importcfg", importcfg, ldflags, mainpkg)
}
实际执行的命令类似于:
/usr/local/go/pkg/tool/linux_amd64/link \
-o /tmp/go-build123/exe/myapp \
-importcfg /tmp/go-build123/importcfg.link \
-buildmode=exe \
/tmp/go-build123/main/_pkg_.a
链接器从 mainpkg(main 包的 .a)出发,根据 importcfg.link 找到所有依赖包的 .a 文件,将它们链接为最终的可执行文件。
7. 构建缓存
每次编译不需要重新执行,Go 有一套基于内容寻址的构建缓存机制。
7.1 buildActionID:计算缓存 key
// src/cmd/go/internal/work/exec.go
func (b *Builder) buildActionID(a *Action) cache.ActionID {
p := a.Package
h := cache.NewHash("build " + p.ImportPath)
fmt.Fprintf(h, "compile\n")
if p.Module != nil {
fmt.Fprintf(h, "go %s\n", p.Module.GoVersion)
}
fmt.Fprintf(h, "goos %s goarch %s\n", cfg.Goos, cfg.Goarch)
fmt.Fprintf(h, "import %q\n", p.ImportPath)
// 编译器版本
fmt.Fprintf(h, "compile %s %q %q\n", b.toolID("compile"), forcedGcflags, p.Internal.Gcflags)
// 所有输入文件的哈希
inputFiles := str.StringList(p.GoFiles, p.CgoFiles, p.CFiles, /* ... */)
for _, file := range inputFiles {
fmt.Fprintf(h, "file %s %s\n", file, b.fileHash(filepath.Join(p.Dir, file)))
}
// 所有依赖包的 content ID
for _, a1 := range a.Deps {
p1 := a1.Package
if p1 != nil {
fmt.Fprintf(h, "import %s %s\n", p1.ImportPath, contentID(a1.buildID))
}
}
return h.Sum()
}
Action ID 综合了:编译器版本、平台、编译标志、所有源文件内容的哈希、所有依赖包编译产物的内容 ID。任何一项变化都会导致缓存失效。
对于第三方包,p.Dir 指向模块缓存目录,p.GoFiles 是缓存中的源文件。由于模块缓存是只读的、不可变的(同一 module@version 的内容不会改变),第三方包的源文件哈希天然稳定,缓存命中率很高。
7.2 useCache:查找缓存
// src/cmd/go/internal/work/buildid.go
func (b *Builder) useCache(a *Action, actionHash cache.ActionID, target string, printOutput bool) (ok bool) {
a.actionID = actionHash
actionID := buildid.HashToString(actionHash)
if cfg.BuildA {
// -a 强制重新编译
return false
}
// 检查目标文件的 buildID 是否以 actionID 开头
if target != "" {
buildID, _ := buildid.ReadFile(target)
if strings.HasPrefix(buildID, actionID+buildIDSeparator) {
a.buildID = buildID
a.built = target
return true
}
}
// 检查缓存
c := cache.Default()
if file, _, err := cache.GetFile(c, actionHash); err == nil {
a.built = file
// ...
return true
}
return false
}
先检查已安装的目标文件,再查 $GOCACHE 中的缓存。命中后直接设置 a.built 为缓存文件路径,跳过编译。
8. 第三方包与标准库的区别
从编译流程的角度看,第三方包和标准库的处理几乎完全相同。唯一的差异:
| 维度 | 标准库 | 第三方包 |
|---|---|---|
p.Dir | $GOROOT/src/<pkg> | $GOMODCACHE/<module>@<version>/<pkg> |
p.Standard | true | false |
p.Module | nil(或 std) | 非 nil,含 Path、Version、GoVersion |
| 编译标志 | 可能加 -std | 根据 Module.GoVersion 设置 -lang |
builtin / unsafe | 跳过编译(a.Actor = nil) | 正常编译 |
gc 函数中体现了 -lang 和 -std 的差异:
// src/cmd/go/internal/work/gc.go
defaultGcFlags := []string{"-p", pkgpath}
if p.Module != nil {
v := p.Module.GoVersion
if allowedVersion(v) {
vers = v
}
}
defaultGcFlags = append(defaultGcFlags, "-lang=go"+gover.Lang(vers))
if p.Standard {
defaultGcFlags = append(defaultGcFlags, "-std")
}
-lang 告诉编译器该包要求的最低 Go 版本,控制语言特性的可用性。第三方包的 -lang 版本来自其所属模块的 go.mod 中声明的 go 指令,这就是为什么一个声明 go 1.18 的模块可以使用泛型,而声明 go 1.17 的不行。
9. 全链路总结
go build ./cmd/myapp
│
├─ load.PackagesAndErrors ← 解析 import,定位包源码(标准库/模块缓存)
│ └─ 递归解析所有 import,生成 []*load.Package
│ 每个 Package 包含:Dir(源码目录)、GoFiles(.go 文件列表)、Imports(依赖列表)
│
├─ b.AutoAction ← 构建 Action DAG
│ ├─ b.CompileAction ← 为每个包创建 build Action
│ │ └─ 递归为所有 import 依赖创建 CompileAction
│ └─ b.LinkAction ← 为 main 包创建 link Action
│ └─ addTransitiveLinkDeps ← 展平所有传递依赖
│
└─ b.Do ← 并发执行 Action 图
├─ 叶子 Action 先执行(无依赖的包先编译)
├─ b.build(action) ← 编译单个包
│ ├─ useCache → 缓存命中则跳过
│ ├─ 生成 importcfg(依赖包路径 → .a 文件路径)
│ ├─ gc() → compile -importcfg ... *.go → _pkg_.a
│ ├─ asm() → 汇编 .s 文件
│ ├─ pack() → 打包为 .a
│ └─ updateBuildID → 写入缓存
│
└─ b.link(action) ← 链接最终二进制
├─ useCache → 缓存命中则跳过
├─ writeLinkImportcfg(所有依赖的 import→.a 映射)
├─ ld() → link -importcfg ... main.a → myapp
└─ updateBuildID → 写入缓存
10. 源码文件索引
| 文件 | 作用 |
|---|---|
cmd/go/internal/work/build.go | go build 入口 runBuild |
cmd/go/internal/work/action.go | Action 结构体、CompileAction / LinkAction / addTransitiveLinkDeps |
cmd/go/internal/work/exec.go | Do(并发调度)、build(编译单包)、link(链接)、buildActionID(缓存 key)、writeLinkImportcfg |
cmd/go/internal/work/gc.go | gc(调用 compile)、ld(调用 link) |
cmd/go/internal/work/buildid.go | useCache(构建缓存查找) |
cmd/go/internal/load/pkg.go | 包加载,将 import 路径解析为 Package 结构 |

2408

被折叠的 条评论
为什么被折叠?



