Goでmainが実行される様子をもっと追ってみる
この記事は Voicy Advent Calendar 2020 の 日目の記事です。
前日は nataconさんの「IntelliJ IDEAのHTTPクライアントが使いやすい話」でした。ツールを使いこなすと爆速で開発ができますよね。自分は浮気しがちなので早く一流のvimmerになりたいです。
この記事は「Goでmainが実行される様子を追ってみる」の続きです。
前回は自分がコーディイングしたmainの関数がどのように実行されているかを確認しました。今回はGo言語のアーキテクチャに思いを馳せてからa再びmain実行までを追ってみたいと思います。
Goの並行処理
“Do not communicate by sharing memory; instead, share memory by communicating.”
という格言があるようにGoでは共有メモリではなく、メッセージパッシングよる並行処理を推奨しています。
- 共有メモリ : 複数のプロセスでメモリを共有して、プロセス間通信を実現する方法
- メッセージパッシング : 複数のプロセス間でメッセージを送受信しながら、プロセス間通信を実現する方法
共有メモリなどは競合状態などが起こりうる可能性があrい、デバッグが難しいという反面、メッセージパッシングはプロセス間での取り決めのみでよく、実装が複雑になりにくいとされています。
Goではgoroutineとchannelでメッセージパッシングをシンプルに実装できるようにしています。goroutineがプロセス、channelがgoroutine間のメッセージパッシングを実現、という役割になります。
今回はmain.goで、どのようにプログラムが実行されるかに的を絞るためにgoroutineのみ説明します。
goroutineのスケジューラ
goroutineはGoのランタイムで管理される軽量スレッドで、並行処理を軽くするために設計されています。
goroutineが通常のOSスレッドよりも軽量で実行が早い理由は以下の2点があります。
- スレッド自体のサイズが小さい
- スレッド切り替え時のオーバヘッドが少ない
goroutineのサイズは2048byteで、OSスレッドはMB単位であることを考えると、サイズが1000倍小さいことがわかります。
またOSのスレッドは、コンテキストスイッチでレジスタの退避など、複数の処理を実行しなければならないことに反して、メモリ空間を共通で使用している分goroutineの切り替えは処理が少なく済むため、切り替えのオーバヘッドを減らすことができます。
とはいえ、実際のCPUでのプログラミングの実行はOSスレッドで実行しなければなりません。Goでは複数のgoroutineに対して複数のOSスレッドにタスクをマルチプレクシングすることで、軽量な並行処理を実現しています。
タスクスケジューリングの代表的な二つの方法は以下です。
- Fair-scheduling : タスクに優先度をつけて平等に割り振る
- Work-stealing : 使用されていないプロセッサが他のタスクを積極的に盗む
LinuxOSや、ロードバランサなどの負荷分散システムはFair-schedulingを使用しています。
それに反してGoではWork-stealingを使用して、複数のタスクをスケジューリングしています。
Goではこのスケジューリングを実現するために3つの構造体を作成します。
- G : goroutine タスクの実行単位
- M : OSスレッド CPUを動作させるスレッド
- P : 複数のg(タスク) を複数のM (スレッド) に割り当てるProcessor
- PはG(正確にはfork-joinで作成された並行タスク)を入れるローカルキューを持っている
- Pは複数のP間で共有しているグローバルキューを持っている
- キューにgoroutineが積まれる
- PはMを複数持てる、ただしMは必ずしもPに紐づいている必要はない
- Mはスケジューラに必要があれば、Pに追加される
ではスケジューラのアルゴリズムをコードのコメントからみていきます。
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
- 一定期間に一度、Gのグローバルキューをチェックする
- もしグローバルキューにGがない場合は、ローカルキューをチェックする
- もしローカルキューにGがない場合は、「他のPからGを盗む」
- もし盗めなかった場合はグローバルキューをもう一度チェックする。
- もしグローバルキューにGがない場合は、ポーリングする
上記を見るとタスクをスティールしている様子がわかります。
このようにgoroutineをPがOSスレッドに割り当てることで、goroutineのタスクがCPUで実行されます。ではまたruntimeの解読に戻りましょう。
runtimeのコードリーディング
さて、Goの並行処理ではG,M,Pが出てくることがわかりましたが、実際にruntime2.go内で定義されている構造体であることがわかります。
type g struct {
...
}
type m struct {
...
}
type p struct {
...
}
前回では詳しく触れなかったですが、今度はmainが実行される様子をgとmとpの状態を確認しながら追っていきましょう。
前回エントリポイントからrt0_go に処理が移動することを確認しましたが、そこからの処理の概要を表すといかになります。
└── _rt0_amd64_darwin
└── _rt0_amd64
└── runtime.rt0_go
├── runtime.osinit
├── runtime.schedinit #Pの作成とスケジューラの初期化
├── runtime.newproc #Gを作成して、Pのキューに入れる
│ └── runtime.newproc1
│ └── runtime.runqput
├── runtime.mstart #mを実行する
│ └── runtime.mstart1
│ └── runtime.schedule
│ └── runtime.execute
│ └── runtime.gogo
│ └── runtime.main
│ ├── runtime.newm
│ ├── runtime.init
│ ├── runtime.gcenable
│ ├── main.init
│ ├── main.main
│ │ └── your_code # origincode
│ │
│ │
│ └── exit0
└── runtime.mexit
- runtime.osinit
OSやハードウェアの固有の値である、CPU数やページサイズを取得しています。
// BSD interface for threading.
func osinit() {
// pthread_create delayed until end of goenvs so that we
// can look at the environment first.
ncpu = getncpu()
physPageSize = getPageSize()
}
- runtime.schedinit (proc.go)
環境変数 GOMAXPROCS
の数の長さのPのリストを作成しています。
func schedinit() {
...
// 初期化処理
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil { // 新しいPのリストを作成して返す関数
throw("unknown runnable goroutine during bootstrap")
}
...
}
- runtime.newproc (proc.go)
Gを作ってPのローカルキューに追加している様子がわかります。
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) # Gを作る処理
_p_ := getg().m.p.ptr() # Pを取得
runqput(_p_, newg, true) # PのローカルキューにGを追加
if mainStarted {
wakep()
}
})
}
- runtime.mstart (mstart.go)
mを起動して、処理が終了したら終了の処理を入れています。具体的な処理はmstart1に移譲しています、mstart1ではscheduleを読んでいます。
func mstart() {
_g_ := getg()
... mstart1() ...
mexit(osStack)
}func mstart1() {
_g_ := getg()
...初期化処理 (起動時コールバックやOSのシグナルを受け取るためのmの起動など)
...
if _g_.m != &m0 {
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}
schedule()
}
- runtime.schedule
scheduleでは最終的にexecuteを読んでおり、gogoという処理を呼び出しています。
func schedule() {
...
execute(gp, inheritTime)
}
func execute(gp *g, inheritTime bool) {
_g_ := getg()
...
gogo(&gp.sched) # M(OSスレッドでプログラムを実行してもらう処理)
}
- runtime.gogo (asm_amd64.s)
runtime.gogoはアセンブリであり、goroutineのプログラムカウンタや変数の状態などをOSスレッドを実行するためにレジスタを書き換える処理をしています。
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX // make sure g != nil
get_tls(CX)
MOVQ DX, g(CX)
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
MOVQ gobuf_pc(BX), BX // go_bufで記録していたPCをレジスタに渡す
JMP BX // プログラムカウンタ(命令位置)にJMP
- runtime.main
Goが最初に実行するgoroutine ここではGCの初期化やスケジューラの起動などの初期化処理を行ったあと、ユーザプログラムのエントリポイントを実行する
func main() {
...
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
systemstack(func() {
newm(sysmon, nil, -1)
})
}
// Record when the world started.
runtimeInitTime = nanotime()
gcenable()
...
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()
...
exit(0)
...
}
- runtime.goexit0
goroutineの終わりの処理、goroutine自体の削除などをしたあと、schedule()を再び呼ぶ、つまりキューに入っているタスクから次のタスクがスケジューリングされる
func goexit0(gp *g) {
...
dropg()
...
schedule()}
終わりに
Goの並行処理のアーキテクチャを学んで実際プログラムが実行されるruntimeをコードリーディングすることで、より理解が深まったのではないでしょうか?今回は説明から省いてしまった、shcheduleのアルゴリズムや、タスクには関係ないMのループ処理など、面白いところはたくさんありますので、ぜひ皆さんも読んでコメントをいただけばと思います。
次回は shiei_kawa さんからです!お楽しみに!