この記事は How “go build” Works の翻訳記事です。
go buildはどのようにして最も単純なGolangプログラムをコンパイルしているのでしょうか?
この記事ではその疑問を解消することを目標にしています。
最も単純な以下のプログラムについて考えてみましょう。
// main.go
package main
func main() {}
go build main.goを実行すると、1.1Mbの実行可能ファイルmainが出力され、何も実行されません。 この何もしないバイナリを作成するために、go buildは何をしたのでしょうか?
go buildコマンドはいくつかの便利なオプションを提供しています。
-
-work:go buildは、作業ファイル用の一時フォルダーを作成します。 この引数は、そのフォルダーの場所を出力し、ビルド後に削除しません -
-a: Golangは、以前にビルドされたパッケージをキャッシュします。-aはgo buildにキャッシュを無視させるので、ビルドはすべてのステップを出力します -
-p 1: これにより、処理が単一スレッドで行われるように設定され、出力が線形にログに記録されます -
-x:go buildは、compileなどの他のGolangツールのラッパーです。-xは、これらのツールに送信されるコマンドと引数を出力します
go build -work -a -p 1 -x main.goを実行するとmainだけでなく大量のログが出て、buildでmainを作成する際に行われていることを我々に教えてくれます。
ログはまず以下の内容を出力します。
WORK=/var/folders/rw/gtb29xf92fv23f0zqsg42s840000gn/T/go-build940616988
これは、構造が次のような作業ディレクトリです。
├── b001
│ ├── _pkg_.a
│ ├── exe
│ ├── importcfg
│ └── importcfg.link
├── b002
│ └── ...
├── b003
│ └── ...
├── b004
│ └── ...
├── b006
│ └── ...
├── b007
│ └── ...
└── b008
└── ...
go buildは、完了する必要のあるタスクのアクショングラフを定義します。
このグラフの各アクションは、独自のサブディレクトリ(NewObjdirで定義)を取得します。
グラフの最初のノードb001は、メインバイナリをコンパイルするためのルートタスクです。
依存する各アクションの数は大きく、最後はb008です。 (b005がどこに行ったのかわかりませんが問題ないと思われるので割愛します)
b008
実行される最初のアクションは、グラフの末端のb008です。
mkdir -p $WORK/b008/
cat >$WORK/b008/importcfg << 'EOF'
# import config
EOF
cd /<..>/src/runtime/internal/sys
/<..>/compile
-o $WORK/b008/_pkg_.a
-trimpath "$WORK/b008=>"
-p runtime/internal/sys
-std
-+
-complete
-buildid gEtYPexVP43wWYWCxFKi/gEtYPexVP43wWYWCxFKi
-goversion go1.14.7
-D ""
-importcfg $WORK/b008/importcfg
-pack
-c=16
./arch.go ./arch_amd64.go ./intrinsics.go ./intrinsics_common.go ./stubs.go ./sys.go ./zgoarch_amd64.go ./zgoos_darwin.go ./zversion.go
/<..>/buildid -w $WORK/b008/_pkg_.a
cp $WORK/b008/_pkg_.a /<..>/Caches/go-build/01/01b...60a-d
b008では
- アクションディレクトリを作成します(すべてのアクションがこれを行うため、以降ではこの記述を省略します)
- ツール
compileで使用するimportcfgファイルを作成します(空です) - ディレクトリを
runtime/internal/sysパッケージのソースフォルダに変更します。 このパッケージには、ランタイムで使用される定数が含まれています - パッケージをコンパイルします
-
buildidを使用してメタデータをパッケージに書き込み(-w)、パッケージをgo-buildキャッシュにコピーします(すべてのパッケージがキャッシュされるため、以降ではこの記述を省略します)
これをツールcompileに送信された引数に分解してみましょう(go tool compile --helpでも説明されています)。
-
-o出力先のファイル -
-trimpathソースファイルのパスからprefix$WORK/b008=>を取り除く -
-pimportで使われるパッケージ名をセット -
-stdcompiling standard library(現時点ではよくわかりませんでした) -
-+compiling runtime(これもわかりませんでした) -
-completeコンパイラはCまたはアセンブリではなく完全なパッケージを出力 -
-buildidメタデータにbuild idを付与する -
-goversionコンパイルされたパッケージに必要なバージョン -
-Dローカルなインポートで使う相対パスは"" -
-importcfgインポート設定ファイルは他のパッケージを参照 -
-packパッケージをオブジェクトファイル.oではなくアーカイブ.aとして作る -
-cビルド時にどれだけ並列で処理するか - パッケージ内のファイルのリスト
これらの引数のほとんどはすべてのcompileコマンドで同じなので、以降ではこの記述を省略します。
b008の出力はruntime/internal/sysに対応している$WORK/b008/_pkg_.aというアーカイブファイルです。
buildid
buildidとは何かを説明しましょう。
buildidの形式は<actionid>/<contentid>です。
これは、パッケージをキャッシュして go buildのパフォーマンスを向上させるためのインデックスとして使用されます。
<actionid>は、アクション(すべての呼び出し、引数、および入力ファイル)のハッシュです。 <contentid>は、出力 .aファイルのハッシュです。
go buildアクションごとに、同じ<actionid>を持つ別のアクションによって作成されたコンテンツをキャッシュで検索できます。
これは buildid.goに実装されています。
buildidはメタデータとしてファイルに保存されるため、<contentid>を取得するために毎回ハッシュする必要はありません。 このIDは、 go tool buildid <file>で確認できます(バイナリでも機能します)。
上記の b008のログでは、buildIDは compileツールによってgEtYPexVP43wWYWCxFKi/gEtYPexVP43wWYWCxFKiとして設定されています。
これは単なるプレースホルダーであり、後でキャッシュされる前に、 go tool buildid -wで正しいgEtYPexVP43wWYWCxFKi/b-rPboOuD0POrlJWPTEiに上書きされます。
b007
次はb007です
cat >$WORK/b007/importcfg << 'EOF'
# import config
packagefile runtime/internal/sys=$WORK/b008/_pkg_.a
EOF
cd /<..>/src/runtime/internal/math
/<..>/compile
-o $WORK/b007/_pkg_.a
-p runtime/internal/math
-importcfg $WORK/b007/importcfg
...
./math.go
-
packagefile runtime/internal/sys=$WORK/b008/_pkg_.aと書かれたimportcfgを作成しています これはb007がb008に依存していることを示します -
runtime/internal/mathをコンパイルmath.goの中身を覗いてみると確かにb008で作られたruntime/internal/sysをインポートしています
b007の出力はruntime/internal/mathに対応している$WORK/b007/_pkg_.aというアーカイブファイルです。
b006
cat >$WORK/b006/go_asm.h << 'EOF'
EOF
cd /<..>/src/runtime/internal/atomic
/<..>/asm
-I $WORK/b006/
-I /<..>/go/1.14.7/libexec/pkg/include
-D GOOS_darwin
-D GOARCH_amd64
-gensymabis
-o $WORK/b006/symabis
./asm_amd64.s
/<..>/asm
-I $WORK/b006/
-I /<..>/go/1.14.7/libexec/pkg/include
-D GOOS_darwin
-D GOARCH_amd64
-o $WORK/b006/asm_amd64.o
./asm_amd64.s
cat >$WORK/b006/importcfg << 'EOF'
# import config
EOF
/<..>/compile
-o $WORK/b006/_pkg_.a
-p runtime/internal/atomic
-symabis $WORK/b006/symabis
-asmhdr $WORK/b006/go_asm.h
-importcfg $WORK/b006/importcfg
...
./atomic_amd64.go ./stubs.go
/<..>/pack r $WORK/b006/_pkg_.a $WORK/b006/asm_amd64.o
ここで、通常の.goファイルから抜け出し、低レベルのGoアセンブリ.sファイルの処理を開始します。
- ヘッダファイル
go_asm.hを作成 - 低レベルな関数が集まった
runtime/internal/atomicパッケージに移動 - ツール
go tool asm(go tool asm --helpで説明)を実行して、symabis"Symbol Application Binary Interfaces(ABI)ファイル" を作成し、次にオブジェクトファイルasm_amd64.oを作成 -
compileを使用して、symabisファイルと-asmhdrを含むヘッダーを含む_pkg_.aファイルを作成 -
packコマンドでasm_amd64.oを_pkg_.aの中に加える
asmツールはここでは以下の引数を伴って呼び出されています。
-
-I: アクションb007およびlibexec/pkg/includesフォルダーを含めます。includesには3つのファイルasm_ppc64x.h、funcdata.hとtextflag.hがあり、すべて低レベルの関数定義があります。 例えば、FIXED_FRAMEは、スタックフレームの固定部分のサイズを定義します -
-D: 事前定義されたシンボルを含める -
-gensymabis:symabisファイルを作成する -
-o: 出力先のファイル
b006の出力はruntime/internal/atomicに対応している$WORK/b006/_pkg_.aというアーカイブファイルです。
b004
cd /<..>/src/internal/cpu
/<..>/asm ... -o $WORK/b004/symabis ./cpu_x86.s
/<..>/asm ... -o $WORK/b004/cpu_x86.o ./cpu_x86.s
/<..>/compile ... -o $WORK/b004/_pkg_.a ./cpu.go ./cpu_amd64.go ./cpu_x86.go
/<..>/pack r $WORK/b004/_pkg_.a $WORK/b004/cpu_x86.o
b004は対象がinternal/cpuに変わった以外はb006と同様です。
最初にsymabisとオブジェクトファイルをcpu_x86.sをアセンブルして作成して、goファイルをコンパイルした後、それらを合わせて、アーカイブ_pkg_.aを作成します。
b004の出力はinternal/cpuに対応している$WORK/b004/_pkg_.aというアーカイブファイルです。
b003
cat >$WORK/b003/go_asm.h << 'EOF'
EOF
cd /<..>/src/internal/bytealg
/<..>/asm ... -o $WORK/b003/symabis ./compare_amd64.s ./count_amd64.s ./equal_amd64.s ./index_amd64.s ./indexbyte_amd64.s
cat >$WORK/b003/importcfg << 'EOF'
# import config
packagefile internal/cpu=$WORK/b004/_pkg_.a
EOF
/<..>/compile ... -o $WORK/b003/_pkg_.a -p internal/bytealg ./bytealg.go ./compare_native.go ./count_native.go ./equal_generic.go ./equal_native.go ./index_amd64.go ./index_native.go ./indexbyte_native.go
/<..>/asm ... -o $WORK/b003/compare_amd64.o ./compare_amd64.s
/<..>/asm ... -o $WORK/b003/count_amd64.o ./count_amd64.s
/<..>/asm ... -o $WORK/b003/equal_amd64.o ./equal_amd64.s
/<..>/asm ... -o $WORK/b003/index_amd64.o ./index_amd64.s
/<..>/asm ... -o $WORK/b003/indexbyte_amd64.o ./indexbyte_amd64.s
/<..>/pack r $WORK/b003/_pkg_.a $WORK/b003/compare_amd64.o $WORK/b003/count_amd64.o $WORK/b003/equal_amd64.o $WORK/b003/index_amd64.o $WORK/b003/indexbyte_amd64.o
b003もやることはb004やb006と同じです。
このパッケージの主な問題は、多くのオブジェクトファイル.oを作成するために複数の.sファイルがあり、それぞれを_pkg_.aファイルに追加する必要があることです。
b003の出力はinternal/bytealgに対応している$WORK/b003/_pkg_.aというアーカイブファイルです。
b002
cat >$WORK/b002/go_asm.h << 'EOF'
EOF
cd /<..>/src/runtime
/<..>/asm
...
-o $WORK/b002/symabis
./asm.s ./asm_amd64.s ./duff_amd64.s ./memclr_amd64.s ./memmove_amd64.s ./preempt_amd64.s ./rt0_darwin_amd64.s ./sys_darwin_amd64.s
cat >$WORK/b002/importcfg << 'EOF'
# import config
packagefile internal/bytealg=$WORK/b003/_pkg_.a
packagefile internal/cpu=$WORK/b004/_pkg_.a
packagefile runtime/internal/atomic=$WORK/b006/_pkg_.a
packagefile runtime/internal/math=$WORK/b007/_pkg_.a
packagefile runtime/internal/sys=$WORK/b008/_pkg_.a
EOF
/<..>/compile
-o $WORK/b002/_pkg_.a
...
-p runtime
./alg.go ./atomic_pointer.go ./cgo.go ./cgocall.go ./cgocallback.go ./cgocheck.go ./chan.go ./checkptr.go ./compiler.go ./complex.go ./cpuflags.go ./cpuflags_amd64.go ./cpuprof.go ./cputicks.go ./debug.go ./debugcall.go ./debuglog.go ./debuglog_off.go ./defs_darwin_amd64.go ./env_posix.go ./error.go ./extern.go ./fastlog2.go ./fastlog2table.go ./float.go ./hash64.go ./heapdump.go ./iface.go ./lfstack.go ./lfstack_64bit.go ./lock_sema.go ./malloc.go ./map.go ./map_fast32.go ./map_fast64.go ./map_faststr.go ./mbarrier.go ./mbitmap.go ./mcache.go ./mcentral.go ./mem_darwin.go ./mfinal.go ./mfixalloc.go ./mgc.go ./mgcmark.go ./mgcscavenge.go ./mgcstack.go ./mgcsweep.go ./mgcsweepbuf.go ./mgcwork.go ./mheap.go ./mpagealloc.go ./mpagealloc_64bit.go ./mpagecache.go ./mpallocbits.go ./mprof.go ./mranges.go ./msan0.go ./msize.go ./mstats.go ./mwbbuf.go ./nbpipe_pipe.go ./netpoll.go ./netpoll_kqueue.go ./os_darwin.go ./os_nonopenbsd.go ./panic.go ./plugin.go ./preempt.go ./preempt_nonwindows.go ./print.go ./proc.go ./profbuf.go ./proflabel.go ./race0.go ./rdebug.go ./relax_stub.go ./runtime.go ./runtime1.go ./runtime2.go ./rwmutex.go ./select.go ./sema.go ./signal_amd64.go ./signal_darwin.go ./signal_darwin_amd64.go ./signal_unix.go ./sigqueue.go ./sizeclasses.go ./slice.go ./softfloat64.go ./stack.go ./string.go ./stubs.go ./stubs_amd64.go ./stubs_nonlinux.go ./symtab.go ./sys_darwin.go ./sys_darwin_64.go ./sys_nonppc64x.go ./sys_x86.go ./time.go ./time_nofake.go ./timestub.go ./trace.go ./traceback.go ./type.go ./typekind.go ./utf8.go ./vdso_in_none.go ./write_err.go
/<..>/asm ... -o $WORK/b002/asm.o ./asm.s
/<..>/asm ... -o $WORK/b002/asm_amd64.o ./asm_amd64.s
/<..>/asm ... -o $WORK/b002/duff_amd64.o ./duff_amd64.s
/<..>/asm ... -o $WORK/b002/memclr_amd64.o ./memclr_amd64.s
/<..>/asm ... -o $WORK/b002/memmove_amd64.o ./memmove_amd64.s
/<..>/asm ... -o $WORK/b002/preempt_amd64.o ./preempt_amd64.s
/<..>/asm ... -o $WORK/b002/rt0_darwin_amd64.o ./rt0_darwin_amd64.s
/<..>/asm ... -o $WORK/b002/sys_darwin_amd64.o ./sys_darwin_amd64.s
/<..>/pack r $WORK/b002/_pkg_.a $WORK/b002/asm.o $WORK/b002/asm_amd64.o $WORK/b002/duff_amd64.o $WORK/b002/memclr_amd64.o $WORK/b002/memmove_amd64.o $WORK/b002/preempt_amd64.o $WORK/b002/rt0_darwin_amd64.o $WORK/b002/sys_darwin_amd64.o
b002を見ればこれまでのアクションがなぜ必要だったのかわかります。
b002はGoのバイナリの実行に必要なruntimeパッケージ全てを含んでいます。例えば、b002には mgc.goというGoのGCの実装も含まれています。これはb004(internal/cpu)とb006(runtime/internal/atomic)をインポートしています。
b002はコアライブラリの中では最も複雑なパッケージかもしれませんが、ビルド自体は今までと同じ工程で行われます。つまりasm、compileして出力されたファイルをpackして_pkg_.aにしています。
b002の出力はruntimeに対応している$WORK/b002/_pkg_.aというアーカイブファイルです。
b001
cat >$WORK/b001/importcfg << 'EOF'
# import config
packagefile runtime=$WORK/b002/_pkg_.a
EOF
cd /<..>/main
/<..>/compile ... -o $WORK/b001/_pkg_.a -p main ./main.go
cat >$WORK/b001/importcfg.link << 'EOF'
packagefile command-line-arguments=$WORK/b001/_pkg_.a
packagefile runtime=$WORK/b002/_pkg_.a
packagefile internal/bytealg=$WORK/b003/_pkg_.a
packagefile internal/cpu=$WORK/b004/_pkg_.a
packagefile runtime/internal/atomic=$WORK/b006/_pkg_.a
packagefile runtime/internal/math=$WORK/b007/_pkg_.a
packagefile runtime/internal/sys=$WORK/b008/_pkg_.a
EOF
/<..>/link
-o $WORK/b001/exe/a.out
-importcfg $WORK/b001/importcfg.link
-buildmode=exe
-buildid=yC-qrh2sY_qI0zh2-NE7/owNzOBTqPO00FkqK0_lF/HPXqvMz_4PvKsQzqGWgD/yC-qrh2sY_qI0zh2-NE7
-extld=clang
$WORK/b001/_pkg_.a
mv $WORK/b001/exe/a.out main
First it builds an importcfg that includes runtime built in b002 to then compile main.go to pkg.a
- 最初に、
b002のruntimeを含むようにしてimportcfgを作成した後、main.goをコンパイルして_pkg_.aを作成します。 - 以前に登場した全パッケージに加えて
command-line-arguments=$WORK/b001/_pkg_.aを含むimportcfg.linkを作成したら、linkコマンドでそれらをリンクして実行ファイルを作成します。 - 最後に
mainにリネームして出力先に移動します。
linkの引数の補足をしておきましょう。
-
-buildmode: 実行ファイルをビルドする -
-extld: 外部のリンカを参照する
ようやくお目当てのものが手に入りました。
b001から生まれるのがmainバイナリです。
Bazelとの類似点
効率的なキャッシュを実現するためのアクショングラフの作成は、Bazelが高速ビルドに使用するビルドツールと同じアイデアです。
Golangの actionidとcontentidは、Bazelがキャッシュで使用するactioncacheと content-addressable store(CAS)に対応しています。
BazelはGoogleの製品であり、Golangも同様です。 彼らがソフトウェアを迅速そして正確にビルドする方法について同様の哲学をもったことは非常に合理的と言えるでしょう。
Bazelの rules_goパッケージでは、builderコードで go buildを再実装する方法を確認できます。
アクショングラフ、フォルダ管理、およびキャッシュはBazelによって外部で処理されるため、これは非常にクリーンな実装です。
次のステップへ
go buildは、今回のような何もしないプログラムでも、それをコンパイルするために多くのことを行っていました。
ツール( compile asm)やその入力ファイルと出力ファイル(.a .o .s)については今回はあまり詳しく説明しませんでした。
また、今回は最も基本的なプログラムをコンパイルしているだけです。
次のようにしてコンパイルをもっと複雑なものにできます。
- 他のパッケージをインポートする 例えば
Hello worldを出力するためfmtをインポートするとさらに23個のアクションがアクショングラフに追加される - 外部パッケージを参照するために
go.modを使用する -
GOOSとGOARCHの値を変えて他のアーキテクチャ向けにビルドする 例えば、wasm向けのコンパイルではアクションや引数の内容が全く異なってくる
go buildを実行してログを検査することは、Goコンパイラがどのように機能するかを学ぶためのアプローチとしてはトップダウンなアプローチです。 基礎から学びたい場合は次のようなリソースに飛び込むのに最適な出発点です。
- Introduction to the Go compiler
- Go: Overview of the Compiler
- Go at Google: Language Design in the Service of Software Engineering
- build.go
- compile/main.go