Read OSS

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)

この関数は次の手順を順番に実行します。

  1. g0 スタックのセットアップ(159〜166 行目):OS が提供したスタックをもとに最初の goroutine のスタックを作成し、現在の SP から 64KB 下を stack.lo として設定します。

  2. CPU 機能の検出(168〜186 行目):CPUID を実行して Intel か AMD か、そしてプロセッサの機能を調べます。この情報は、高速なメモリ操作などランタイムの最適化に使われます。

  3. TLS の初期化(249〜258 行目):getg() — 現在の goroutine を取得する関数 — が正しく動作するよう、スレッドローカルストレージを設定します。多くのプラットフォームでは OS の TLS を利用しますが、Linux では settls を呼び出します。

  4. 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 を呼び出します。

src/runtime/proc.go#L835-L884

この関数は、ランタイムの主要サブシステムを慎重な順序で初期化します。ロックランクの設定、スタックアロケータ、乱数生成器、メモリアロケータ(mallocinit)、CPU アルゴリズムの選択、GOMAXPROCS の設定などが含まれます。順番には意味があります。mallocinitrandinit の完了を前提とし、どちらもスタックシステムの初期化後に実行する必要があります。

schedinit の後、rt0_goruntime.main を実行する最初の goroutine を作成します。

src/runtime/proc.go#L152-L294

runtime.main が行うことは次のとおりです。

  1. sysmon スレッドを起動します — プリエンプション、ネットワークポーリング、GC ペーシングを担うバックグラウンドモニターです
  2. メインの goroutine をメインの OS スレッドに固定します(一部の C ライブラリが要求します)
  3. doInit(runtime_inittasks) を通じてすべての ランタイム init 関数を実行します
  4. gcenable() でガベージコレクターを有効化します
  5. 依存関係の順序に従ってすべての パッケージ init 関数を実行します
  6. 最後に 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 つの中核的な抽象概念が説明されています。

src/runtime/proc.go#L24-L34

  • 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 に引き渡せるのです。

グローバル変数 m0g0 が最初のインスタンスです。

src/runtime/proc.go#L118-L124

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 に対して負うデット)があります。stackguard0stackPreempt をセットすると、プリエンプションチェックがトリガーされます。

ワークスティーリングとスレッド管理

スケジューリングの中心となるループは schedule() 関数です。

src/runtime/proc.go#L4141

この関数は findRunnable() を呼び出し、ここでワークスティーリングアルゴリズムが実装されています。

src/runtime/proc.go#L3395

findRunnable の探索順序は慎重に設計されています。

  1. ローカルランキューを確認する
  2. グローバルランキューを確認する(スターベーションを防ぐため、61 回に 1 回)
  3. ネットワークポーラーに実行可能な goroutine がないかポーリングする
  4. 他の P のランキューからワークを盗もうとする
  5. 何も見つからなければ M をパークする

「スピニングスレッド」の最適化により、スレッドの過度なパーク・アンパークを防いでいます。

src/runtime/proc.go#L58-L83

ポイントはこうです。少なくとも 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 種類のプリエンプション機構をサポートしています。

src/runtime/preempt.go#L1-L40

協調的プリエンプションは、goroutine の stackguard0stackPreempt に書き換えることで機能します。すべての関数プロローグにはスタック境界チェックが含まれており、この書き換えられた値がチェックをトリガーすると、関数はスタック拡張パスに入ります。そこでプリエンプション要求であることが検出され、goroutine が yield します。

非同期プリエンプション(Go 1.14 で追加)は、関数呼び出しを含まないタイトなループ — 協調的プリエンプションポイントに到達しないコード — を対象としています。ランタイムはスレッドにシグナル(Unix では SIGURG)を送り、シグナルハンドラが goroutine の状態を確認し、安全なポイントであれば goroutine を一時停止します。

ヒント: CPU バウンドな goroutine がスケジューラをブロックしているように見える場合、関数呼び出しのないタイトなループが原因かもしれません。非同期プリエンプションがほとんどのケースをカバーしていますが、CGo の呼び出しはプリエンプションが介入できない領域の一つです。

メモリシステムへ

スケジューラはメモリアロケータと密接に結びついています。すべての P はロックフリーな割り当てのための mcache を持ち、GC はスケジューラを通じてストップ・ザ・ワールドの一時停止とマークアシストを調整します。次の記事では、Go のメモリ管理を掘り下げます。tcmalloc にインスパイアされたアロケータの階層構造と、並行三色ガベージコレクターを取り上げます。