メインコンテンツまでスキップ

パフォーマンスとプロファイリング

.NET (C#) の経験がある開発者向けに、pprof、trace、ベンチマーク、アロケーション削減を解説します。C# では BenchmarkDotNet、PerfView、dotnet-trace、dotnet-counters、dotMemory などの外部ツールを使い分けますが、Go ではプロファイラー (pprof)、実行トレース (go tool trace)、ベンチマーク (testing.B)、ランタイムメトリクス (runtime/metrics) がツールチェーンと標準ライブラリに最初から含まれています。「計測してから最適化する」という流れを、Go の道具で実践する方法をまとめます。

1. このドキュメントで扱うこと​

  • net/http/pprof の有効化と読み方
  • ヒープ・アロケーション分析
  • sync.Pool と事前割り当て
  • GOMAXPROCS とコンテナ環境

net/http/pprof の有効化と読み方​

net/http/pprof をブランクインポートすると、http.DefaultServeMux に /debug/pprof/ 以下のエンドポイントが登録されます。取得できる主なプロファイルは次のとおりです。

エンドポイント内容
/debug/pprof/profile?seconds=30CPU プロファイル (指定秒数のサンプリング)
/debug/pprof/heapヒープ (メモリ使用量とアロケーション)
/debug/pprof/allocs過去のすべてのアロケーション
/debug/pprof/goroutine全ゴルーチンのスタック (?debug=2 で詳細)
/debug/pprof/block同期待ちでブロックされた時間 (有効化が必要)
/debug/pprof/mutexミューテックス競合 (有効化が必要)
/debug/pprof/trace?seconds=5実行トレース (go tool trace で開く)

取得したプロファイルは go tool pprof で読みます。

# CPU: 30 秒サンプリングして、ブラウザで開く
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"

# ヒープ: 既定は使用中のメモリ (inuse_space)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

Web UI では、Top (関数ごとの flat と cum)、Graph、Flame Graph、Source などの表示を切り替えられます。

  • flat: その関数自身で消費した量です。
  • cum: その関数が呼び出した先を含む累計です。

まず Flame Graph や Top で「幅が広い (消費が大きい)」箇所を探し、その箇所を Source で確認します。

ヒープ・アロケーション分析​

Go のメモリ性能はアロケーション回数と、GC が走査するヒープの大きさに大きく左右されます。次の観点で見ます。

  • ヒーププロファイルのサンプル種別: inuse_space / inuse_objects (今使っている量) はリークや常駐メモリの調査に、alloc_space / alloc_objects (これまでの累計) は GC 負荷や一時オブジェクトの多さの調査に使います。go tool pprof -sample_index=alloc_space ... で切り替えます。
  • 2 つのプロファイルの差分: -base (または -diff_base) で、2 時点のヒーププロファイルの差を見られます。
  • ベンチマークのアロケーション: go test -bench . -benchmem で、1 回の操作あたりのバイト数 (B/op) とアロケーション回数 (allocs/op) が出ます。
  • エスケープ解析: go build -gcflags=-m で、どの変数がヒープに逃げる (escape) かをコンパイラが報告します。値がスタックに収まるか、ヒープに確保されるかは、コンパイラが解析して決めます。C# のように、class か struct かで型が決めるわけではありません。
  • GC の調整: GOGC (既定 100) は、ヒープがどれだけ増えたら GC を始めるかの割合です。GOMEMLIMIT (Go 1.19 以降) はメモリの上限の目安を与えます。コンテナのメモリ制限が決まっている場合に、GOMEMLIMIT をその値より少し小さく設定すると、OOM を避けやすくなります。

sync.Pool と事前割り当て​

アロケーションを減らす代表的な手段は次の 3 つです。

  • 事前割り当て: スライスは make([]T, 0, n)、map は make(map[K]V, n)、strings.Builder は Grow(n) で、容量が分かっているときに先に確保します。要素の追加のたびに起きる再確保とコピーを避けられます。
  • sync.Pool: 短命で再利用できるオブジェクト (大きなバッファーなど) をプールします。.NET の ArrayPool<T> や ObjectPool<T> に近い道具です。ただし、プール内のオブジェクトは GC のタイミングで解放されることがあり、「必ず再利用される」保証はありません。
  • 不要なコピーと変換の回避: string と []byte の相互変換はコピーが発生します。fmt.Sprintf やリフレクションは便利ですが、ホットパスでは strconv や strings.Builder の方が割り当てが少なくなります。

GOMAXPROCS とコンテナ環境​

GOMAXPROCS は、Go コードを同時に実行できる OS スレッド (P) の数の上限で、既定値は論理 CPU 数です。.NET のスレッドプールが Environment.ProcessorCount やコンテナの CPU 制限を自動的に考慮するのに対し、Go は次のように異なります。

  • Go 1.24 以前: コンテナの CPU 制限 (cgroup の CPU quota) を考慮せず、ホストの論理 CPU 数を既定にします。たとえば CPU 制限が 2 でも、ホストが 64 コアなら GOMAXPROCS は 64 になり、CPU スロットリングによるレイテンシー悪化の原因になります。対策として、Uber の go.uber.org/automaxprocs を使うか、環境変数 GOMAXPROCS を明示します。
  • Go 1.25 以降: Linux では、cgroup の CPU 帯域制限を考慮して GOMAXPROCS の既定値が決まり、制限の変化にも追従します。ただし、GOMAXPROCS を環境変数または runtime.GOMAXPROCS で明示した場合は、その値が優先されます。

利用しているバージョンの挙動は、リリースノートで確認してください。

2. .NET との対応表​

.NET (C#)Go
dotnet-trace / dotnet-countersgo tool trace / runtime/metrics
PerfView / dotMemorypprof (CPU / heap / goroutine)
BenchmarkDotNettesting.B / benchstat

上の表を補う対応関係です。

.NET (C#)Go
[Benchmark] / [MemoryDiagnoser]func BenchmarkXxx(b *testing.B) / go test -bench . -benchmem
BenchmarkDotNet の統計処理 (平均、誤差など)-count=10 で複数回実行し、benchstat で比較
ArrayPool<T> / ObjectPool<T>sync.Pool
Span<T> / stackalloc によるアロケーション回避スライス (部分スライスは割り当てなし)、エスケープ解析
GC.GetTotalAllocatedBytes() / GC.CollectionCount()runtime/metrics (/gc/heap/allocs:bytes など)、runtime.ReadMemStats
DOTNET_gcServer / GCHeapHardLimitGOGC / GOMEMLIMIT
Environment.ProcessorCount / スレッドプールGOMAXPROCS / ゴルーチンのスケジューラー
dotnet-dump の dumpheap / スレッド一覧/debug/pprof/heap / /debug/pprof/goroutine?debug=2
PGO (Dynamic PGO)PGO (default.pgo を main パッケージに置く、Go 1.21 以降)

3. C# 開発者がつまずきやすいポイント​

推測せずにプロファイルする​

どこが遅いかの推測は外れることが多く、これは C# でも同じです。まず pprof で CPU とアロケーションの上位を確認してから手を入れます。最適化の前後は、同じ条件でベンチマークを取って比較します。

ベンチマークは 1 回では信頼できない​

go test -bench の結果は、マシンの負荷や CPU の周波数でぶれます。BenchmarkDotNet のように自動で統計処理をしてくれるわけではないため、-count=10 などで複数回実行し、benchstat で変更前後を比較します。benchstat は差が統計的に有意かどうかも示します。

go test -bench . -benchmem -count=10 > old.txt
# コードを変更
go test -bench . -benchmem -count=10 > new.txt
benchstat old.txt new.txt

benchstat は標準では含まれず、go install golang.org/x/perf/cmd/benchstat@latest で入れます。

ベンチマークの対象が最適化で消える​

結果を使わないループは、コンパイラの最適化で処理ごと消えたり、定数に畳み込まれたりすることがあります。Go 1.24 以降は for b.Loop() { ... } を使うと、ループ内の呼び出しがそのまま残るように扱われ、この問題と b.N の手書きが軽減されます。それより古いバージョンでは、for i := 0; i < b.N; i++ を使い、結果をパッケージ変数へ代入して消されないようにします。

pprof のエンドポイントを公開しない​

net/http/pprof は、メモリの内容に近い情報や内部構造を取得できるため、インターネットに公開してはいけません。アプリのメインのポートとは別の、localhost にだけバインドするポートで提供し、必要な場合は kubectl port-forward などでアクセスします。ブランクインポートは http.DefaultServeMux に登録するため、アプリ本体でも DefaultServeMux を使っていると、意図せず公開されます。

CPU プロファイルはサンプリング。待ちは見えない​

CPU プロファイルは CPU を使っている時間のサンプリングなので、ロック待ちや I/O 待ちで止まっている時間は映りません。遅いのに CPU が空いている場合は、block / mutex プロファイルや go tool trace を使います。block と mutex プロファイルは既定で無効のため、runtime.SetBlockProfileRate と runtime.SetMutexProfileFraction で有効化します。

値渡しとポインターの使い分けは「大きさ」だけで決めない​

C# では struct は値渡し、class は参照渡しと型で決まります。Go ではどちらも自分で選べるため、「ポインターの方が速いはず」と何でもポインターにすると、エスケープ解析の結果ヒープ割り当てが増え、GC の負荷が上がることがあります。小さな構造体は値のまま扱う方が速い場合が多く、計測して決めます。

部分スライスが巨大な配列を保持し続ける​

small := big[:10] のように部分スライスを取ると、元の配列全体が GC されずに残ります。大きなバッファーから少しだけ取り出して長期保持する場合は、slices.Clone や copy で新しいスライスにコピーします。

sync.Pool は最適化の道具であり、正しさの前提にしない​

プールから取り出したオブジェクトには前の利用の内容が残っているため、使う前に必ず Reset などで初期化します。Put した後は、そのオブジェクトへの参照を使い続けてはいけません。また、プールはいつでも空になりえます。効果は必ずベンチマークで確認します。

GC は世代別ではない​

.NET の GC は世代別で、コンパクションを行います。Go の GC は世代別でもコンパクションもしない、並行のマーク & スイープです。このため、「短命オブジェクトは安い」という前提が成り立ちにくく、アロケーションの回数と、生きているヒープの大きさが、そのまま GC の負荷になります。

小さな関数のオーバーヘッドを気にしすぎない​

defer やインターフェース経由の呼び出しなどのコストは、現在のコンパイラでは小さく、多くの場合は問題になりません。ホットパスだと確認できた箇所に限って手を入れます。

4. コード例​

例 1: pprof の有効化​

C# では、dotnet-trace collect や dotnet-counters monitor を外から接続して使いますが、Go ではアプリに数行を足して、HTTP でプロファイルを取得します。

package main

import (
"log"
"net/http"
_ "net/http/pprof" // DefaultServeMux に /debug/pprof/ を登録する
"time"
)

func main() {
// pprof 専用に、localhost にだけバインドしたサーバーを別ポートで起動する
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // nil = DefaultServeMux
}()

// アプリ本体 (自前の mux を使い、DefaultServeMux は使わない)
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}

起動後に、別のターミナルから次のように取得して分析します。

go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

# 実行トレース
curl -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"
go tool trace trace.out

go tool trace は、ゴルーチンのスケジューリング、GC、ブロッキング、ネットワーク待ちなどを時間軸で見られ、「CPU が空いているのに遅い」原因の調査に向いています。.NET の dotnet-trace と PerfView の組み合わせに近い用途です。

block と mutex のプロファイルを使う場合は、起動時に有効化します。

runtime.SetBlockProfileRate(1) // ブロックしたすべてのイベントを記録 (負荷に注意)
runtime.SetMutexProfileFraction(5) // 競合イベントの 1/5 を記録

例 2: ベンチマークとアロケーション削減​

C# (BenchmarkDotNet):

[MemoryDiagnoser]
public class BuildBenchmarks
{
[Benchmark(Baseline = true)]
public List<string> Naive()
{
var list = new List<string>();
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}

[Benchmark]
public List<string> Prealloc()
{
var list = new List<string>(1000);
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}
}

Go (build.go):

package perf

import "strconv"

func buildNaive(n int) []string {
var out []string
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}

func buildPrealloc(n int) []string {
out := make([]string, 0, n) // 容量を先に確保して、再確保とコピーを避ける
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}

Go (build_test.go、Go 1.24 以降の b.Loop を使用):

package perf

import "testing"

func BenchmarkNaive(b *testing.B) {
for b.Loop() {
buildNaive(1000)
}
}

func BenchmarkPrealloc(b *testing.B) {
for b.Loop() {
buildPrealloc(1000)
}
}

実行と比較は次のとおりです。

go test -bench . -benchmem -count=10 | tee bench.txt
benchstat bench.txt

出力には、1 回あたりの時間 (ns/op)、バイト数 (B/op)、アロケーション回数 (allocs/op) が含まれます。Go 1.23 以前では、for b.Loop() を for i := 0; i < b.N; i++ に置き換えます。CPU とメモリのプロファイルを取りたい場合は、-cpuprofile cpu.out -memprofile mem.out を付け、go tool pprof cpu.out で開きます。

例 3: sync.Pool による一時バッファーの再利用​

.NET の ArrayPool<byte>.Shared.Rent / Return に近い使い方です。

package main

import (
"bytes"
"fmt"
"sync"
)

var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}

func render(name string) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // 前回の内容が残っているため、必ず初期化する
defer bufPool.Put(buf)

buf.WriteString("hello, ")
buf.WriteString(name)
return buf.String() // String() はコピーを返すので、Put 後も安全
}

func main() {
fmt.Println(render("gopher"))
}

プールに戻したバッファーの中身を指す参照 (buf.Bytes() の結果など) を Put の後に使うと、他のゴルーチンによる再利用と衝突します。buf.String() のようにコピーを返すか、呼び出し側で使い終えてから Put します。

例 4: runtime/metrics でランタイムの状態を読む​

dotnet-counters で見ていた GC やスレッドの情報に相当する値を、プログラムから読み取れます。

package main

import (
"fmt"
"runtime/metrics"
)

func main() {
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:bytes"},
{Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)

for _, s := range samples {
switch s.Value.Kind() {
case metrics.KindUint64:
fmt.Printf("%s = %d\n", s.Name, s.Value.Uint64())
case metrics.KindFloat64:
fmt.Printf("%s = %f\n", s.Name, s.Value.Float64())
default:
fmt.Printf("%s: (未対応の種別)\n", s.Name)
}
}

// 利用できるメトリクスの一覧は metrics.All() で取得できる
fmt.Println("available metrics:", len(metrics.All()))
}

Prometheus や OpenTelemetry を使う場合は、ランタイムメトリクスを公開するコレクターやパッケージが提供されています。まず metrics.All() で、利用できる名前と説明を確認してください。

例 5: GOMAXPROCS とメモリ上限​

C# (runtimeconfig.json の例):

{
"configProperties": {
"System.GC.HeapHardLimit": 1073741824
}
}

Go では、環境変数で調整します。

# メモリの上限の目安 (ソフトリミット)。コンテナ制限より少し小さくする
export GOMEMLIMIT=900MiB

# Go 1.24 以前のコンテナでは、CPU 制限に合わせて明示する
export GOMAXPROCS=2

プログラムから確認する場合は次のようにします。

package main

import (
"fmt"
"runtime"
)

func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 は現在値の取得のみ
}

Go 1.24 以前で環境変数を設定せずに CPU 制限を自動で反映したい場合は、go.uber.org/automaxprocs を import _ "go.uber.org/automaxprocs" で読み込む方法があります。Go 1.25 以降は、ランタイム自体が cgroup の CPU 帯域制限を考慮します。

例 6: エスケープ解析を確認する​

package main

import "fmt"

type point struct{ x, y int }

func newPoint() *point {
p := point{1, 2}
return &p // 呼び出し元へポインターを返すため、ヒープに逃げる
}

func sum() int {
p := point{1, 2} // 関数内で完結するため、スタックに置ける
return p.x + p.y
}

func main() {
fmt.Println(newPoint().x, sum())
}
go build -gcflags=-m

moved to heap: p のようなメッセージで、どの変数がヒープに確保されるかを確認できます。出力は Go のバージョンによって細部が異なります。

5. まとめ​

  • Go は、プロファイラー (pprof)、実行トレース (go tool trace)、ベンチマーク (testing.B)、ランタイムメトリクス (runtime/metrics) をツールチェーンと標準ライブラリに備えています。
  • net/http/pprof は localhost 限定の別ポートで有効化し、CPU・ヒープ・ゴルーチンのプロファイルを go tool pprof で読みます。インターネットには公開しません。
  • CPU プロファイルは待ち時間を映さないため、block / mutex プロファイルや go tool trace を併用します。
  • ベンチマークは -benchmem で割り当てを見て、-count で複数回実行し、benchstat で変更前後を比較します。
  • アロケーションの削減は、事前割り当て、sync.Pool、不要なコピーと変換の回避が中心です。エスケープ解析は -gcflags=-m で確認できます。
  • Go の GC は世代別ではなく、アロケーションの回数と生きているヒープの大きさが負荷になります。GOGC と GOMEMLIMIT で調整します。
  • コンテナでは、Go 1.24 以前は GOMAXPROCS を CPU 制限に合わせる必要があります。Go 1.25 以降はランタイムが cgroup の制限を考慮します。
  • 推測ではなく計測を起点にし、ホットパスだと確認できた箇所にだけ最適化を入れます。

参考資料​