Go

Go言語並行処理パターン完全ガイド:Context と pprof で極めるパフォーマンスチューニング

マイクロサービスやデータ処理基盤で Go を選ぶ最大の理由は 軽量スレッド (goroutine) による圧倒的スループットです。しかし、ただ go func() を乱発するだけでは CPU 使用率が上がり続けるだけ。この記事では実務で頻出する並行処理パターンを体系的に整理し、context によるキャンセル制御と pprof / bench を使った ボトルネック解析→最適化 の手順をステップバイステップで解説します。


この記事で得られること

  • Go の代表的な並行処理パターン 5 つをコード付きで理解できる
  • contexterrgroup を活用した安全なキャンセル/エラーハンドリングを習得
  • go test -benchpprof で CPU・メモリプロファイルを可視化し、改善する方法を学べる
  • sync.Pool やチャネルバッファ調整で GC コストを削減する実践テクニックが身に付く

はじめに

「Go は並行処理が得意」と聞いて書いてみたものの、goroutine が増えすぎてメモリを食い尽くす or どこで止まっているか分からない──そんな経験はないでしょうか? 本稿では “実務で本当に使えるパターン”“測定→改善” の両輪を提供し、単なるお試し実装から プロダクション品質 へ引き上げることを目的とします。


goroutine 基礎復習

  • goroutine は 2KB スタックから始まる軽量スレッド。ランタイムがコストを抑えつつ OS スレッドにマッピング。
  • Channel でメモリ安全にデータ受け渡し。バッファなし(同期)とバッファあり(非同期)の違いを理解。
msgCh := make(chan string)

// 生成側
go func() {
    msgCh <- "ping"
}()

// 消費側
fmt.Println(<-msgCh) // => ping

並行処理パターン集

ゴール: ここでは「なぜそのパターンが必要か」「コードがどう動くか」「ハマりどころ」をセットで理解します。実務で“コピペして動く”だけでなく、自分のシナリオにあわせて調整できるレベルを目指しましょう。

1. Worker Pool ― ジョブを固定ゴルーチンで捌く

項目説明
ユースケースサムネイル生成・メール送信・API への並列リクエストなど “大量の独立ジョブ” を CPU コア数以内で処理したい場面
メリットgoroutine が無限増殖せず、OS スレッドやメモリの暴走を防げる
落とし穴Pool サイズが小さすぎるとスループット低下、大きすぎると文鎮化。runtime.NumCPU()**** を基準にベンチで調整
// StartPool creates n workers that read Job from jobs channel and
// push Result to results channel until ctx is cancelled.
func StartPool(ctx context.Context, n int, jobs <-chan Job) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func(workerID int) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return // ★ 親がキャンセルしたら即終了
                case job, ok := <-jobs:
                    if !ok {
                        return // jobs チャネルが閉じられた
                    }
                    r := work(job)  // ビジネスロジック
                    results <- r    // 非バッファならここで back‑pressure 発生
                }
            }
        }(i)
    }
    go func() { // 終了を待って results を閉じる
        wg.Wait()
        close(results)
    }()
    return results
}

ポイント解説

  1. ctx.Done() を最優先で監視し、上位からのキャンセルを漏らさない。
  2. results をバッファなしにすると「生産速度 > 消費速度」で自然にスローダウン=Back‑Pressure 制御。
  3. Pool サイズ = コア数 × 1〜2 が一般解だが、I/O 待ちが長いジョブはもっと増やしても OK。必ず ベンチマークで数値を確認

2. Fan‑out / Fan‑in ― 大量 I/O を並列→集約

  • Fan‑out: リクエストを複数ワーカーに配って並列実行
  • Fan‑in: ばら撒いた結果を 1 チャネルにまとめる
func FetchAll(ctx context.Context, urls []string) ([]*Resp, error) {
    jobs := make(chan string)
    res  := make(chan *Resp)

    // Fan‑out 部分:Worker Pool 再利用
    go func() {
        defer close(jobs)
        for _, u := range urls { jobs <- u }
    }()
    go StartPool(ctx, 8, jobs, res) // 8 並列

    // Fan‑in 部分:結果をスライスに貯める
    var out []*Resp
    for r := range res {
        out = append(out, r)
    }
    return out, ctx.Err()
}

よくあるエラー: res を読まないままリターン → goroutine leak。必ず受信側 (for range res) を書く。


3. Pipeline ― 複数ステージでストリーム処理

ASCII 図でイメージを掴みましょう。

[File] ──decode──▶ (img) ──resize──▶ (img) ──encode──▶ [JPEG]
          ch1               ch2               ch3

各ステージはそれ自体 Worker Pool を内包してもよい。バッファサイズ (chX の容量) を変えるとスループットとメモリ使用量がトレードオフ になるので、pprof で Heap を見つつ調整します。


4. Context キャンセル連鎖 ― Graceful Shutdown の肝

srv := &http.Server{ Addr: ":8080", Handler: mux }
// OS Signal を受けたら cancel()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)

go func() {
    <-ctx.Done()               // 親コンテキストが終了
    srv.Shutdown(context.Background()) // 子もまとめて止める
}()
  • キャンセルは片方向: 親 → 子。子から親に流れないので注意。
  • context.WithTimeout で「○秒経ったら強制キャンセル」を付与し、ダウンタイムを短縮。

5. errgroup ― エラーハンドリング付き WaitGroup

g, ctx := errgroup.WithContext(ctx)
for _, fn := range tasks {
    task := fn
    g.Go(func() error { return task(ctx) })
}
if err := g.Wait(); err != nil {
    log.Printf("first error: %v", err)
}
  • メリット: どれか 1 つがエラーを返すと ctx にキャンセルが伝播し、他の goroutine を速やかに終了。
  • パターン: DB シャーディング先を並列クエリ → 一部失敗で全体キャンセル、など。

パフォーマンス測定の基本

“速いかどうか” は推測せず必ず数値で確認する — これが Go での最適化の鉄則です。本節では ベンチ → プロファイル → 可視化 の一連フローを具体例で解説します。

ステップ 0 再現可能なベンチマークを用意する

  • テストファイル名は xxx_test.go、関数名は BenchmarkXxx(b *testing.B)
  • ベンチ対象の I/O 依存は メモリ内データ に差し替えて変動を排除。
func BenchmarkResize(b *testing.B) {
    img := dummyImage()          // 固定データ
    for i := 0; i < b.N; i++ {
        _ = Resize(img, 320, 240) // 本番関数を直接呼ぶ
    }
}

ステップ 1 go test -bench で定量評価

オプション用途
-bench .すべてのベンチを実行go test -bench .
-benchmemメモリ割当て計測 (B/op, allocs/op)go test -bench Resize -benchmem
-benchtime=3s実行時間を固定し統計精度を上げるgo test -bench . -benchtime 3s

出力サンプル

BenchmarkResize-8     120000     10052 ns/op     8.0 kB/op     3 allocs/op
  • ns/op : 1 回あたりの実行時間。
  • kB/op / allocs/op : GC 負荷の目安。特に allocs/op=0 が出れば “完全ノーアロケーション”。

Tip: 並列実行を試すときは GOMAXPROCS 環境変数で論理コア数を制御し、スケーラビリティを確認。

ステップ 2 CPU / メモリプロファイルを取得

# CPU プロファイル
go test -bench Resize -run ^$ \
        -cpuprofile cpu.out -benchtime 5s
# メモリプロファイル(ヒープ使用量)
go test -bench Resize -run ^$ \
        -memprofile mem.out -benchtime 5s
  • -run ^$ で通常テストをスキップしベンチのみ実行。
  • ベンチ時間は 5–10 秒に伸ばすとサンプリング密度が上がり分析しやすい。

ステップ 3 go tool pprof で解析

# 対話モード
go tool pprof cpu.out
(pprof) top            # 関数別 CPU 使用率
(pprof) list Resize    # ソースをホットスポット着色表示
(pprof) web            # SVG FlameGraph をブラウザ表示

# HTTP UI(v1.20+ は --http → -http)
go tool pprof -http 0.0.0.0:8080 cpu.out
  • top : 上位 10 関数を時間割合で確認。
  • list : ホットライン(◆印)が最適化候補。
  • web : Flame Graph でコールスタックを俯瞰。

ヒープ解析 では mem.out を開き、top -allocated / top -inuse を切り替え。“累積割当て” と “現在使用中” の両方を比較すると、リークとスパイクを区別できます。

ステップ 4 トレースでスケジュールを可視化(応用)

go test -bench Resize -run ^$ -trace trace.out -benchtime 5s
go tool trace trace.out  # ブラウザ UI
  • goroutine の生成・ブロック・アンブロックがタイムラインで観察でき、チャネル詰まりや GC 一時停止 の位置を特定しやすい。

ワークフローまとめ

  1. go test -bench-benchmem でベースラインを取る。
  2. cpuprofile / memprofile を取得 → pprof で関数トップ・ホットライン・FlameGraph を確認。
  3. ボトルネックに対して アルゴリズム変更 / オブジェクト再利用 / バッファ最適化 を適用。
  4. ベンチを再実行 → 指標が改善しているか検証。数字が正義

最適化テクニック

テクニック効果代表コード
sync.Poolオブジェクト再利用で GC 圧縮pool.Get() / pool.Put()
Channel バッファ最適化コンテキストスイッチ削減make(chan Job, runtime.NumCPU()*2)
Mutex vs Channel排他が少ないなら Mutex が速いsync.Mutex

実践プロジェクト例:画像処理パイプライン

実際に手元で ベンチ → pprof を体験できる“ラボ環境”を GitHub に用意しました。

  • ブランチ : lab/03-concurrency-bench
  • ワンコマンド : docker compose up --buildリポジトリのルート で実行)
    • ベンチ完了後、http://localhost:8080/ui/ ‑ ルートを開くとメニューが表示され、Flame Graph リンクをクリックできます で Flame Graph が開きます。

ディレクトリ構成

go-blog-examples/
├── docker-compose.yml       # ルートで `docker compose up` を実行
└── bench-lab/
    ├── pipeline/             # 各ステージ実装
    │   ├── decode.go
    │   ├── resize.go
    │   └── encode.go
    ├── benchmark/            # ベンチ & 埋め込み画像
    │   ├── image-data/sample.jpg
    │   └── pipeline_test.go
    ├── docker/
    │   ├── Dockerfile        # 実行用イメージ
    │   └── entrypoint.sh
    ├── scripts/run_bench.sh  # プロファイル生成 & UI 起動
    └── README.md             # ラボの操作ガイド

埋め込み画像 (embed) を使ったベンチマーク

go test はワーキングディレクトリが変わるため、外部ファイルへの相対パスがズレがちです。そこで embed を使って JPEG をバイナリに埋め込み、どこでも同じベンチが再現できるようにしました。

package benchmark

import (
    _ "image/jpeg"          // デコーダ登録のみ
    "testing"
    "embed"

    "github.com/izayo/go-blog-examples/bench-lab/pipeline"
)

//go:embed image-data/sample.jpg
var sampleImage []byte

func BenchmarkPipeline(b *testing.B) {
    for i := 0; i < b.N; i++ {
        img, _ := pipeline.Decode(sampleImage) // ← 失敗時は白画像でフォールバック
        img = pipeline.Resize(img, 320, 240)
        _, _ = pipeline.Encode(img, 80)
    }
}

pipeline.Decode のフォールバック実装

画像デコードに失敗した場合でもベンチが落ちないよう、真っ白 640×480 のダミー画像 を返す設計です。

func Decode(data []byte) (image.Image, error) {
    img, _, err := image.Decode(bytes.NewReader(data))
    if err != nil {
        dummy := image.NewRGBA(image.Rect(0, 0, 640, 480))
        draw.Draw(dummy, dummy.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
        return dummy, nil
    }
    return img, nil
}

ベンチ実行コマンド(単一パッケージ指定)

-cpuprofile複数パッケージには使えない ため、ベンチを含むパッケージだけをターゲットにします。

go test ./bench-lab/benchmark -bench . -benchmem \
        -cpuprofile cpu.out -memprofile mem.out -benchtime 5s

スクリプト run_bench.sh では上記コマンドを自動実行し、完了後に

# ブラウザ自動起動を抑止しつつ 0.0.0.0 で公開
PPROF_NO_BROWSER=1 go tool pprof -http 0.0.0.0:8080 bench-lab/cpu.out

を起動して Web UI を開きます。

TIP – "Couldn't find a suitable web browser!" と表示されても OK
これはブラウザ自動起動を無効化しているためのメッセージです。
サーバー自体は起動しているので、ホスト側ブラウザで http://localhost:8080/ui/ を開き、左上メニューの Flame Graph を選択してください。


まとめ

  • goroutine は軽いが無限ではない。パターンを選択し、context で制御せよ。
  • 計測なくして最適化なし。bench → pprof → 改善 のサイクルを習慣化。
  • GC 圧縮・バッファ調整・同期原始の選択は データ量・衝突率 を見て決める。

次回は Go での gRPC ストリーミング実践分散トレーシング (OpenTelemetry) を取り上げます。お楽しみに!


行動呼びかけ: 記事の Worker Pool コードをコピーし、go test -bench でバッファサイズをいじりながら pprof 可視化に挑戦してみてください。高速化のインパクトを数字で実感できます。

  • この記事を書いた人

ふくまる

機械設計業をしていたが25歳でエンジニアになると決意して行動開始→ 26歳でエンジニアに転職→ 28歳でフリーランスエンジニアに→ 現在、34歳でフリーランス7年目 Go案件を受注中 Go,GCPが得意分野

-Go