Goでmainが実行される様子を追ってみる

yamagenii
Voicy Engineering
Published in
17 min readDec 16, 2020

この記事は Voicy Advent Calendar 2020 の 16日目の記事です。

前日は minorii_jp さんの「Slackの #all_office_maint にbot取り入れたら総務の問い合わせが減った件 について書く」でした。Voicyのカルチャー醸成に最も貢献しているminorii_jpさんは元エンジニアで、自動化もなんのその!という意気込みで会社の雰囲気をよくしてくれています。

今回の記事は社内勉強会「Goリーディング会」で自分がruntimeパッケージのリーディングをした時に発表した内容です。

以下のことを学べます。

  • プログラミング言語がどのように実行されているのか
  • goroutineがどのようにタスクをアサインしているのか

ぜひハンズオンが一番なので、ぜひ手元で実行しながらやってみてください!

runtimeパッケージとは

Goのランタイムを提供するパッケージです。
普段はアプリケーションを実装するときはお目にかかからないかもしれませんが、実行時にはかならずバイナリになります。

runtimeパッケージのおかげで、アプリケーションをgoroutine上で実行することができており、Goという言語を陰で支えている存在になっています。

まずはruntimeパッケージの概要を眺めてみましょう。

  • Goのバージョン 1.14.2

goclocというライブラリでディレクトリ内の構成を確認します。

$ gocloc src/runtime
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Go 612 13262 24748 82468
Assembly 188 5436 7787 37140
C 49 614 504 2377
Python 1 135 103 366
Markdown 1 71 0 240
C Header 7 50 155 135
Makefile 1 1 3 1
-------------------------------------------------------------------------------
TOTAL 859 19569 33300 122727
-------------------------------------------------------------------------------

これをみると主要なファイルはGoとAssemblyとCで構成されていることがわかります。

  • Go : runtimeのほとんどのロジックはGoで書かれている
  • Assembly : 各アーキテクチャに対応するためのライブラリ
  • C : cgo のライブラリとして提供されているファイル

※ cgoはC言語のライブラリをGoから実行するためのライブラリです。今回は触れません。

Assemblyはアーキテクチャの種類だけファイルが存在しています。各機能はファイル名のprefixをみると良いでしょう。

prefix     役割
asm_ 共通初期実行のロジックなど、共通エントリポイントもここをみる
rt0_ プログラムのエントリポイント
atomic_ データメモリバリア
duff_ メモリコピー時の最適化の実装
libfuzzer_ Fuzzingのライブラリ
memclr メモリのクリア
memmove メモリムーブ
msan_ メモリサニタイザ メモリの破壊検知
preempt_ goroutine のメモリを確保
race_ 競合管理
sys_ システムコール
tls_ TLS

この中で rt0 はプログラムのエントリポイントを実装されています。実際 go コマンドでアプリケーションを実行した時に最初に実行されるのはこのアセンブリです。

rt0 についても確認してみましょう

ls src/runtime/rt0*
src/runtime/rt0_aix_ppc64.s src/runtime/rt0_linux_mipsx.s
src/runtime/rt0_android_386.s src/runtime/rt0_linux_ppc64.s
src/runtime/rt0_android_amd64.s src/runtime/rt0_linux_ppc64le.s
src/runtime/rt0_android_arm.s src/runtime/rt0_linux_riscv64.s
src/runtime/rt0_android_arm64.s src/runtime/rt0_linux_s390x.s
src/runtime/rt0_darwin_386.s src/runtime/rt0_netbsd_386.s
src/runtime/rt0_darwin_amd64.s src/runtime/rt0_netbsd_amd64.s
src/runtime/rt0_darwin_arm.s src/runtime/rt0_netbsd_arm.s
src/runtime/rt0_darwin_arm64.s src/runtime/rt0_netbsd_arm64.s
src/runtime/rt0_dragonfly_amd64.s src/runtime/rt0_openbsd_386.s
src/runtime/rt0_freebsd_386.s src/runtime/rt0_openbsd_amd64.s
src/runtime/rt0_freebsd_amd64.s src/runtime/rt0_openbsd_arm.s
src/runtime/rt0_freebsd_arm.s src/runtime/rt0_openbsd_arm64.s
src/runtime/rt0_freebsd_arm64.s src/runtime/rt0_plan9_386.s
src/runtime/rt0_illumos_amd64.s src/runtime/rt0_plan9_amd64.s
src/runtime/rt0_js_wasm.s src/runtime/rt0_plan9_arm.s
src/runtime/rt0_linux_386.s src/runtime/rt0_solaris_amd64.s
src/runtime/rt0_linux_amd64.s src/runtime/rt0_windows_386.s
src/runtime/rt0_linux_arm.s src/runtime/rt0_windows_amd64.s
src/runtime/rt0_linux_arm64.s src/runtime/rt0_windows_arm.s
src/runtime/rt0_linux_mips64x.s

linuxやandroidやwindowsなど各アーキテクチャのファイルがあることがわかります。

mainが実行される手順を追ってみる

ではgoがどのようにアプリケーションのコードを実行するかをmainが実際に実行される様子をみてみましょう。

rt0がどのように呼び出されるかを確認しまます

実行環境 : macOS Catalina 10.15.4

まずは何もないgoのコードを作成します。

$ cat main.go
package main
func main() {
}

作成したコードをコンパイルして、ディスアセンブルします。

Macでは実行形式がMach-O形式で、otoolを使用すると解析することができます。

$ go build main.go
//ディスアセンブル
$ otool -vt main > asm.s

作成した asm.s を読むとruntimeパッケージが自動で読み込まれる様子を確認できます。 _runtime がruntimeパッケージであることを示しています。また、作成したコードはmainパッケージであり、 _main に存在します。このコード自体は asm.s の一番下に記述されていました。

$ less asm.s
...
_runtime.text:
0000000001001000 jmpq *(%rax)
0000000001001002 outsl (%rsi), %dx
0000000001001004 andb %ah, 0x75(%rdx)
0000000001001007 imull $0x203a4449, 0x20(%rsp,%riz,2), %ebp
000000000100100f andb 0x6d666176(%rip), %ch
0000000001001015 pushq %rbx
...
_main.main:
0000000001056db0 retq
0000000001056db1 int3
0000000001056db2 int3
0000000001056db3 int3
0000000001056db4 int3
0000000001056db5 int3
0000000001056db6 int3
0000000001056db7 int3
0000000001056db8 int3
0000000001056db9 int3
0000000001056dba int3
0000000001056dbb int3
0000000001056dbc int3
0000000001056dbd int3
0000000001056dbe int3
0000000001056dbf int3

作成したmainは retq のみ実行している様子がわかります。

さて今回は Goのランタイムがどのようにこの main を実行しているかについて説明します。

まずプログラムが最初に実行される行である、エントリポイントを探しましょう。rip というレジスタにアドレスが格納されるので、Mach-Oのバイナリメタデータを確認して、どこが初めに実行されるかを確認します。

# otoolでバイナリのメタデータを作成
$ otool -hl main > mach-o-header.txt
# ripの記述を取得
$ cat mach-o-header.txt | grep rip
r15 0x0000000000000000 rip 0x0000000001054330

上記の結果から 0x0000000001054330 から実行されることがわかりました。ではこのエントリポイントの先を asm.s で確認します。

$ cat asm.s | grep -B 1 001054330
__rt0_amd64_darwin:
0000000001054330 jmp 0x1050790

amd64_darwin で実行されている様子が確認できました。これは自分のMacのアーキテクチャです。命令はjmpですぐに他の行に移動している様子がわかりますので、同様に実行の様子を追っていきましょう

$ cat asm.s | grep -A 2 -B 1 1050790
__rt0_amd64:
0000000001050790 movq (%rsp), %rdi
0000000001050794 leaq 0x8(%rsp), %rsi
0000000001050799 jmp 0x10507a0 # レジスタの移動をしたあと0x10507a0にjmp
$ cat asm.s | grep -A 2 -B 1 10507a0
_runtime.rt0_go:
00000000010507a0 movq %rdi, %rax
00000000010507a3 movq %rsi, %rbx
00000000010507a6 subq $0x27, %rsp

最終的に_runtime.rt0_go にたどり着きました。複数のアーキテクチャで共通に使われるGoのエントリポイントで、Goの実行の初期化から実行まで行っています。
ここからはソースコードを読んでいきます。

asm_amd64.s

TEXT runtime·rt0_go(SB),NOSPLIT,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
...
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX)
LEAQ runtime·g0(SB), CX // groutineの初期化
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX // groutineの初期化

// save m->g0 = g0
MOVQ CX, m_g0(AX)
// save m0 to g0->m
MOVQ AX, g_m(CX)

CLD // convention is D is always left cleared
CALL runtime·check(SB)

MOVL 16(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 24(SP), AX // copy argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB) // OS初期化
CALL runtime·schedinit(SB) // スケジューラ初期化

// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry runtime.mainを引数にする
PUSHQ AX
PUSHQ $0
CALL runtime·newproc(SB) // newproc()をCALL
POPQ AX
POPQ AX

// start this M
CALL runtime·mstart(SB) // Mを起動 -> スケジューラがスタートしてgoroutineが始まる

CALL runtime·abort(SB) // mstart should never return
RET

// Prevent dead-code elimination of debugCallV1, which is
// intended to be called by debuggers.
MOVQ $runtime·debugCallV1(SB), AX
RET

...
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)

上記をみると以下のように実行されていることがわかります。

  1. goroutineの初期化
  2. 引数の取得
  3. スケジューラやOSの初期化
  4. runtime.main() 関数ポインタを引数にして、newproc() に渡している
  5. Mを起動 (スケジューラの起動)

※MはOSのスレッドを表す構造体です

つまりGoには共通のエントリポイントである、runtime.main()が存在して、newproc() にそのエントリポイントを渡すことで実行していることがわかります。

ではnewproc() をみてみましょう。

proc.go

 // Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
//
// The stack layout of this call is unusual: it assumes that the
// arguments to pass to fn are on the stack sequentially immediately
// after &fn. Hence, they are logically part of newproc's argument
// frame, even though they don't appear in its signature (and can't
// because their types differ between call sites).
//
// This must be nosplit because this stack layout means there are
// untyped arguments in newproc's argument frame. Stack copies won't
// be able to adjust them and stack splits won't be able to copy them.
//
//go:nosplit
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)

_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)

if mainStarted {
wakep()
}
})
}

g とはgoroutineのことを指します。コメントをみるとわかるようにこのメソッドは関数のポインタからgoroutineを作成して、実行キューに追加します。

つまりruntime.main() をgoroutineの実行形式にしています。

ではruntime.main()をみていきます。

proc.go

func main() {
g := getg()
...
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
...

}

runtime.mainは最終的に main_main という関数を実行していることが確認できました。

このmain_mainは main.mainのディレクティブであることがわかります。これが先ほど作った自分のコードの _main.main にあたります。

//go:linkname main_main main.main
func main_main()

さて、以上のことを踏まえると、以下のように実行されていることがわかります。

  1. プログラムが実行
  2. goroutineの初期化
  3. 引数の取得
  4. スケジューラやOSの初期化
  5. runtime.main() 関数ポインタを引数にして、newproc() に渡している
  6. newproc()内で、_main.mainを実行キューに追加する
  7. Mを起動 (スケジューラの起動)
  8. スケジューラがキューにあるgoroutineの起動する
  9. _main.mainが実行される

以上がmainが実行される様子です。newproc() はGo言語でgoroutineを作成するときに出てくる go 命令の時に実行される処理です。

goの並行処理基盤はOSスレッド M とgoroutineのgと Mにgを割り当てる P (Processing) でタスクの割り振りをしていて、Mを起動すると、gのキューにあるタスクがMにアサインされて実行されていきます。

次回は並行処理について説明します。

最後に

いかがだったでしょうか?普段プログラミングをしていると意識しないところではありますが、こういったランタイムの実装を確認することで、アーキテクチャの理解に繋がり、強いてはより効率的なコーディングに繋がります。

次回はtamo_horyさんからです!

--

--