Goのbuildを眺めてみる
はじめに
最近Goを使用する機会があり、Goは触れたことがない言語であり馴染むためにどのような言語仕様であるかを知りたいと思います。
今回はGoのコンパイルはCやRustとどのように違うのか?という疑問があり、少しだけ眺めてみました。
マシンスペック
MacBook Air M2 arm64
事前知識
コンパイル
コンパイルとはGCCではこのように定義されています。
Compilation can involve up to four stages: preprocessing, compilation proper, assembly and linking, always in that order. GCC is capable of preprocessing and compiling several files either into several assembler input files, or into one assembler input file; then each assembler input file produces an object file, and linking combines all the object files (those newly compiled, and those specified as input) into an executable file.
和訳すると以下になります。
コンパイルは、前処理・(狭義の)コンパイル・アセンブル・リンクの最大4段階からなり、必ずこの順序で実行されます。
GCC は複数のソースファイルに対して前処理とコンパイルを行い、それぞれ別々のアセンブリ入力に出力することも、1つのアセンブリ入力にまとめて出力することもできます。次に、各アセンブリ入力からオブジェクトファイルが生成され、最後にリンク段階で(今回新たにコンパイルしたものも、入力として指定したものも含めた)すべてのオブジェクトファイルが結合され、実行ファイルが作られます。
噛み砕いていうと、C言語(今回はそうします)で記述されたプログラムをコンピュータが実行可能なファイルに変換することがコンパイルです。
Goのコンパイル
Goのコマンドは下記で説明されています。
その中でコンパイルにあたるのはgo buildになります。
内容は下記
Build compiles the packages named by the import paths, along with their dependencies, but it does not install the results.
If the arguments to build are a list of .go files from a single directory, build treats them as a list of source files specifying a single package.
和訳は
build は、指定したインポートパスのパッケージとその依存関係をコンパイルしますが、生成物をインストールはしません。
もし build の引数が同一ディレクトリ内の .go ファイルの一覧なら、それらを単一パッケージを構成するソースファイル群として扱います。
先日の私の記事では、Goはbuildをする際にはNullポインタなどの検知でエラーを出さないが、実行時にユーザ空間でpanicを出すことがわかっています。
Goのはbuildは何をしているのかを少し眺めてみます。
実験準備
プロジェクトの準備
mkdir -p ~/lab-go-build/cmd/app ~/lab-go-build/internal/lib
cd ~/lab-go-build
go mod init example.com/lab
ファイルの準備
# vim internal/lib/lib.go
package lib
func Add(a, b int) int { return a + b }
# vim cmd/app/main.go
package main
import (
"fmt"
"runtime"
"example.com/lab/internal/lib"
)
func main() {
fmt.Printf("GOOS=%s GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Println("2+3=", lib.Add(2, 3))
}
実験
通常のビルドとキャッシュ
# 1回目のビルド・実行
time go build ./cmd/app
./app
go build ./cmd/app 5.07s user 1.00s system 254% cpu 2.391 total
GOOS=darwin GOARCH=arm64
2+3= 5
## 2回目のビルド・実行
time go build ./cmd/app
go build ./cmd/app 0.06s user 0.08s system 225% cpu 0.063 total
ビルド内容がキャッシュされて、1回目のビルド時間より2回目の方が大きく短縮されています。
また、キャッシュの確認もします。
du -sh "$(go env GOCACHE)"
36M /Users/XXXX/Library/Caches/go-build
ディレクトリ先は、00からffまでの16進数で割り振られていると考えられるディレクトリがあり、READMEなども入っていました。
READMEの中身は
This directory holds cached build artifacts from the Go build system.
Run "go clean -cache" if the directory is getting too large.
Run "go clean -fuzzcache" to delete the fuzz cache.
See go.dev to learn more about Go.
和訳は
このディレクトリには、Go のビルドシステムによってキャッシュされたビルド成果物が保存されています。
ディレクトリが大きくなりすぎた場合は、go clean -cache を実行してください。
ファジング用キャッシュを削除するには、go clean -fuzzcache を実行してください。
詳しくは go.dev をご覧ください。
実行内容の確認
ビルド時に何が実行されているかを確認してみます。
大きい出力のため、先頭の20行くらいを記載します。
go clean -cache
go build -v -x -work ./cmd/app
WORK=/var/folders/5h/y7lq6y6x7dq030403j6jzzsh0000gn/T/go-build1647249908
internal/godebugs
internal/unsafeheader
internal/goarch
internal/byteorder
mkdir -p $WORK/b013/
mkdir -p $WORK/b016/
internal/coverage/rtcov
mkdir -p $WORK/b015/
mkdir -p $WORK/b009/
mkdir -p $WORK/b007/
example.com/lab/internal/lib
mkdir -p $WORK/b002/
echo '# import config' > $WORK/b007/importcfg # internal
echo '# import config' > $WORK/b016/importcfg # internal
echo '# import config' > $WORK/b013/importcfg # internal
echo '# import config' > $WORK/b015/importcfg # internal
cd /Users/XXXX/lab-go-build
...
アセンブリを確認
Goのアセンブリは下記のように説明されています。
The assembler is based on the input style of the Plan 9 assemblers, which is documented in detail elsewhere. If you plan to write assembly language, you should read that document although much of it is Plan 9-specific. The current document provides a summary of the syntax and the differences with what is explained in that document, and describes the peculiarities that apply when writing assembly code to interact with Go.
The most important thing to know about Go's assembler is that it is not a direct representation of the underlying machine. Some of the details map precisely to the machine, but some do not. This is because the compiler suite (see this description) needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain actually generates for that operation might not be a move instruction at all, perhaps a clear or load. Or it might correspond exactly to the machine instruction with that name. In general, machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract. The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.
The assembler program is a way to parse a description of that semi-abstract instruction set and turn it into instructions to be input to the linker. If you want to see what the instructions look like in assembly for a given architecture, say amd64, there are many examples in the sources of the standard library, in packages such as runtime and math/big. You can also examine what the compiler emits as assembly code (the actual output may differ from what you see here):
機械翻訳の和訳は下記です。
このアセンブラは Plan 9 のアセンブラの入力形式に基づいており、その詳細は別の文書に詳しく記されています。アセンブリ言語を書く予定があるなら、その多くが Plan 9 固有ではありますが、その文書を読んでおくべきです。本書では、構文の要約と、その文書で説明されている内容との差異を示し、さらに Go と連携するアセンブリコードを書く際に特有の事項を説明します。
Go のアセンブラについて最も重要なのは、基盤となる機械(ハードウェア)を直接表現したものではないという点です。細部のいくつかは機械に正確に対応しますが、そうでないものもあります。これは、コンパイラ群(この説明参照)が、通常のパイプラインでアセンブラのパスを必要としないためです。代わりに、コンパイラは一種の半抽象的な命令セットを扱い、命令選択はコード生成の後段で部分的に行われます。アセンブラはその半抽象形式を処理するため、たとえば MOV のような命令を見ても、ツールチェーンがその操作に対して実際に生成するものは必ずしも move 命令ではなく、場合によってはクリアやロードかもしれません。あるいは、その名前の機械命令に正確に一致することもあります。一般に、機械固有の操作はそのままの形で現れる傾向がありますが、メモリ移動やサブルーチンの呼び出し・リターンのような、より一般的な概念はより抽象的です。詳細はアーキテクチャごとに異なり、記述の不正確さについてはご容赦ください。状況が厳密に定義されているわけではありません。
アセンブラプログラムは、その半抽象命令セットの記述を解析し、リンカに入力する命令へと変換するための手段です。特定のアーキテクチャ(たとえば amd64)でアセンブリがどのように見えるかを確認したい場合は、標準ライブラリのソース、たとえば runtime や math/big といったパッケージに多くの例があります。また、コンパイラが出力するアセンブリコードを調べることもできます(実際の出力はここで見るものと異なる場合があります)。
plan9については下記。今回はリンクの共有までとします。 実際にアセンブリを確認してみます。
go build -o app ./cmd/app
go tool objdump -s 'main\.main$' ./app | sed -n '1,80p'
TEXT main.main(SB) /Users/XXXXX/lab-go-build/cmd/app/main.go
main.go:9 0x1000bcd50 f9400b90 MOVD 16(R28), R16
main.go:9 0x1000bcd54 d100c3f1 SUB $48, RSP, R17
main.go:9 0x1000bcd58 eb10023f CMP R16, R17
main.go:9 0x1000bcd5c 540009a9 BLS 77(PC)
main.go:9 0x1000bcd60 f8150ffe MOVD.W R30, -176(RSP)
main.go:9 0x1000bcd64 f81f83fd MOVD R29, -8(RSP)
main.go:9 0x1000bcd68 d10023fd SUB $8, RSP, R29
main.go:10 0x1000bcd6c a908ffff STP (ZR, ZR), 136(RSP)
main.go:10 0x1000bcd70 a909ffff STP (ZR, ZR), 152(RSP)
...
まとめ
今回は、Goのbuildコマンドについて少しだけ簡単にみてみました。
明示的に記事にはしていませんが、コメントを付与してもキャッシュが効かずにrebuildが走る挙動も見受けられるようでした。この辺りは別記事で追試をして明らかにしていきたいです。
Discussion