Go ランタイムの内部構造:プロセス起動と G-M-P スケジューラ
前提知識
- ›記事 1〜3:リポジトリ構成からコンパイラパイプラインまで
- ›OS の基礎知識(スレッド、スレッドローカルストレージ、仮想メモリ)
- ›Go の Plan 9 スタイルアセンブリ記法の基本
Go ランタイムの内部構造:プロセス起動と G-M-P スケジューラ
Go バイナリにはランタイムが同梱されています。goroutine のスケジューリング、メモリ割り当て、ガベージコレクション、OS とのやり取りを担う、本格的なシステムソフトウェアです。OS ローダーが Go バイナリを起動するとき、実行は main() 関数からではなく、ランタイム全体をブートストラップするプラットフォーム固有のアセンブリコードから始まります。この記事では、そのブートストラップシーケンスを追いながら、goroutine を動かす仕組みである G-M-P スケジューラを深く掘り下げていきます。
アセンブリのエントリーポイントと rt0_go
Linux/amd64 では、最初に実行される命令は rt0_linux_amd64.s に記述されています。
src/runtime/rt0_linux_amd64.s#L7-L8
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
ファイルの内容はこれだけです。アーキテクチャ共通の _rt0_amd64 へジャンプし、そこからさらに runtime·rt0_go へ飛びます。この多段ディスパッチにより、OS 固有のエントリー処理とアーキテクチャ固有の初期化処理を明確に分離しています。
実際の処理が行われるのは rt0_go です。
src/runtime/asm_amd64.s#L142-L269
sequenceDiagram
participant OS as OS Loader
participant rt0 as rt0_linux_amd64
participant rt0go as rt0_go
participant sched as schedinit
participant main as runtime.main
OS->>rt0: Load binary, jump to entry
rt0->>rt0go: JMP _rt0_amd64 → rt0_go
rt0go->>rt0go: Set up g0 stack bounds
rt0go->>rt0go: CPUID: detect CPU features
rt0go->>rt0go: Initialize TLS
rt0go->>rt0go: Link g0 ↔ m0
rt0go->>sched: CALL schedinit
rt0go->>rt0go: Create goroutine for runtime.main
rt0go->>rt0go: CALL runtime.mstart (enters scheduler)
この関数は次の手順を順番に実行します。
-
g0 スタックのセットアップ(159〜166 行目):OS が提供したスタックをもとに最初の goroutine のスタックを作成し、現在の SP から 64KB 下を
stack.loとして設定します。 -
CPU 機能の検出(168〜186 行目):
CPUIDを実行して Intel か AMD か、そしてプロセッサの機能を調べます。この情報は、高速なメモリ操作などランタイムの最適化に使われます。 -
TLS の初期化(249〜258 行目):
getg()— 現在の goroutine を取得する関数 — が正しく動作するよう、スレッドローカルストレージを設定します。多くのプラットフォームでは OS の TLS を利用しますが、Linux ではsettlsを呼び出します。 -
g0/m0 の紐付け(260〜269 行目):ランタイムの根幹を成す 2 つのオブジェクト、
g0(スケジューリング処理用のシステム goroutine)とm0(最初の OS スレッド)を結びつけます。
// save m->g0 = g0
MOVQ CX, m_g0(AX)
// save m0 to g0->m
MOVQ AX, g_m(CX)
この双方向のリンクは非常に重要です。すべての M は自身の g0 を知り、すべての g0 は自身の M を知っています。
schedinit と runtime.main
ハードウェア層のセットアップを終えた rt0_go は、次に schedinit を呼び出します。
この関数は、ランタイムの主要サブシステムを慎重な順序で初期化します。ロックランクの設定、スタックアロケータ、乱数生成器、メモリアロケータ(mallocinit)、CPU アルゴリズムの選択、GOMAXPROCS の設定などが含まれます。順番には意味があります。mallocinit は randinit の完了を前提とし、どちらもスタックシステムの初期化後に実行する必要があります。
schedinit の後、rt0_go は runtime.main を実行する最初の goroutine を作成します。
runtime.main が行うことは次のとおりです。
- sysmon スレッドを起動します — プリエンプション、ネットワークポーリング、GC ペーシングを担うバックグラウンドモニターです
- メインの goroutine をメインの OS スレッドに固定します(一部の C ライブラリが要求します)
doInit(runtime_inittasks)を通じてすべての ランタイム init 関数を実行しますgcenable()でガベージコレクターを有効化します- 依存関係の順序に従ってすべての パッケージ init 関数を実行します
- 最後に
main_main()— すなわち皆さんのmain.main— を呼び出します
fn := main_main // indirect call; linker resolves the address
fn()
139 行目の //go:linkname main_main main.main ディレクティブが、ランタイム側の参照とリンカーが見つけた main() を結びつけます。
ヒント:
GODEBUG=inittrace=1を設定すると、すべてのinit()関数の実行時間を確認できます。起動が遅い原因を調べるときに役立ちます。
G-M-P モデル
proc.go 冒頭のスケジューラドキュメントには、3 つの中核的な抽象概念が説明されています。
- G(goroutine): 処理の単位。スタック、スケジューリング状態(
gobuf)、GC メタデータを持ちます。 - M(machine): OS スレッド。システムスタック操作用の
g0と、現在実行中の goroutine を指すcurgを持ちます。 - P(processor): ローカルランキュー、メモリキャッシュ(
mcache)、タイマーヒープを持つ論理プロセッサ。P の数はちょうど GOMAXPROCS 個です。
graph TD
subgraph "P0 (Processor)"
LRQ0["Local Run Queue<br/>[G3, G4, G5]"]
MC0["mcache"]
TH0["Timer Heap"]
end
subgraph "P1 (Processor)"
LRQ1["Local Run Queue<br/>[G6, G7]"]
MC1["mcache"]
TH1["Timer Heap"]
end
M0["M0 (OS Thread)<br/>running G1"] --> P0
M1["M1 (OS Thread)<br/>running G2"] --> P1
M2["M2 (OS Thread)<br/>in syscall, no P"]
GRQ["Global Run Queue<br/>[G8, G9, G10]"]
style M2 fill:#f99
P という抽象化は Go 1.1 のスケジューラ再設計で導入されました。P が登場する前は、スケジューリングの状態はすべて M ごと、またはグローバルに管理されていました。問題は、M がシステムコールに入ると、そのスケジューリングリソースも一緒に塞がれてしまうことでした。P はスケジューリングリソースを切り離し可能にすることでこれを解決します。M がシステムコールに入ると、その P は Go コードを実行できる別の M に引き渡せるのです。
グローバル変数 m0 と g0 が最初のインスタンスです。
Goroutine の状態遷移
すべての goroutine は atomicstatus フィールドで状態を管理しています。状態は runtime2.go で定義されています。
src/runtime/runtime2.go#L17-L99
stateDiagram-v2
[*] --> _Gidle: newproc allocates G
_Gidle --> _Grunnable: initialized
_Grunnable --> _Grunning: execute()
_Grunning --> _Grunnable: preempted / yield
_Grunning --> _Gsyscall: entering syscall
_Gsyscall --> _Grunnable: syscall returns
_Grunning --> _Gwaiting: gopark()
_Gwaiting --> _Grunnable: goready()
_Grunning --> _Gdead: goexit()
_Gdead --> _Gidle: reused from free list
_Grunning --> _Gpreempted: async preemption
_Gpreempted --> _Gwaiting: suspendG
この状態フィールドは、goroutine のスタックに対するロックとしても機能します。_Gscan ビット(0x1000)は任意の状態と OR 演算することで、GC がスタックをスキャン中であることを示します。GC がスキャンビットを並行してセットする可能性があるため、状態遷移にはアトミックな CAS 操作が必要です。
g 構造体自体も多くのフィールドを持っています。
src/runtime/runtime2.go#L471-L596
主なフィールドとして、stack(スタック境界)、stackguard0(プリエンプションに使用)、sched(コンテキストスイッチ用のレジスタ保存領域である gobuf)、gcAssistBytes(goroutine が GC に対して負うデット)があります。stackguard0 に stackPreempt をセットすると、プリエンプションチェックがトリガーされます。
ワークスティーリングとスレッド管理
スケジューリングの中心となるループは schedule() 関数です。
この関数は findRunnable() を呼び出し、ここでワークスティーリングアルゴリズムが実装されています。
findRunnable の探索順序は慎重に設計されています。
- ローカルランキューを確認する
- グローバルランキューを確認する(スターベーションを防ぐため、61 回に 1 回)
- ネットワークポーラーに実行可能な goroutine がないかポーリングする
- 他の P のランキューからワークを盗もうとする
- 何も見つからなければ M をパークする
「スピニングスレッド」の最適化により、スレッドの過度なパーク・アンパークを防いでいます。
ポイントはこうです。少なくとも 1 つのスレッドがスピン(ワークを探している)していれば、新しいワークが来ても追加のスレッドを起こしません。最後のスピニングスレッドがワークを見つけてスピンを停止したときにのみ、代わりのスピナーを起こします。これにより、スレッド生成の急激な増加を抑えつつ、最終的に CPU を最大限活用できることを保証しています。
flowchart TD
A["schedule()"] --> B["findRunnable()"]
B --> C{"Local run queue?"}
C -->|Yes| H["execute(gp)"]
C -->|No| D{"Global run queue?<br/>(every 61st check)"}
D -->|Yes| H
D -->|No| E{"Network poller?"}
E -->|Yes| H
E -->|No| F{"Steal from other P?"}
F -->|Yes| H
F -->|No| G["Park M<br/>(stopm)"]
H --> I["Run goroutine"]
I --> A
プリエンプション:協調的と非同期
Go は preempt.go に記述された 2 種類のプリエンプション機構をサポートしています。
協調的プリエンプションは、goroutine の stackguard0 を stackPreempt に書き換えることで機能します。すべての関数プロローグにはスタック境界チェックが含まれており、この書き換えられた値がチェックをトリガーすると、関数はスタック拡張パスに入ります。そこでプリエンプション要求であることが検出され、goroutine が yield します。
非同期プリエンプション(Go 1.14 で追加)は、関数呼び出しを含まないタイトなループ — 協調的プリエンプションポイントに到達しないコード — を対象としています。ランタイムはスレッドにシグナル(Unix では SIGURG)を送り、シグナルハンドラが goroutine の状態を確認し、安全なポイントであれば goroutine を一時停止します。
ヒント: CPU バウンドな goroutine がスケジューラをブロックしているように見える場合、関数呼び出しのないタイトなループが原因かもしれません。非同期プリエンプションがほとんどのケースをカバーしていますが、CGo の呼び出しはプリエンプションが介入できない領域の一つです。
メモリシステムへ
スケジューラはメモリアロケータと密接に結びついています。すべての P はロックフリーな割り当てのための mcache を持ち、GC はスケジューラを通じてストップ・ザ・ワールドの一時停止とマークアシストを調整します。次の記事では、Go のメモリ管理を掘り下げます。tcmalloc にインスパイアされたアロケータの階層構造と、並行三色ガベージコレクターを取り上げます。