Go のバイナリを覗いてみた
人材育成室 育成メンバーチームで 研修中の はすと です。
Goは速いとよく言われはしますが、実際なぜ速いの?と聞かれると、コンパイル言語であることや、並列処理くらいしか理由を挙げることができません。そこで、もっと具体的になぜ速いのかを明確にしたいと思いました。
Goはコンパイルするとバイナリが出力され、アプリケーションは、バイナリを実行して動きます。そのため、このバイナリにGoの早さの秘密が隠されているのではないかと考え、Go のバイナリを覗いてみました。
バイナリ構造を把握する
いきなりバイナリを覗いても、訳がわからないので、まずはバイナリ構造をざっくり把握していこうと思います。
サンプルコードとして、Hello World を表示するコードを用意します。
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World")
}
これをコンパイルします。
$ go build -o ./bin/hello_go ./go/hello.go
すると、Gバイナリが出力されるのでこのファイルを使っていきます。
file コマンドでファイルタイプを覗く
中身を見る前に、fileコマンドを使ってファイル情報を見てみます。
$ file ./bin/hello_go
./bin/hello_go: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=Gvb1P_J9Q2QsWbndRE-i/BqD-me_Z_gagcH3CKmc2/NiYP7-zce4bZvI4adIfW/sc5inU9iSjuruq9zQB7Q, BuildID[sha1]=69f95bfe6774aaf03293ba9d6d361d33b7a3a764, with debug_info, not stripped
色々書いているように見えますが、必要なところだけ拾っていきます。
ELF 64-bit LSB executable, ARM aarch64
これは、ELFファイルであることと、64bit、ARM アーキテクチャであることが書かれています。
64bitとARMについて
64bitというのは、CPUが一度に処理できるデータの幅で、ARMというのは 命令セットアーキテクチャ(ISA) の種類です、CPUが「どんな命令語を理解するか」の設計の違いを表していています。
- ARM → 命令をシンプルに絞った設計(Mac M1、スマホなど)
- x86 → 複雑な命令を多く持つ設計(Intel、AMD)
ELFわからない方はこちらをご覧ください
ELFについて
先ほどファイル情報を見た際に出てきたELFについて見ていきたいと思います。
そもそも ELFとは Executable and Linkable Format の略で、LinuxをはじめとするUnix系OSで使われる実行ファイルの形式のことです。
ELFファイルは大きく3つの要素で構成されていて、ELF Header, セクション, セグメント の3つで構成されています。
ELF Header
ファイルの先頭に位置し、OSがこのファイルをELFと認識するための情報や、プログラムの実行がどのアドレスから始まるかを示すエントリポイントのアドレスも格納されています。
セクション
ELFの中身は、セクションという単位で役割ごとに分けられています。
| セクション | 内容 |
|---|---|
.text |
機械語命令(CPUが実行するコード) |
.rodata |
読み取り専用データ(文字列リテラルなど) |
.data |
初期化済みグローバル変数 |
.bss |
未初期化グローバル変数 |
.debug_info |
デバッグ情報(行番号とアドレスの対応表など) |
セグメント
OSがELFをメモリに展開する時に、セクションをそのまま1つずつ載せるのではなく、パーミッションが同じセクション同士を束ねてメモリに載せます。この束ねる単位をセグメントと言います。
| パーミッション | 対象セクション | 意味 |
|---|---|---|
| R+X | .text | 読み取り+実行可能 |
| R | .rodata | 読み取り専用 |
| RW | .data/.bss | 読み書き可能 |
つまり、セクションはリンカ・デバッガ向けの細かい単位で、セグメントはOSがメモリに展開するための単位になります。
statically linked,
これは実行前に解決済みという意味で、依存ライブラリが全てバイナリに含まれているおかげで、Goのバイナリは単体で動くことができます。
with debug_info, not stripped
これはデバッグ情報と、デバッガが使用できるシンボル情報などが削除されていない状態を表しています。消すこともできますが、このシンボル情報があることで、デバッガがアドレスと関数名を対応づけることができて、人間が読める形で追うことができます。
size コマンドでセクションごとのサイズを見る
次は、sizeコマンドを使って、ELFのセクションごとのサイズを見てみます。
$ size ./bin/hello_go
text data bss dec hex filename
1456363 42700 246328 1745391 1aa1ef ./bin/hello_go
結果を見ると、.text がかなり多いことがわかります。コード上では、fmt.Println("Hello World") しか書いていないのになぜでしょうか。これは、機械語命令(.text)に、Goランタイムやfmt などの静的リンクされたパッケージが入っているためです。
ただ、これだけでは、具体的に何が入っているのかがわからないため、関数名とアドレスの対応表であるシンボルテーブル を覗いてみます。
シンボルテーブルを覗いてみる
.textセクションには大量の機械語命令が詰め込まれていますが、どの関数がどのアドレスから始まるかを対応付けているのがシンボルテーブルで、go tool nm はそのシンボルテーブルを人間が読める形で出力してくれます。
以下を実行します。
$ go tool nm ./bin/hello_go
すると以下のような出力が大量に得られます
# アドレス / シンボルの種類 / シンボル名(パッケージ関数名)
a6010 T main.main
9e810 T os.(*File).Write
9e990 T os.(*File).wrapErr
9e780 T os.(*SyscallError).Error
9e800 T os.(*SyscallError).Unwrap
...
シンボルテーブルは大量すぎて記事には載せることができないため一部だけ載せます。
シンボルの種類
シンボルの種類にはどんなものがあるのかをまとめます。
| 記号 | 意味 |
|---|---|
T |
.text セクションの関数(公開) |
t |
.text セクションの関数(非公開) |
D |
.data セクションの変数(公開) |
d |
.data セクションの変数(非公開) |
R |
.rodata セクションの定数(公開) |
r |
.rodata セクションの定数(非公開) |
**大文字 = 公開(exported)、小文字 = 非公開(unexported)**というルールになっています。
全部を見ることはできないので、どのシンボルが多いかを集計してみます。
$ go tool nm ./bin/hello_go | awk '{print $3}' | grep -o '^[^.]*' | sort | uniq -c | sort -rn | head -20
1677 runtime
83 type:
78 reflect
44 fmt
42 syscall
40 internal/strconv
40 internal/poll
33 os
33 internal/runtime/maps
31 sync
25 internal/runtime/cgroup
24 internal/sync
23 internal/godebug
18 $f64
17 go:itab
13 unicode
11 sync/atomic
11 internal/abi
10 strconv
10 internal/runtime/exithook
結果として、runtime が圧倒的に多く、Goのランタイムがバイナリにそのまま内包されていることがわかりました。
バイナリから見えたこと
ここまで、file → size → go tool nm と覗いてきたことで、共通して見えてきたことがあります。それは、各パッケージが静的リンクされており、ELFファイルでいうところの、機械語命令(.text)が非常に多いということです。さらにそこからシンボルのリストを覗いた結果、runtime が多くを占めていることがわかりました。
また、私は fmt.Println("Hello World") しか書いていませんが、コンパイル時に、Goのランタイムやパッケージが丸ごと入っていました。この結果から何が言えるかというと、Goは全てを詰め込む設計をとっていることが見えてきたと思います。静的リンクで、バイナリに詰め込むことで実行時に探しにいく必要がなく、オーバヘッドが少なくなるため、実行速度が速くなるという原理であるということが言えそうです。
トレードオフとして、バイナリサイズは大きくなってしまいます。
まとめ
今回、Goのバイナリを覗いてみることで、普段の業務では見えない部分を知ることができて学びの連続でした。次回は、バイナリの中で最も多くを占めていた runtime の内部に焦点を当てて、さらに深く覗いていきたいと思います。
参考







