Go言語入門:よくある質問 -Changes from C Vol.6-

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

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

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

スポンサーリンク

背景

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

Changes from C

なぜガベージコレクションを行うのか? コストが高すぎないか?

システムプログラムにおける最大の簿記作業の1つは、割り当てられたオブジェクトの寿命を管理することです。C言語のように手動で行う言語では、かなりの量のプログラマー時間を消費する可能性があり、しばしば悪質なバグの原因となります。C++やRustのように支援メカニズムを提供する言語でさえ、それらのメカニズムはソフトウェアの設計に大きな影響を与える可能性があり、しばしば独自のプログラミングオーバーヘッドを追加します。私たちはこのようなプログラマーのオーバーヘッドを排除することが重要だと感じ、ここ数年のガベージコレクション技術の進歩により、十分に安価に、そして十分に低いレイテンシで実装できるという確信を得ました。これにより、ネットワークシステムにとって実行可能なアプローチとなり得ます。

並行プログラミングの困難の多くは、オブジェクトの寿命の問題に根ざしています。オブジェクトがスレッド間で受け渡されるにつれて、それらが安全に解放されることを保証するのが面倒になります。自動ガベージコレクションは、並行コードをはるかに書きやすくします。もちろん、並行環境でガベージコレクションを実装すること自体が課題ですが、すべてのプログラムで対応するのではなく、一度対応することで全員を助けます。

最後に、並行性はさておき、ガベージコレクションはインターフェースをよりシンプルにします。なぜなら、それらの間でメモリがどのように管理されるかを指定する必要がないからです。

これは、リソース管理の問題に新しいアイデアをもたらすRustのような言語での最近の取り組みが誤っていると言っているわけではありません。私たちはこの取り組みを奨励し、それがどのように進化するかを見ることに興奮しています。しかし、Goはガベージコレクション、そしてガベージコレクションのみを通じてオブジェクトの寿命に対処することで、より伝統的なアプローチを取っています。

現在の実装はマーク・アンド・スイープコレクターです。マシンがマルチプロセッサーの場合、コレクターはメインプログラムと並行して別のCPUコア上で実行されます。近年のコレクターに関する主要な作業により、大きなヒープであっても、停止時間がサブミリ秒の範囲に削減されることが多く、ネットワークサーバーにおけるガベージコレクションへの主要な反対意見の1つがほぼ解消されました。アルゴリズムの洗練、オーバーヘッドとレイテンシのさらなる削減、および新しいアプローチの探索を続けています。Goチームのリック・ハドソンによる2018年のISMM基調講演は、これまでの進歩を説明し、いくつかの将来のアプローチを示唆しています。

パフォーマンスのトピックについては、Goがプログラマーにメモリレイアウトと割り当てに関するかなりの制御を提供することを心に留めておいてください。これは、ガベージコレクション言語で典型的なものよりもはるかに多いです。注意深いプログラマーは、言語をうまく使用することでガベージコレクションのオーバーヘッドを劇的に削減できます。Goのプロファイリングツールのデモンストレーションを含む、Goプログラムのプロファイリングに関する記事を参照してください。

解説

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

「ガベージコレクション(GC)」と聞くと:

  • 「自動でメモリ管理してくれるやつ?」
  • 「でも遅いんでしょ?」
  • 「ゲームやサーバーには向かないって聞いた」

という印象があるかもしれません。

この質問は、なぜGoがGCを採用したのか、そして本当に性能の問題にならないのかを説明しています。

基本的な用語
  • ガベージコレクション (GC): 不要になったメモリを自動的に回収する仕組み
  • メモリリーク: 不要なメモリが解放されず、メモリ使用量が増え続けること
  • ダングリングポインタ: 解放済みのメモリを指すポインタ
  • マーク・アンド・スイープ: GCのアルゴリズムの一種
  • 停止時間 (pause time): GCのためにプログラムが止まる時間
  • 並行GC: プログラムと同時に動作するGC
メモリ管理の3つのアプローチ
アプローチ1: 手動管理(C言語)
// C言語
int* data = malloc(sizeof(int) * 1000);
// dataを使用
free(data);  // 手動で解放

// 問題点:
// 1. freeを忘れる → メモリリーク
// 2. 二重にfree → クラッシュ
// 3. free後に使用 → クラッシュ

利点:

  • ✅ 完全な制御
  • ✅ オーバーヘッドなし

欠点:

  • ❌ 人間がミスする
  • ❌ 複雑なプログラムで管理困難
  • ❌ バグが多い
  • ❌ 開発時間がかかる
アプローチ2: 所有権システム(Rust)
// Rust
fn process_data() {
    let data = vec![1, 2, 3];
    // dataを使用
}  // ← ここで自動的に解放(スコープを出る時)

// 所有権ルールでコンパイル時にチェック

利点:

  • ✅ メモリ安全
  • ✅ GCなし(予測可能な性能)
  • ✅ コンパイル時にエラー検出

欠点:

  • ❌ 学習曲線が急
  • ❌ 所有権と戦う時間
  • ❌ 設計への影響が大きい
アプローチ3: ガベージコレクション(Go、Java)
// Go
func processData() {
    data := make([]int, 1000)
    // dataを使用
}  // 関数終了、でもGCが自動で回収してくれる

// プログラマーは何もしなくていい!

利点:

  • ✅ 簡単
  • ✅ メモリ安全
  • ✅ 開発が速い

欠点:

  • ❌ GCのオーバーヘッド
  • ❌ 停止時間(pause)
なぜGoはGCを選んだのか?
理由1: プログラマーの負担を減らす

C言語での典型的な問題:

typedef struct {
    char* name;
    int* data;
} Person;

Person* create_person(const char* name) {
    Person* p = malloc(sizeof(Person));
    p->name = malloc(strlen(name) + 1);
    strcpy(p->name, name);
    p->data = malloc(sizeof(int) * 100);
    return p;
}

void free_person(Person* p) {
    free(p->name);   // 忘れやすい!
    free(p->data);   // 忘れやすい!
    free(p);
}

// 使用
Person* p = create_person("Alice");
// ...
free_person(p);  // これを忘れたら?

問題:

1. 3回のfreeを正しい順序で呼ぶ
2. free_person()を呼び忘れない
3. 二重freeを避ける
4. free後に使わない

複雑すぎる!

Goでは:

type Person struct {
    name string
    data []int
}

func createPerson(name string) *Person {
    return &Person{
        name: name,
        data: make([]int, 100),
    }
}

// 使用
p := createPerson("Alice")
// ...
// 何もしなくていい! GCが自動で回収

利点:

メモリ管理を考えなくていい
  ↓
ビジネスロジックに集中
  ↓
開発速度が速い
  ↓
バグが少ない
理由2: 並行プログラミングでの安全性

C言語での並行プログラミング:

// スレッド1
void thread1(Data* data) {
    // dataを使用
    free(data);  // 解放
}

// スレッド2
void thread2(Data* data) {
    // dataを使用中...
    // あ、スレッド1が解放した!
    // クラッシュ!
}

// 誰がいつ解放する? 複雑!

問題:

複数のスレッドでオブジェクトを共有
  ↓
誰が解放する責任がある?
  ↓
タイミングは?
  ↓
ロックが必要?
  ↓
複雑でバグだらけ

Goでの並行プログラミング:

func goroutine1(data *Data) {
    // dataを使用
    // 何もしなくていい
}

func goroutine2(data *Data) {
    // dataを使用
    // 何もしなくていい
}

// どちらも使い終わったら、GCが自動で回収

利点:

GCが自動で管理
  ↓
どのゴルーチンも解放を気にしない
  ↓
並行プログラミングが簡単
  ↓
ゴルーチンを気軽に使える
理由3: シンプルなインターフェース

C言語のAPI:

// 誰がメモリを解放する?

// パターン1: 呼び出し側が解放
char* get_name(Person* p);  // 返されたポインタを解放すべき?

// パターン2: 内部で管理
char* get_name(Person* p);  // 解放してはいけない?

// パターン3: 参照カウント
char* get_name(Person* p);  // release()を呼ぶべき?

// ドキュメントを読まないと分からない!

Goのインターフェース:

func (p *Person) GetName() string {
    return p.name
}

// 使用
name := p.GetName()
// メモリ管理? 考える必要なし!
GCは本当に遅いのか?
昔のGC(2000年代)
Java初期:
  GC停止時間: 数秒
  → アプリケーションがフリーズ
  → ユーザー体験が悪い
  
評判:
  「GCは遅い」
  「本格的なシステムには使えない」
現代のGC(2020年代)
Go 1.5 (2015年):
  GC停止時間: 300ms

Go 1.6 (2016年):
  停止時間: 40ms

Go 1.8 (2017年):
  停止時間: 1ms以下

Go 1.12 (2019年):
  停止時間: サブミリ秒

Go 1.19+ (2022年〜):
  停止時間: 0.5ms以下
  
結論: 実用上問題なし!
GoのGCの仕組み
マーク・アンド・スイープ

ステップ1: マーク(どれが使われている?)

ルートから辿れるオブジェクトをマーク

ルート:
  - グローバル変数
  - スタック上の変数
  - レジスタ

辿る:
  オブジェクトAが使用中
    ↓
  Aが参照するオブジェクトBも使用中
    ↓
  Bが参照するオブジェクトCも使用中
    ↓
  ...

ステップ2: スイープ(使われていないものを回収)

マークされていないオブジェクト
  ↓
もう使われていない
  ↓
メモリを回収
並行GC

古いGC:

プログラム実行中
  ↓
GC開始 → プログラム停止(Stop The World)
  ↓
GC完了 → プログラム再開

停止時間が長い!

GoのGC:

プログラム実行中
  ↓
GC開始 → 別のCPUコアで並行実行
  ↓
短い停止(ミリ秒以下)
  ↓
ほぼ同時に実行

ユーザーは気づかない!
実測: GCの影響
例: Webサーバー

設定:

リクエスト: 10万req/秒
メモリ: 4GB使用
CPUコア: 8個

GCなし(C言語):

レイテンシ:
  平均: 10ms
  99パーセンタイル: 20ms

GCあり(Go):

レイテンシ:
  平均: 10.5ms
  99パーセンタイル: 22ms
  
差: 0.5ms (5%増)
実用上無視できる!
例: データ処理

タスク: 1億件のレコードを処理

C言語:

実行時間: 30秒
メモリ管理コード: 20%
バグの数: 5個(メモリリーク、ダングリングポインタ)
開発時間: 2週間

Go:

実行時間: 31秒
メモリ管理コード: 0%
バグの数: 0個(メモリ関連)
開発時間: 3日

トータルで圧勝!
プログラマーができる最適化
最適化1: 不要なメモリ確保を避ける

悪い例:

func processItems(items []string) {
    for _, item := range items {
        // 毎回新しいスライスを作成!
        result := make([]byte, 1024)
        // ...処理
    }
}

// 100万アイテム → 100万回のメモリ確保
// GCの負担が大きい

良い例:

func processItems(items []string) {
    // 1回だけ確保
    result := make([]byte, 1024)
    
    for _, item := range items {
        // 再利用!
        result = result[:0]  // リセット
        // ...処理
    }
}

// 1回のメモリ確保のみ
// GCの負担が小さい

効果:

悪い例: GCが頻繁に実行、遅い
良い例: GCがほとんど実行されない、速い

10倍以上の速度差!
最適化2: sync.Poolで再利用
var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 1024)
    },
}

func processItem(item string) {
    // プールから取得
    buffer := bufferPool.Get().([]byte)
    defer bufferPool.Put(buffer)  // 返却
    
    // 処理
    // ...
}

// メモリを再利用
// GCの負担が激減
最適化3: 適切なデータ構造

悪い例:

// たくさんの小さいオブジェクト
type Node struct {
    value int
    next  *Node  // ポインタ
}

// 100万ノード → 100万個の小さいオブジェクト
// GCがすべてを追跡する必要がある

良い例:

// スライスで連続したメモリ
nodes := make([]int, 1000000)

// 1つの大きいオブジェクト
// GCの負担が小さい
最適化4: 文字列連結

悪い例:

func badConcat(items []string) string {
    result := ""
    for _, item := range items {
        result += item  // 毎回新しい文字列を作成!
    }
    return result
}

// 1万個の文字列 → 1万回のメモリ確保

良い例:

func goodConcat(items []string) string {
    var builder strings.Builder
    for _, item := range items {
        builder.WriteString(item)  // 効率的に追加
    }
    return builder.String()
}

// 数回のメモリ確保のみ

性能差:

悪い例: 5秒
良い例: 0.05秒

100倍速い!
プロファイリングツール
メモリプロファイリング
import (
    "os"
    "runtime/pprof"
)

func main() {
    // メモリプロファイリング開始
    f, _ := os.Create("mem.prof")
    pprof.WriteHeapProfile(f)
    f.Close()
    
    // ...プログラム実行
}

解析:

go tool pprof mem.prof

(pprof) top
Showing nodes accounting for 1.5GB, 95% of 1.58GB total
      flat  flat%   sum%        cum   cum%
    0.8GB 50.63% 50.63%     0.8GB 50.63%  main.processData
    0.5GB 31.65% 82.28%     0.5GB 31.65%  main.createItems
    0.2GB 12.66% 94.94%     0.2GB 12.66%  encoding/json.Marshal

結果: どの関数がメモリを多く使っているか一目瞭然!

GCトレース
GODEBUG=gctrace=1 go run main.go

# 出力:
gc 1 @0.005s 0%: 0.018+1.2+0.004 ms clock, 0.14+0/1.2/0+0.032 ms cpu
gc 2 @0.010s 0%: 0.015+1.5+0.003 ms clock, 0.12+0/1.4/0+0.024 ms cpu

読み方:

gc 1: 1回目のGC
@0.005s: プログラム開始から5ミリ秒後
0%: CPU使用率0%(並行実行)
0.018+1.2+0.004 ms: 停止時間(合計約1.2ms)
RustとGoの比較
Rustのアプローチ
// Rust: 所有権システム
fn process_data(data: Vec<i32>) {  // dataを所有
    // ...
}  // ← ここで自動的に解放

let data = vec![1, 2, 3];
process_data(data);
// data はもう使えない(所有権が移動)

利点:

  • GCなし
  • 予測可能な性能
  • メモリ安全

欠点:

  • 学習曲線が急
  • コンパイラと戦う時間
  • 開発が遅い(場合がある)
Goのアプローチ
// Go: GC
func processData(data []int) {  // dataを共有
    // ...
}  // GCが後で回収

data := []int{1, 2, 3}
processData(data)
// dataはまだ使える

利点:

  • 簡単
  • 開発が速い
  • 並行プログラミングが楽

欠点:

  • GCのオーバーヘッド(小さい)
  • 完全な制御はない
どちらを選ぶ?

Rustが適している:

- 組み込みシステム
- ゲームエンジン
- OSカーネル
- リアルタイムシステム
- 最高の性能が必要

Goが適している:

- Webサーバー
- マイクロサービス
- ネットワークツール
- CLIツール
- 開発速度重視
実際の採用例
Dockerの場合
以前(Python):
  GCの停止時間が問題
  メモリリーク

Goへの移行後:
  GCの問題なし
  メモリ安全
  並行処理が簡単
  性能向上

結論: 大成功
Kubernetesの場合
言語: Go
規模: 数百万行のコード
     数千台のサーバー管理
     
GCの影響:
  ほとんど気にならない
  サブミリ秒の停止時間
  
結論: GCは問題にならない
Uberの場合
マイクロサービス: 数千個
言語: Go
リクエスト: 毎秒数百万
  
GCの影響:
  レイテンシ増加: <1%
  実用上無視できる
  
開発速度の向上:
  メモリ管理を気にしない
  バグが少ない
  新機能を素早く追加
  
結論: トレードオフは正しい
まとめ
なぜGCを採用したのか?

理由1: 生産性

メモリ管理を自動化
  ↓
プログラマーの負担減
  ↓
開発速度向上
  ↓
バグ減少

理由2: 並行プログラミング

複数のゴルーチンでオブジェクト共有
  ↓
誰が解放する? → GCが自動判断
  ↓
並行プログラミングが簡単
  ↓
ゴルーチンを気軽に使える

理由3: シンプルなAPI

メモリ管理を隠蔽
  ↓
インターフェースがシンプル
  ↓
ドキュメントが簡単
  ↓
使いやすいライブラリ
GCは本当に遅くないのか?

現代のGoのGC:

停止時間: <1ms (サブミリ秒)
並行実行: 別のCPUコアで
影響: レイテンシ増加 <5%
  
実用上: 問題なし!

改善の歴史:

Go 1.5 (2015): 300ms
Go 1.8 (2017): 1ms
Go 1.12 (2019): <1ms
Go 1.19+ (2022〜): <0.5ms

継続的に改善中!
プログラマーができること

最適化の原則:

1. 不要なメモリ確保を避ける
2. オブジェクトを再利用する
3. 適切なデータ構造を選ぶ
4. プロファイリングで確認

効果:

注意深いプログラミング
  ↓
GCの負担を大幅に削減
  ↓
10倍以上の性能向上も可能
トレードオフ
観点手動管理(C)所有権(Rust)GC(Go)
学習曲線緩やか
開発速度遅い速い
メモリ安全性
性能最高最高高い
並行処理困難簡単
予測可能性
Goの立場
完璧な性能 < 実用的な性能 + 生産性

GCのオーバーヘッド(5%)
  vs
開発速度の向上(50%以上)
  
トータルで見て、GCが勝利!
実用的な結論

GCが問題になる場合:

- リアルタイムシステム
- 組み込みシステム
- ゲームエンジン(一部)
  
→ RustやC/C++を検討

GCで十分な場合:

- Webサーバー
- マイクロサービス
- ネットワークツール
- バッチ処理
- CLIツール
- ほとんどのビジネスアプリケーション
  
→ Goが最適!
最終的なメッセージ
「GCは遅い」は古い常識
  ↓
現代のGCは高速
  ↓
実用上問題なし
  ↓
生産性の向上が大きい
  ↓
正しいトレードオフ

統計:

Microsoftの調査:
  セキュリティ脆弱性の70%が
  メモリ安全性の問題
  
GCがこれを解決
  ↓
セキュアなシステム

Google、Uber、Dropboxなどの大企業が採用:

理由:
  開発速度
  メモリ安全性
  並行処理の容易さ
  十分な性能
  
結論: GCは正しい選択

初心者へのアドバイス:

1. GCを恐れない
   → 現代のGCは高速

2. まず書く、次に最適化
   → 早すぎる最適化は悪

3. プロファイリングで確認
   → 推測ではなく測定

4. 必要なら最適化
   → 注意深いコードで大幅改善可能

5. ビジネスロジックに集中
   → メモリ管理はGCに任せる

この設計判断により、Goは「最高の性能」よりも「十分な性能と高い生産性」を選びました。これは、現代のソフトウェア開発において非常に賢明な選択です。

実際、ほとんどのアプリケーションでボトルネックはGCではなく、I/O、ネットワーク、データベースです。GCを心配する前に、まず本当のボトルネックを特定しましょう! 🎯

そして、もしGCが本当に問題になったら? その時は詳細なプロファイリングをして、適切な最適化をすればいいのです。多くの場合、コードを少し改善するだけでGCの影響は無視できるレベルになります! 🚀

おわりに 

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

よっしー
よっしー

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

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

コメント

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