Goのパフォーマンス分析
パフォーマンス分析は決して難解なものではありません。pprof、benchmark、traceを組み合わせて使用すれば、GoプログラムにおけるCPU、メモリ、および並行処理のボトルネックを特定することができます。
Go API の応答時間が 50 ミリ秒から 5 秒に延びた場合、問題のトラブルシューティングのためにコードにログを追加しますか、それともツールを使って問題の原因を特定しますか?このレッスンでは、Go のパフォーマンス分析に役立つ一連のツールをすべて習得します。
1. 学習内容
pprof起動方法(HTTP / テスト / ファイル)- ホットスポット関数のCPUプロファイル解析
- メモリ割り当てのヒーププロファイル解析
- ゴルーチンプロファイリングを用いた並行処理の問題の分析
benchmarkベンチマークテスト +-benchmemtrace: ゴルーチンスケジューリングの追跡-raceデータ競合の検出
2. バックエンドエンジニアの実話
(1) 課題:APIの応答時間が50ミリ秒から5秒に延びた。原因を突き止めるのに、1週間かけてログを分析する必要があった
ボブは決済チームのバックエンドエンジニアですが、彼が担当するAPIの処理速度が最近ますます遅くなってきています:
「1ヶ月前までは決済インターフェースは問題なく動作していたのに、今週になって応答に5秒もかかるようになった。各関数の開始と終了にタイムスタンプを付けるために
fmt.Printlnを使い、50行のログ出力を追加した。コードを10回修正して10回デプロイしたが、それでも問題の原因が特定できなかった。上司から『もう1週間も経つのに、一体何が問題なんだ?』と聞かれた。」
彼の疑いの矛先は、以下の点に向けられている:
❌ データベースの処理が遅すぎる?—— しかし、検索すると…… 2ms
❌ 下流サービスのタイムアウト?—— 呼び出されたが、応答は正常である
❌ ネットワーク遅延?—— すべて同じサーバールームにある
✅ 実際の原因:文字列の連結により、大量のメモリが割り当てられる + GC 頻繁に
(2) Goの解決策:精密なデバッグのためのpprof
import (
"net/http"
_ "net/http/pprof" // たった1行で pprof を有効化
)
func main() {
// pprof エンドポイントが /debug/pprof/ に自動登録される
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 業務コードは引き続き実行される...
}
すると、ボブは走り出した:
# 収集 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メソッド(最も一般的なもの)
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))
}
# 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)
// 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()
}
}
# 実行 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のホットスポットの特定
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))
}
# 収集 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 の出力の解釈
(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. ヒーププロファイル
▶ サンプル:メモリリークの特定
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))
}
# 収集 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) ヒープ表示モード
# 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. ゴルーチンプロファイル
# 表示 goroutine 数量と状態
$ go tool pprof http://localhost:6060/debug/pprof/goroutine
# 表示 goroutine stack trace(テキスト)
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2
▶ サンプル:ゴルーチンリークの検出
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))
}
$ 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
// 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...)
}
}
$ 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
▶ サンプル:トレース
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)
}
# 生成 trace ファイルの後に、ブラウザで表示する
$ go tool trace trace.out
# ブラウザを開く、表示:
# - goroutine 分析:どれ goroutine どのくらいの時間稼働しますか
# - スケジューリングの遅延:goroutine いつスケジューリングされるのか
# - ネットワークの輻輳:goroutine 何を待っているのか
# - システムコール:GC いつ実行するか
8. 完全な例:「500 msの応答遅延」の特定
// 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))
}
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 操作]
pprof を実行してデータを収集し、そのデータに基づいて判断を下しましょう。Goにおける最も一般的なパフォーマンスのボトルネックは、過剰なメモリ割り当て(高いGC負荷)と非効率的な文字列連結です。
❓ よくある質問
import _ "net/http/pprof" を実行し、HTTP サービスを起動します。エンドポイントは /デバッグ/pprof/ に自動的に登録されます。(2) テストモード:go test -cpuprofile=cpu.prof -memprofile=mem.prof。本番環境では、専用ポート(外部に公開されていないもの)経由でHTTPメソッドを使用してください。curl /デバッグ/pprof/goroutine?デバッグ=2 を使用すると、各ゴルーチンごとのスタックトレースを表示できます。[chan receive] 状態のゴルーチンが多数ある場合は、チャネルリークを示している可能性があります。[IO wait] 状態のゴルーチンが多数ある場合は、接続プールが不足していることを示している可能性があります。-benchmem オプションの出力をどのように解釈すればよいですか?ns/op(1操作あたりの時間)、B/op(1操作あたりの割り当てバイト数)、および allocs/op(1操作あたりの割り当て回数)。最適化の目標:GC時間はオブジェクトの数に比例するため、allocs/op(割り当て回数)を削減することです。go run -race main.go または go test -race ./... を使用します。レース検出機能は、実行時にデータレースを検出します。同じ変数に対する並行した読み取りと書き込み(少なくとも1回の書き込みがある場合)があると、警告が発生します。CI/CD環境では常に-raceを有効にすることを推奨しますが、実行速度が大幅に低下(5~20倍)するため、本番環境では有効にしないでください。+ を使用する代わりに strings.Builder を使用すること; (2) スライスやマップのサイズを事前に割り当て忘れること; (3) 頻繁なJSONのシリアライズ/デシリアライズ; (4) リソースが解放されないまま残るGoroutineのリーク; (5) チャネルの不適切な使用によるブロッキング; (6) 激しいロック競合。pprofを使用してこれらの問題を特定し、一つずつ最適化してください。📖 まとめ
- pprofの起動:
import _ "net/http/pprof"+ HTTPエンドポイント - CPUプロファイル:
/デバッグ/pprof/profile?seconds=30 - ヒーププロファイル:
/デバッグ/pprof/heap(4つのモード) - ゴルーチンプロファイル:
/デバッグ/pprof/goroutine?デバッグ=2 - ベンチマーク:
go test -bench=. -benchmem -cpuprofile=... - トレース:
go tool trace trace.out(スケジューリングの遅延を確認するため) - レース検出器:
go test -race ./... - 最適化プロセス:測定 → 位置決め → 最適化――当て推量は一切不要
📝 練習問題
-
基本問題(難易度 ⭐):パフォーマンスの問題があるプログラム(
+を使用した大量の文字列連結を含む)を作成し、pprof の HTTP エンドポイントを有効にします。go tool pprof -http=:8081を実行して CPU プロファイルを表示し、ホットスポットを特定してください。その後、strings.Builderを使用してコードを最適化し、変更前後の CPU プロファイルを比較してください。 -
上級問題(難易度 ⭐⭐):ベンチマークとpprofを使用して、JSONシリアライゼーションのパフォーマンスを分析してください。要件:(1) 100個のフィールドを含む構造体を作成すること;(2)
json.Marshalとjson.Encoderのパフォーマンスを比較すること; (3)-benchmemを使用してメモリ割り当てを確認する;(4)-cpuprofileを使用してプロファイルを生成し、go tool pprofを用いてホットスポットを分析する。 -
課題(難易度:⭐⭐⭐):メモリリークのあるプログラムを診断し、修正してください。メモリリーク(goroutineリーク+スライスリーク)を含むGoコードが提供されます。要件:(1) pprofによるヒーププロファイリングを使用して、リークの発生源を特定すること;(2) ゴルーチンプロファイルを使用して、リークの数値を確認すること;(3)
-raceを使用して、並行処理上の問題を検出すること;(4) すべての問題を修正した後、pprofを使用してリークがなくなったことを確認すること;(5) 完全な診断レポートを作成すること。



