マイクロサービスやデータ処理基盤で Go を選ぶ最大の理由は 軽量スレッド (goroutine) による圧倒的スループットです。しかし、ただ go func()
を乱発するだけでは CPU 使用率が上がり続けるだけ。この記事では実務で頻出する並行処理パターンを体系的に整理し、context
によるキャンセル制御と pprof
/ bench
を使った ボトルネック解析→最適化 の手順をステップバイステップで解説します。
この記事で得られること
- Go の代表的な並行処理パターン 5 つをコード付きで理解できる
context
とerrgroup
を活用した安全なキャンセル/エラーハンドリングを習得go test -bench
とpprof
で 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
}
ポイント解説
ctx.Done()
を最優先で監視し、上位からのキャンセルを漏らさない。results
をバッファなしにすると「生産速度 > 消費速度」で自然にスローダウン=Back‑Pressure 制御。- 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 一時停止 の位置を特定しやすい。
ワークフローまとめ
go test -bench
と-benchmem
でベースラインを取る。cpuprofile / memprofile
を取得 →pprof
で関数トップ・ホットライン・FlameGraph を確認。- ボトルネックに対して アルゴリズム変更 / オブジェクト再利用 / バッファ最適化 を適用。
- ベンチを再実行 → 指標が改善しているか検証。数字が正義。
最適化テクニック
テクニック | 効果 | 代表コード |
---|---|---|
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 可視化に挑戦してみてください。高速化のインパクトを数字で実感できます。