Goのパフォーマンス分析

パフォーマンス分析は決して難解なものではありません。pprof、benchmark、traceを組み合わせて使用すれば、GoプログラムにおけるCPU、メモリ、および並行処理のボトルネックを特定することができます。

Go API の応答時間が 50 ミリ秒から 5 秒に延びた場合、問題のトラブルシューティングのためにコードにログを追加しますか、それともツールを使って問題の原因を特定しますか?このレッスンでは、Go のパフォーマンス分析に役立つ一連のツールをすべて習得します。

1. 学習内容


2. バックエンドエンジニアの実話

(1) 課題:APIの応答時間が50ミリ秒から5秒に延びた。原因を突き止めるのに、1週間かけてログを分析する必要があった

ボブは決済チームのバックエンドエンジニアですが、彼が担当するAPIの処理速度が最近ますます遅くなってきています:

「1ヶ月前までは決済インターフェースは問題なく動作していたのに、今週になって応答に5秒もかかるようになった。各関数の開始と終了にタイムスタンプを付けるためにfmt.Printlnを使い、50行のログ出力を追加した。コードを10回修正して10回デプロイしたが、それでも問題の原因が特定できなかった。上司から『もう1週間も経つのに、一体何が問題なんだ?』と聞かれた。」

彼の疑いの矛先は、以下の点に向けられている:

TEXT
❌ データベースの処理が遅すぎる?—— しかし、検索すると…… 2ms
❌ 下流サービスのタイムアウト?—— 呼び出されたが、応答は正常である
❌ ネットワーク遅延?—— すべて同じサーバールームにある
✅ 実際の原因:文字列の連結により、大量のメモリが割り当てられる + GC 頻繁に

(2) Goの解決策:精密なデバッグのためのpprof

GO
import (
    "net/http"
    _ "net/http/pprof"  // たった1行で pprof を有効化
)

func main() {
    // pprof エンドポイントが /debug/pprof/ に自動登録される
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // 業務コードは引き続き実行される...
}

すると、ボブは走り出した:

BASH
# 収集 30 秒 CPU profile
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30

# 結果:炎の図が表示される strings.Builder 占める割合は 2% CPU、
# そして strings.Join + ガベージコレクションが占める割合は 78% CPU!

(3) メリット:推測とツールの比較

手法 所要時間 精度
fmt.Println ロギング 1週間(複数回のデプロイ) ❌ 推測
pprofによるCPUプロファイル 30秒 ✅ 負荷の高い関数の正確な特定
pprof ヒーププロファイル 1 秒 ✅ 行番号単位までのメモリ割り当ての詳細

3. pprof の起動方法

▶ サンプル:HTTPメソッド(最も一般的なもの)

GO
package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"  // インポートするだけで pprof エンドポイントが登録される
    "time"
)

func slowFunction() {
    // 遅い関数のシミュレーション
    var result string
    for i := 0; i < 100000; i++ {
        result += fmt.Sprintf("%d ", i) // 不適切な文字列連結
    }
}

func main() {
    // pprof HTTP サービスを起動(独立ポート、外部非公開)
    go func() {
        log.Println("pprof 開始日: :6060")
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // ビジネスサービス
    http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
        slowFunction()
        fmt.Fprintln(w, "done")
    })

    log.Println("業務サービスの開始は :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
▶ 試してみよう
BASH
# pprof エンドポイント:
# http://localhost:6060/debug/pprof/          — トップページ
# http://localhost:6060/debug/pprof/profile   — CPU profile(デフォルト 30 秒)
# http://localhost:6060/debug/pprof/heap      — Heap profile
# http://localhost:6060/debug/pprof/goroutine — goroutine 情報
# http://localhost:6060/debug/pprof/block     — ブロッキング解析
# http://localhost:6060/debug/pprof/mutex     — ロック競合の分析

▶ サンプル:テスト手法(ベンチマーク + pprof)

GO
// string_bench_test.go
package main

import (
    "strings"
    "testing"
)

// 悪い方法:+ による文字列連結
func BenchmarkStringPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 1000; j++ {
            s += "a"
        }
    }
}

// 良い方法:strings.Builder
func BenchmarkStringBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 1000; j++ {
            sb.WriteString("a")
        }
        _ = sb.String()
    }
}

// 良い方法:事前割り当て
func BenchmarkStringBuilderPrealloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        sb.Grow(1000)
        for j := 0; j < 1000; j++ {
            sb.WriteString("a")
        }
        _ = sb.String()
    }
}
▶ 試してみよう
BASH
# 実行 benchmark(メモリ割り当てを確認する)
$ go test -bench=. -benchmem -count=3

# 生成 CPU profile
$ go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof

# 分析 profile
$ go tool pprof -http=:8081 cpu.prof

4. CPUプロファイル

▶ サンプル:CPUのホットスポットの特定

GO
package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "strings"
)

func heavyCPU() string {
    var sb strings.Builder
    for i := 0; i < 10000; i++ {
        sb.WriteString("hello")
        sb.WriteString(" ")
        sb.WriteString("world")
        sb.WriteString("\n")
    }
    return sb.String()
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    http.HandleFunc("/cpu", func(w http.ResponseWriter, r *http.Request) {
        result := heavyCPU()
        w.Write([]byte(result[:100]))
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}
▶ 試してみよう
BASH
# 収集 CPU profile(30 1秒以内に複数回アクセス /cpu エンドポイント)
$ go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30

# コマンドラインモード
$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
(pprof) top       # 表示 Top 10 注目関数
(pprof) list main # 表示 main コードの各行にかかる時間
(pprof) web       # ブラウザで可視化を開く

(1) pprof top の出力の解釈

TEXT
(pprof) top
Showing nodes accounting for 4.56s, 82.31% of 5.54s total
Dropped 28 nodes (cum <= 0.03s)
      flat  flat%   sum%        cum   cum%
     2.34s 42.24% 42.24%      2.34s 42.24%  runtime.memmove
     1.12s 20.22% 62.46%      1.12s 20.22%  runtime.mallocgc
     0.56s 10.11% 72.57%      0.56s 10.11%  strings.(*Builder).copy
     ...
意味
flat 現在の関数の実行時間
flat% 「フラット」に費やした時間が総時間のうち占める割合
sum% 累積割合
cum 現在の関数およびそれが呼び出すすべてのサブ関数にかかる時間
cum% 累積時間が総時間のうち占める割合
💡 ヒント: flat の値が高い関数は「それ自体が処理が遅い」(ホットスポット)のに対し、cum の値は高いが flat の値が低い関数は「呼び出しが原因で処理が遅い」(管理上の問題)ものです。まず、flatの値が最も高い関数を最適化してください。そうすることで、最も早く結果が得られます。


5. ヒーププロファイル

▶ サンプル:メモリリークの特定

GO
package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

var leak []string  // グローバル変数、決して GC されない

func memoryLeak() {
    // 呼び出しごとに 10000 件追加、決して削除しない
    for i := 0; i < 10000; i++ {
        leak = append(leak, "leaked string data")
    }
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    http.HandleFunc("/leak", func(w http.ResponseWriter, r *http.Request) {
        memoryLeak()
        w.Write([]byte("leaked"))
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}
▶ 試してみよう
BASH
# 収集 heap profile(現在のメモリ割り当てを確認する)
$ go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

# 表示 allocation 最も多い関数
$ go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top
(pprof) list main.memoryLeak

(2) ヒープ表示モード

BASH
# 4つの表示モード:
-inuse_space  # 現在使用中のメモリ(デフォルト)
-inuse_objects # 現在使用中のオブジェクト数
-alloc_space  # 累積割り当てメモリ総量
-alloc_objects # 累計で割り当てられた対象の総数

# 漏れを探すために alloc_space(誰が最も多く配分するか見てみよう)
$ go tool pprof -alloc_space http://localhost:6060/デバッグ/pprof/heap
モード 目的
inuse_space 現在のメモリ使用量(リーク検出の最終結果)
inuse_objects 現在のオブジェクト数(多数の小さなオブジェクトを検索する場合)
alloc_space 総割り当て量(頻繁なGCの根本原因を特定するため)
alloc_objects 割り当て総数(短命なオブジェクトを特定するため)

6. ゴルーチンプロファイル

BASH
# 表示 goroutine 数量と状態
$ go tool pprof http://localhost:6060/debug/pprof/goroutine

# 表示 goroutine stack trace(テキスト)
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2

▶ サンプル:ゴルーチンリークの検出

GO
package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

func leakyGoroutine() {
    ch := make(chan int)
    go func() {
        // この goroutine は決して終了しない
        val := <-ch  // 永久にブロックされる
        fmt.Println(val)
    }()
    // ch にデータが送信されることは決してない
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    http.HandleFunc("/leak", func(w http.ResponseWriter, r *http.Request) {
        leakyGoroutine()
        w.Write([]byte("leaked"))
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}
▶ 試してみよう
BASH
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2
# 出力内の各 goroutine の stack trace 表示:
# goroutine 5 [chan receive]:
# main.leakyGoroutine.func1()
#     /app/main.go:14
# もし大量の [chan receive] 対応する送信者がありません → 漏洩

7. ベンチマークとトレース

▶ サンプル:Benchmark + -benchmem

GO
// bench_test.go
パッケージ main

import (
    "encoding/json"
    "testing"
)

type Data struct {
    ID    int    `json:"id"`
    Name  文字列 `json:"name"`
    Email 文字列 `json:"email"`
}

// ベンチマークテスト:JSON シリアライズ性能
func BenchmarkJSONMarshal(b *testing.B) {
    data := Data{ID: 1, Name: "Alice", Email: "alice@example.com"}

    for i := 0; i < b.N; i++ {
        _, err := json.Marshal(data)
        if err != nil {
            b.Fatal(err)
        }
    }
}

// ベンチマークテスト:JSON シリアライズ + 事前割り当てバッファ
func BenchmarkJSONMarshalBuffer(b *testing.B) {
    data := Data{ID: 1, Name: "Alice", Email: "alice@example.com"}
    buf := make([]byte, 0, 256)

    for i := 0; i < b.N; i++ {
        buf = buf[:0]
        result, err := json.Marshal(data)
        if err != nil {
            b.Fatal(err)
        }
        buf = append(buf, result...)
    }
}
▶ 試してみよう
BASH
$ go test -bench=. -benchmem -count=5 ./...
BenchmarkJSONMarshal-8          10000000   156.2 ns/op   48 B/op   1 allocs/op
BenchmarkJSONMarshalBuffer-8    10000000   158.1 ns/op   48 B/op   1 allocs/op

▶ サンプル:トレース

GO
package main

import (
    "fmt"
    "os"
    "runtime/trace"
    "sync"
)

func main() {
    // trace ファイルを作成
    f, _ := os.Create("trace.out")
    defer f.Close()

    // trace を開始
    trace.Start(f)
    defer trace.Stop()

    // テスト対象コードを実行
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            result := fibonacci(30)
            fmt.Printf("Worker %d: %d\n", id, result)
        }(i)
    }
    wg.Wait()
}

func fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    return fibonacci(n-1) + fibonacci(n-2)
}
▶ 試してみよう
BASH
# 生成 trace ファイルの後に、ブラウザで表示する
$ go tool trace trace.out
# ブラウザを開く、表示:
# - goroutine 分析:どれ goroutine どのくらいの時間稼働しますか
# - スケジューリングの遅延:goroutine いつスケジューリングされるのか
# - ネットワークの輻輳:goroutine 何を待っているのか
# - システムコール:GC いつ実行するか

8. 完全な例:「500 msの応答遅延」の特定

GO
// debug_demo.go
パッケージ main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "strings"
)

// ---------- 低速 API ----------

type UserResponse struct {
    ID    int    `json:"id"`
    Name  文字列 `json:"name"`
    Email 文字列 `json:"email"`
    Bio   文字列 `json:"bio"`
}

// 誤った実装:文字列連結 + 大量アロケーション
func generateUserJSON(userID int) []byte {
    var bio strings.Builder
    // 大量テキスト生成をシミュレーション
    for i := 0; i < 1000; i++ {
        bio.WriteString(fmt.Sprintf("Line %d: User data for ID %d with some additional info\n", i, userID))
    }

    resp := UserResponse{
        ID:    userID,
        Name:  fmt.Sprintf("User_%d", userID),
        Email: fmt.Sprintf("ユーザー%d@example.com", userID),
        Bio:   bio.String(),
    }

    data, _ := json.Marshal(resp)
    return data
}

// 最適化実装:事前割り当て + フォーマット削減
func generateUserJSONOptimized(userID int) []byte {
    // 事前割り当てバッファ
    var bio strings.Builder
    bio.Grow(50000)  // おおよそのサイズ

    for i := 0; i < 1000; i++ {
        bio.WriteString("Line ")
        bio.WriteString(fmt.Sprintf("%d", i))  // さらに strconv.Itoa で最適化可能
        bio.WriteString(": User data for ID ")
        bio.WriteString(fmt.Sprintf("%d", userID))
        bio.WriteString(" with some additional info\n")
    }

    resp := UserResponse{
        ID:    userID,
        Name:  "User_" + fmt.Sprintf("%d", userID),
        Email: fmt.Sprintf("ユーザー%d@example.com", userID),
        Bio:   bio.String(),
    }

    data, _ := json.Marshal(resp)
    return data
}

// ---------- 分析プロセス ----------

/*
位置特定プロセス

(1) Step 1: 開始 pprof HTTP
go run main.go (自動的に起動します pprof で :6060)

(2) Step 2: 負荷テスト
# 別の端末でリクエストを継続する
while true; do curl http://localhost:8080/ユーザー/1 > /dev/null; done

(3) Step 3: 収集 CPU profile
go tool pprof -http=:8081 http://localhost:6060/デバッグ/pprof/profile?seconds=30

(4) Step 4: ブラウザでヒートマップを表示する
- 最も幅の広い色ブロックを観察する → 注目関数
- もし見かけたら runtime.memmove / runtime.mallocgc → メモリの割り当てが多すぎる
- クリック main.generateUserJSON → 各行のコードの実行時間を確認する

(5) Step 5: 表示 Heap profile
go tool pprof -alloc_space http://localhost:6060/デバッグ/pprof/heap
(pprof) top
*/

func main() {
    // pprof
    go func() {
        log.Println("pprof on :6060")
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // サービスエンドポイント
    http.HandleFunc("/ユーザー/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        var userID int
        fmt.Sscanf(id, "%d", &userID)

        data := generateUserJSON(userID)
        w.Header().Set("Content-Type", "application/json")
        w.Write(data)
    })

    log.Println("サービスの開始は :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
100%
flowchart TD
    A[API 応答が遅い] --> B{問題の種類?}
    B -->|CPU 高| C[pprof CPU profile]
    B -->|メモリ使用量が多い| D[pprof Heap profile]
    B -->|goroutine もっと| E[pprof goroutine profile]
    B -->|スケジューリングの遅延| F[go tool trace]

    C --> C1[炎の図を見る]
    C1 --> C2{注目関数?}
    C2 -->|runtime.memmove| G[メモリ割り当ての削減]
    C2 -->|ビジネス関数| H[アルゴリズムの最適化/キャッシュを追加する]

    D --> D1[表示 alloc_space]
    D1 --> D2{誰が最も多く配分したか?}
    D2 -->|strings.Builder| I[事前割り当て Grow]
    D2 -->|一時オブジェクト| J[使用 sync.Pool]

    E --> E1[表示 goroutine stack]
    E1 --> E2{goroutine ステータス?}
    E2 -->|chan receive 閉塞| K[確認 channel 送信者]
    E2 -->|IO wait| L[接続プールを確認する]

    F --> F1[表示 goroutine 分析]
    F1 --> F2{スケジューリングの遅延?}
    F2 -->|GC 一時停止| M[メモリ割り当ての削減]
    F2 -->|システムコール| N[最適化 IO 操作]
💡 ヒント: パフォーマンス最適化のための3段階のアプローチ:測定 → 特定 → 最適化。ボトルネックがどこにあるかを推測するのではなく、まず pprof を実行してデータを収集し、そのデータに基づいて判断を下しましょう。Goにおける最も一般的なパフォーマンスのボトルネックは、過剰なメモリ割り当て(高いGC負荷)と非効率的な文字列連結です。


❓ よくある質問

Q pprof を起動するにはどうすればよいですか?
A 2つの方法があります:(1) HTTP メソッド:import _ "net/http/pprof" を実行し、HTTP サービスを起動します。エンドポイントは /デバッグ/pprof/ に自動的に登録されます。(2) テストモード:go test -cpuprofile=cpu.prof -memprofile=mem.prof。本番環境では、専用ポート(外部に公開されていないもの)経由でHTTPメソッドを使用してください。
Q CPUプロファイルとヒーププロファイルの違いは何ですか?
A CPUプロファイルは「CPUが現在実行している関数」をサンプリング(時間ベースのサンプリング)し、CPUのホットスポットを特定するために使用されます。一方、ヒーププロファイルは「メモリに割り当てられたオブジェクト」をサンプリングし、メモリリークやGCの負荷を特定するために使用されます。これらは2つの異なる種類のプロファイルであり、同時に収集することができます。
Q ゴルーチンプロファイルでは何に注目すべきですか?
A ゴルーチンに関する数値とステータスを確認してください。curl /デバッグ/pprof/goroutine?デバッグ=2 を使用すると、各ゴルーチンごとのスタックトレースを表示できます。[chan receive] 状態のゴルーチンが多数ある場合は、チャネルリークを示している可能性があります。[IO wait] 状態のゴルーチンが多数ある場合は、接続プールが不足していることを示している可能性があります。
Q ベンチマークにおける -benchmem オプションの出力をどのように解釈すればよいですか?
A 3つの列があります:ns/op(1操作あたりの時間)、B/op(1操作あたりの割り当てバイト数)、および allocs/op(1操作あたりの割り当て回数)。最適化の目標:GC時間はオブジェクトの数に比例するため、allocs/op(割り当て回数)を削減することです。
Q trace と pprof の違いは何ですか?
A pprof は「ボトルネックはどこか?」という問いに答えるために「スナップショット」(サンプリング)を撮影します。一方、trace は「なぜ遅いのか?」という問いに答えるために「動画」(イベントストリーム)を記録します。trace は、ゴルーチンのスケジューリングに関する完全なタイムライン(ゴルーチンがいつ実行され、いつブロックされ、いつ GC が一時停止するか)を提供します。まず pprof を使って問題のある箇所を特定し、次に trace を使って根本原因を分析します。
Q レース検出器はどのように使用しますか?
A go run -race main.go または go test -race ./... を使用します。レース検出機能は、実行時にデータレースを検出します。同じ変数に対する並行した読み取りと書き込み(少なくとも1回の書き込みがある場合)があると、警告が発生します。CI/CD環境では常に-raceを有効にすることを推奨しますが、実行速度が大幅に低下(5~20倍)するため、本番環境では有効にしないでください。
Q Goにおける一般的なパフォーマンスのボトルネックにはどのようなものがありますか?
A (1) 文字列連結に + を使用する代わりに strings.Builder を使用すること; (2) スライスやマップのサイズを事前に割り当て忘れること; (3) 頻繁なJSONのシリアライズ/デシリアライズ; (4) リソースが解放されないまま残るGoroutineのリーク; (5) チャネルの不適切な使用によるブロッキング; (6) 激しいロック競合。pprofを使用してこれらの問題を特定し、一つずつ最適化してください。

📖 まとめ


📝 練習問題

  1. 基本問題(難易度 ⭐):パフォーマンスの問題があるプログラム(+ を使用した大量の文字列連結を含む)を作成し、pprof の HTTP エンドポイントを有効にします。go tool pprof -http=:8081 を実行して CPU プロファイルを表示し、ホットスポットを特定してください。その後、strings.Builder を使用してコードを最適化し、変更前後の CPU プロファイルを比較してください。

  2. 上級問題(難易度 ⭐⭐):ベンチマークとpprofを使用して、JSONシリアライゼーションのパフォーマンスを分析してください。要件:(1) 100個のフィールドを含む構造体を作成すること;(2) json.Marshaljson.Encoderのパフォーマンスを比較すること; (3) -benchmem を使用してメモリ割り当てを確認する;(4) -cpuprofile を使用してプロファイルを生成し、go tool pprof を用いてホットスポットを分析する。

  3. 課題(難易度:⭐⭐⭐)メモリリークのあるプログラムを診断し、修正してください。メモリリーク(goroutineリーク+スライスリーク)を含むGoコードが提供されます。要件:(1) pprofによるヒーププロファイリングを使用して、リークの発生源を特定すること;(2) ゴルーチンプロファイルを使用して、リークの数値を確認すること;(3) -raceを使用して、並行処理上の問題を検出すること;(4) すべての問題を修正した後、pprofを使用してリークがなくなったことを確認すること;(5) 完全な診断レポートを作成すること。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%