Go言語入門:よくある質問 -Implementation Vol.2-

スポンサーリンク
Go言語入門:よくある質問 -Implementation Vol.2- ノウハウ
Go言語入門:よくある質問 -Implementation Vol.2-
この記事は約14分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のよくある質問 について解説しています。

スポンサーリンク

背景

Go言語を学んでいると「なんでこんな仕様になっているんだろう?」「他の言語と違うのはなぜ?」といった疑問が湧いてきませんか。Go言語の公式サイトにあるFAQページには、そんな疑問に対する開発チームからの丁寧な回答がたくさん載っているんです。ただ、英語で書かれているため読むのに少しハードルがあるのも事実で、今回はこのFAQを日本語に翻訳して、Go言語への理解を深めていけたらと思い、これを読んだ時の内容を備忘として残しました。

Implementation

ランタイムサポートはどのように実装されていますか?

再びブートストラッピングの問題により、ランタイムコードは元々ほぼC言語で書かれていました(ごくわずかなアセンブラと共に)が、その後Goに翻訳されています(一部のアセンブラビットを除く)。Gccgoのランタイムサポートはglibcを使用しています。gccgoコンパイラは、goldリンカーへの最近の変更によってサポートされている、セグメント化スタックと呼ばれる技術を使用してゴルーチンを実装しています。Gollvmも同様に、対応するLLVMインフラストラクチャ上に構築されています。

解説

この問題は何について説明しているの?

ランタイムサポートとは、Goプログラムが実行される時に裏で動いている「支援システム」のことです。例えば:

  • ゴルーチンの管理
  • メモリの管理(ガベージコレクション)
  • スケジューリング(どのゴルーチンを実行するか)

このランタイム自体が何で作られているか、という話です。

基本的な用語

  • ランタイム: プログラム実行時に必要な基盤システム
  • アセンブリ(アセンブラ): 機械語に最も近い低レベルプログラミング言語
  • glibc: LinuxのC言語標準ライブラリ
  • セグメント化スタック: メモリを柔軟に管理する技術
  • リンカー: コンパイル後のファイルをまとめて実行ファイルを作るツール

ランタイムとは?

あなたが書くコード
package main

import "fmt"

func main() {
    // ゴルーチンを起動
    go func() {
        fmt.Println("Hello from goroutine")
    }()
    
    fmt.Println("Hello from main")
}
裏で動いているランタイム
あなたのコード
    ↓
ランタイムが支援:
  - ゴルーチンのスケジューリング
  - メモリ割り当て
  - ガベージコレクション
  - スタック管理
  - システムコールの呼び出し
    ↓
実際の実行

ランタイム = 執事のような存在

プログラマー: 「ゴルーチンを起動して!」
ランタイム: 「承知しました。適切なタイミングで実行します」

プログラマー: 「メモリが必要!」
ランタイム: 「お任せください。不要になったら自動で片付けます」

ランタイムの実装言語の変遷

Phase 1: C言語 + アセンブリ (初期)
ランタイム = C言語(95%) + アセンブリ(5%)

理由:

  • ブートストラッピング問題(前の質問で説明済み)
  • Goコンパイラがまだ成熟していなかった
  • 低レベル操作にはC言語が適していた

C言語の部分:

// ゴルーチンのスケジューリング(疑似コード)
void schedule() {
    // 次のゴルーチンを選択
    goroutine* g = findNextGoroutine();
    // 実行
    runGoroutine(g);
}

アセンブリの部分:

; スタックの切り替え(疑似コード)
MOV SP, new_stack_pointer
JMP goroutine_entry
Phase 2: Go + アセンブリ (現在)
ランタイム = Go(95%) + アセンブリ(5%)

変更方法:

  • C言語のコードを自動/手動でGoに翻訳
  • 低レベル操作だけアセンブリで残す

Go言語の部分:

// ゴルーチンのスケジューリング
func schedule() {
    // 次のゴルーチンを選択
    gp := findRunnable()
    // 実行
    execute(gp)
}

アセンブリの部分 (例: runtime/asm_amd64.s):

// 低レベルのコンテキストスイッチなど
TEXT runtime·gogo(SB), NOSPLIT, $0-8
    MOVQ    buf+0(FP), BX
    // スタックポインタの切り替え
    MOVQ    gobuf_sp(BX), SP
    JMP     AX

なぜアセンブリが残っているのか?

理由1: 超低レベル操作

一部の操作はGoでは表現できません:

// これはGoでは書けない
// CPU レジスタを直接操作
// スタックポインタを変更
// 割り込みハンドラ

必要なアセンブリ:

  • CPUレジスタの直接操作
  • スタックの切り替え
  • システムコールの呼び出し
  • アトミック操作の最適化
理由2: 最高のパフォーマンス
Go版:    良いパフォーマンス
   ↓
アセンブリ版: 最高のパフォーマンス (数%の改善)

パフォーマンスが重要な部分:

  • ゴルーチンの切り替え(頻繁に実行される)
  • メモリアロケーション
  • 同期プリミティブ

各コンパイラのランタイム実装

1. gc (標準コンパイラ)

構成:

runtime パッケージ:
  - ほぼ全てGo言語
  - 一部アセンブリ (runtime/asm_*.s)
  - 独自のスケジューラ
  - 独自のガベージコレクタ

特徴:

  • 完全にGoに最適化
  • 高速なゴルーチン切り替え
  • 効率的なメモリ管理
2. Gccgo

構成:

ランタイム:
  - glibc を使用 (Linuxの標準Cライブラリ)
  - セグメント化スタック技術
  - GCCの最適化を活用

glibc とは: LinuxのC言語プログラムが使う基本ライブラリ:

  • メモリ管理
  • スレッド管理
  • システムコール

セグメント化スタック:

通常のスタック:
┌─────────────┐
│   固定サイズ  │ ← 大きく確保する必要がある
└─────────────┘

セグメント化スタック:
┌───┐
│ A │ ← 必要に応じて
├───┤    伸びる!
│ B │
├───┤
│ C │
└───┘
3. Gollvm

構成:

ランタイム:
  - LLVM インフラストラクチャ
  - LLVMの最適化技術
  - LLVMのデバッグツール

ランタイムの主要機能

1. ゴルーチンのスケジューリング
// あなたが書くコード
go func() {
    fmt.Println("Hello")
}()

// ランタイムがやること
// 1. 新しいゴルーチンを作成
// 2. 実行待ちキューに追加
// 3. 適切なタイミングで実行
// 4. 終了したら片付け

実装(簡略版):

// runtime パッケージ内
func newproc(fn func()) {
    // 新しいゴルーチン構造体を作成
    newg := malg(stacksize)
    newg.fn = fn
    
    // 実行待ちキューに追加
    runqput(newg)
    
    // 必要なら新しいワーカーを起動
    wakep()
}
2. メモリ管理(ガベージコレクション)
// あなたが書くコード
s := make([]int, 1000)  // メモリ確保
// s を使用
// s = nil  // もう使わない

// ランタイムがやること
// 1. メモリを確保
// 2. 使用状況を監視
// 3. 不要になったら自動回収

実装の概要:

// runtime パッケージ内
func mallocgc(size uintptr) unsafe.Pointer {
    // メモリを確保
    ptr := allocate(size)
    
    // GCのために記録
    recordAllocation(ptr, size)
    
    return ptr
}

func GC() {
    // 使われていないメモリを探す
    // 回収する
}
3. スタック管理
// あなたが書くコード
func recursive(n int) {
    if n > 0 {
        recursive(n - 1)  // 再帰呼び出し
    }
}

// ランタイムがやること
// 1. スタックが足りなくなったら自動拡張
// 2. 使われなくなったら縮小

Goの魔法:

他の言語: スタックサイズ固定(例: 1MB)
   → 深い再帰でスタックオーバーフロー
   
Go: スタック自動拡張
   → 最初は小さく(2KB)
   → 必要に応じて拡張
   → メモリ効率的!

実際のランタイムコード例

runtime パッケージの構造
runtime/
  ├── proc.go          # ゴルーチンスケジューリング
  ├── malloc.go        # メモリアロケーション
  ├── mgc.go           # ガベージコレクション
  ├── stack.go         # スタック管理
  ├── chan.go          # チャネル実装
  ├── asm_amd64.s      # アセンブリ (x86-64)
  ├── asm_arm64.s      # アセンブリ (ARM64)
  └── ...
スケジューラの概要(簡略版)
// runtime/proc.go より(簡略化)

// M: Machine(OSスレッド)
// P: Processor(実行コンテキスト)
// G: Goroutine

func schedule() {
    // 永遠にループ
    for {
        // 次に実行するゴルーチンを探す
        gp := findRunnable()
        
        // ゴルーチンを実行
        execute(gp)
        
        // 終了したか、待機状態になった
        // 次のゴルーチンへ
    }
}

func findRunnable() *g {
    // ローカルキューをチェック
    // グローバルキューをチェック
    // ネットワークポーラーをチェック
    // 他のPから盗む(work stealing)
}

ランタイムと普通のコードの違い

普通のGoコード
package main

import "fmt"

func main() {
    fmt.Println("Hello")
}
  • 安全なメモリアクセス
  • ガベージコレクション
  • 型安全
ランタイムコード
package runtime

import "unsafe"

func mallocgc(size uintptr) unsafe.Pointer {
    // unsafeパッケージを使用
    // ポインタを直接操作
    // メモリを直接確保
}
  • unsafeパッケージを使用
  • 低レベルメモリ操作
  • パフォーマンス最優先

ランタイムを触ってみる

runtime パッケージの使用
package main

import (
    "fmt"
    "runtime"
)

func main() {
    // ゴルーチン数を表示
    fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
    
    // CPU数を表示
    fmt.Printf("CPUs: %d\n", runtime.NumCPU())
    
    // メモリ統計
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc: %v MB\n", m.Alloc/1024/1024)
    
    // 手動GC実行
    runtime.GC()
    
    // ゴルーチンのスタックトレース
    buf := make([]byte, 1024)
    n := runtime.Stack(buf, false)
    fmt.Printf("Stack: %s\n", buf[:n])
}
GOMAXPROCSの設定
package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 最大CPU使用数を設定
    // (デフォルトは利用可能なCPU数)
    runtime.GOMAXPROCS(4)
    
    fmt.Println("Using 4 CPUs")
}

セグメント化スタックの詳細

通常のスタック (他の言語)
関数呼び出し:
┌──────────────┐ ← スタックトップ
│   関数C      │
├──────────────┤
│   関数B      │
├──────────────┤
│   関数A      │
├──────────────┤
│   main       │
└──────────────┘ ← スタック底

問題: 深い再帰でスタックオーバーフロー
Goのスタック(動的拡張)
初期状態 (2KB):
┌─────┐
│ main│
└─────┘

関数呼び出しが増える:
┌─────┐
│ C   │ ← 足りない!
├─────┤
│ B   │
├─────┤
│ A   │
├─────┤
│ main│
└─────┘

自動拡張:
┌─────────────┐
│     C       │
├─────────────┤
│     B       │
├─────────────┤
│     A       │
├─────────────┤
│    main     │
└─────────────┘

利点:

  • メモリ効率的(必要な分だけ)
  • スタックオーバーフローが起きにくい
  • 何百万ものゴルーチンが可能

アセンブリが使われる具体例

ゴルーチンのコンテキストスイッチ

アセンブリ (runtime/asm_amd64.s):

TEXT runtime·gogo(SB), NOSPLIT, $0-8
    // ゴルーチンのコンテキストを復元
    MOVQ    buf+0(FP), BX        // gobuf構造体を取得
    MOVQ    gobuf_g(BX), DX      // gポインタ
    MOVQ    gobuf_sp(BX), SP     // スタックポインタを復元
    MOVQ    gobuf_ret(BX), AX    // 戻り値
    MOVQ    gobuf_pc(BX), BX     // プログラムカウンタ
    JMP     BX                   // 実行を再開

なぜアセンブリ?

  • スタックポインタ(SP)を直接操作
  • CPUレジスタを直接制御
  • Goでは不可能な操作

まとめ

ランタイムの実装
  1. 歴史的変遷
    • 🔧 初期: C言語 + アセンブリ
    • 🚀 現在: Go + アセンブリ
  2. なぜGoで書き直した?
    • ✅ メンテナンスしやすい
    • ✅ 型安全
    • ✅ Goの機能を活用
    • ✅ 一貫性
  3. なぜアセンブリが残る?
    • ⚡ 超低レベル操作
    • ⚡ 最高のパフォーマンス
    • ⚡ Goでは表現不可能
各コンパイラの違い
コンパイラランタイム特徴
gc (標準)Go + アセンブリGo専用最適化
GccgoC(glibc) + セグメント化スタックGCC最適化活用
GollvmLLVM インフラLLVM最適化活用
ランタイムの役割
  1. 🔄 ゴルーチンスケジューリング
    • 数百万のゴルーチンを効率的に管理
  2. 🧹 ガベージコレクション
    • 自動メモリ管理
  3. 📚 スタック管理
    • 動的スタック拡張
  4. 🔗 チャネル実装
    • 並行処理の通信機構
あなたが知っておくべきこと
  1. ランタイムは透明
    • 普段は意識不要
    • 自動的に最適化
  2. 必要な時だけ調整 runtime.GOMAXPROCS(n) // CPU使用数 runtime.GC() // 手動GC
  3. 内部を学ぶと理解が深まる
    • なぜGoが速いのか
    • なぜゴルーチンが軽量なのか

ランタイムはGoの魔法の源です。Goで書かれたランタイムは、Goという言語が自分自身を支えるのに十分強力であることの証明であり、プログラマーには見えないところで、信じられないほど効率的にプログラムを実行しています!

おわりに 

本日は、Go言語のよくある質問について解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました