Day 039 — pprofによるプロファイリング②(メモリ)(実践: 文字列連結によるアロケーション過多の特定)

2026-09-05 🟡 上級者 / Phase 3 実装 pprofによるプロファイリング②(メモリ)

📚 背景知識(読んでから問題へ)

Day 038ではruntime/pprofのCPUプロファイルを使い、「どの関数がCPU時間を消費していたか」を実測データから特定しました。Day 039で扱うのは同じpprofのもう一つの柱、メモリ(ヒープ)プロファイルです。

Day 037では-gcflags="-m"(エスケープ解析)を使って、「このコードのどの値がヒープに逃げるか」をコンパイル時に静的に予測しました。しかしエスケープ解析は「逃げるかどうか」を教えてくれるだけで、「実際に何バイト・何回、どのくらいの頻度でアロケーションが発生しているか」までは教えてくれません。それを実行時に実測するのが、runtime/pprofのWriteHeapProfileとgo tool pprofです。

📌
ヒーププロファイルには4種類のサンプルタイプが含まれており、go tool pprofのフラグで切り替えて見ます。
  • alloc_objects: プロファイル対象期間中にアロケーションされたオブジェクトの総数(すでに解放されたものも含む累積値)
  • alloc_space: プロファイル対象期間中にアロケーションされた総バイト数(同上、累積値)
  • inuse_objects: プロファイルスナップショットを取った瞬間に生存しているオブジェクトの数
  • inuse_space: プロファイルスナップショットを取った瞬間に生存しているバイト数

go tool pprofはデフォルトでinuse_spaceを表示します。累積のアロケーション量を見たい場合は明示的に-alloc_space(または-alloc_objects)を指定する必要があります。

📌
この2系統(alloc_* と inuse_*)の違いを理解することが、Day 039のゴールです。alloc_spaceが巨大でもinuse_spaceが小さいなら、それは「メモリリークではないが、大量のゴミを作ってはGCに掃除させている」状態です。逆にinuse_spaceが時間とともに増え続けるなら、それはリークの兆候です。

📝 問題

以下は、n行分のログ行を結合して1つのレポート文字列を作るバッチ処理プログラムです。機能的には正しいですが、大きなnを指定すると著しく遅く、かつ大量のメモリを消費します。

package main

import (
	"flag"
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
	"strconv"
)

// buildReportSlow は n 行分の行を文字列連結(+=)で結合してレポートを生成する
func buildReportSlow(n int) string {
	report := ""
	for i := 0; i < n; i++ {
		report += "line " + strconv.Itoa(i) + ": some log data here\n"
	}
	return report
}

func main() {
	memprofile := flag.String("memprofile", "", "write memory profile to file")
	flag.Parse()

	report := buildReportSlow(50000)
	fmt.Println("report length:", len(report))

	if *memprofile != "" {
		f, err := os.Create(*memprofile)
		if err != nil {
			fmt.Fprintln(os.Stderr, "could not create memory profile:", err)
			os.Exit(1)
		}
		defer f.Close()
		runtime.GC() // スナップショット前にGCを走らせ、inuseの値を正確にする
		if err := pprof.WriteHeapProfile(f); err != nil {
			fmt.Fprintln(os.Stderr, "could not write memory profile:", err)
			os.Exit(1)
		}
	}
}

要件

  1. 上記コードをgo run main.go -memprofile=mem.profで実行し、メモリプロファイルを取得する手順(ビルド〜実行〜go tool pprofでの確認)を具体的なコマンド列で示す。-alloc_spaceと-inuse_space(デフォルト)の使い分けにも触れること
  2. go tool pprof -top -alloc_space mem.profを実行した場合にどの関数がalloc_spaceの大半を占めると予測されるか、コードの構造から論理的に説明する
  3. -alloc_spaceで見た総アロケーションバイト数と、-inuse_spaceで見たスナップショット時点の生存バイト数が大きく乖離すると予測される理由を、Goの文字列の不変性とGCの動作の観点から説明する
  4. buildReportSlowのアルゴリズムを、外部から見た戻り値(生成されるレポート文字列)を変えずに、アロケーション回数を削減する形に書き直す(strings.Builderを使うこと)。go run main.goでそのまま動く完全なコードを提示すること
  5. 修正前後でgo tool pprof -alloc_spaceの出力がどう変わると予測されるか、またgo test -bench . -memprofile=mem.profを使ってベンチマーク実行中にメモリプロファイルを取得する場合のテストコードとコマンドを示す
  6. Day 037で扱った-gcflags="-m"による静的なエスケープ解析予測と、本問のようなruntime/pprofによる動的なメモリプロファイリングは、それぞれ何がわかり何がわからないかを整理する

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Goの文字列は不変(immutable)です。report += "..."を書くたびに、Goは新しいバイト列をヒープに確保し、古いreportの中身をコピーした上で追記分をつなげる、という動作をします。古いreportが指していたバイト列はどこからも参照されなくなり、次のGCでゴミとして回収される対象になります。このループがn回繰り返されると何が起きるかを考えるところから始めるとよいです。

ヒント2 — アプローチ
  • ループのi回目で確保される新しいバイト列のサイズは、およそ「これまでの累積長 + 今回追記する長さ」にほぼ比例する。これがn回積み重なると、総アロケーションバイト数(alloc_space)は最終的な文字列長よりもはるかに大きく、n の2乗のオーダーに近づく
  • 一方、inuse_spaceはプロファイルを取った瞬間に生きているものだけを数える。ループ内で使い捨てられた中間文字列はすでにGC対象になっているため、inuse_spaceには最終的なreport(とその周辺のわずかなオーバーヘッド)しか計上されない
  • strings.Builderは内部にバイトスライスのバッファを持ち、WriteStringのたびに文字列全体を作り直すのではなく、バッファに追記していく。Grow(n)で事前に容量を確保しておけば、バッファの再確保(コピーを伴う)そのものを避けられる
  • go tool pprofのフラグは-inuse_space(デフォルト)/ -inuse_objects / -alloc_space / -alloc_objectsの4通り。累積のアロケーション傾向を見るときは必ず-alloc_spaceか-alloc_objectsを明示する
ヒント3 — コード骨格

検証の骨格:

mkdir day039 && cd day039
go mod init example.com/day039
# main.go に上記コードを保存する
go build -o day039bin .
./day039bin -memprofile=mem.prof
# report length: XXXXXXX が出力され、mem.prof が生成される

go tool pprof -top -alloc_space mem.prof
# 上位に buildReportSlow が alloc_space の大半を占める形で出るはずである

go tool pprof -top -inuse_space mem.prof
# alloc_space と比べて桁違いに小さい値になるはずである

go tool pprof mem.prof
# 対話モードに入ったら以下を試す
# (pprof) list buildReportSlow

✅ 模範解答

1. メモリプロファイル取得の手順

# 1. モジュール初期化とビルド
mkdir day039 && cd day039
go mod init example.com/day039
# main.go に問題文のコードを保存

# 2. -memprofile フラグを付けて実行し、プロファイルを取得する
go build -o day039bin .
./day039bin -memprofile=mem.prof
# 標準出力: report length: 1450000(値は環境依存)
# カレントディレクトリに mem.prof が生成される

# 3. go tool pprof で累積アロケーション(alloc_space)を確認する
go tool pprof -top -alloc_space mem.prof

# 4. 比較のため、スナップショット時点の生存バイト数(inuse_space、デフォルト)も確認する
go tool pprof -top -inuse_space mem.prof

-alloc_spaceの出力イメージ(実測値は環境依存だが、傾向はほぼ確実に以下のようになります):

Showing nodes accounting for 950.12MB, 99.80% of 951.98MB total
      flat  flat%   sum%        cum   cum%
  920.45MB 96.70% 96.70%   950.12MB 99.80%  main.buildReportSlow
   29.67MB  3.12% 99.82%    29.67MB  3.12%  strconv.Itoa

-inuse_spaceの出力イメージ:

Showing nodes accounting for 1.45MB, 98.60% of 1.47MB total
      flat  flat%   sum%        cum   cum%
     1.45MB 98.60% 98.60%     1.45MB 98.60%  main.buildReportSlow
📌
使い分け: -alloc_spaceは「実行中に累積でどれだけメモリを確保し続けたか」=GC負荷の総量を見るのに使います。-inuse_spaceは「今この瞬間に何が生き残っているか」=メモリリークや現在のメモリ使用量の内訳を見るのに使います。両者は目的が違うため、必ずどちらを見ているか意識してフラグを指定します。

2. alloc_spaceの大半を占める関数の予測

buildReportSlowが圧倒的にalloc_spaceの大半(95%超)を占めると予測できます。理由は次の通りです。

  • ループ内のreport += "line " + strconv.Itoa(i) + ": some log data here\n"は、実行のたびに新しい文字列(新しいバイト列)をヒープに確保する。式の評価順序としても、右辺の文字列連結自体が中間文字列を生成し、さらにその結果をreportに代入する際に古いreportの中身をコピーした新しいバイト列が確保される
  • strconv.Itoa(i)も毎回小さなアロケーションを行うが、report本体の再構築で発生するバイト数(ループが進むごとに線形に増大していく)に比べればごくわずかである
  • buildReportSlowは子関数を呼び出してはいるものの、支配的なコストは自分自身の代入・連結処理そのものにあるため、flatが極めて高くなる

3. alloc_spaceとinuse_spaceが大きく乖離する理由

Goの文字列は不変です。report += ...は「既存のreportを書き換える」のではなく、「新しいバイト列を確保し、reportという変数がそちらを指すように付け替える」という操作です。この結果、i回目のループでは、それまでのreportの長さにほぼ比例したバイト数の新しい確保が発生します。n回のループ全体を通した確保バイト数の合計は、およそ以下のように見積もれます。

確保バイト数の合計 ≈ Σ(i=1 to n) (1行あたりの長さ × i) ∝ n²

つまりalloc_spaceはnの2乗のオーダーで積み上がっていきます。一方で、i回目に確保された「古い方の」バイト列は、i+1回目の代入が終わった瞬間にどこからも参照されなくなり、ガベージコレクションの対象になります。プログラムの最後でruntime.GC()を呼んでからスナップショットを取ると、そうした使い捨ての中間文字列はすでに回収済みで、inuse_spaceには最終的なreport一つ分の実サイズしか残りません。

📌
したがって「alloc_spaceは巨大(n²オーダー)だがinuse_spaceは小さい(最終結果のサイズ程度)」という大きな乖離が生まれます。これはメモリリークではなく、GCに大量の後始末をさせている「アロケーション過多(churn)」という別種の問題であるという点が重要です。inuse_spaceだけを見ていると、この問題は見逃されてしまいます。

4. strings.Builderによる書き直し

package main

import (
	"flag"
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
	"strconv"
	"strings"
)

// buildReportFast は strings.Builder を使い、n行分の行を結合してレポートを生成する。
// buildReportSlow(文字列連結によるO(n²)アロケーション)と異なり、
// バッファへの追記のみでレポートを構築する。
func buildReportFast(n int) string {
	var b strings.Builder
	b.Grow(n * 32) // 1行あたりのおおよそのサイズで事前に容量を確保し、再確保を避ける
	for i := 0; i < n; i++ {
		b.WriteString("line ")
		b.WriteString(strconv.Itoa(i))
		b.WriteString(": some log data here\n")
	}
	return b.String()
}

func main() {
	memprofile := flag.String("memprofile", "", "write memory profile to file")
	flag.Parse()

	report := buildReportFast(50000)
	fmt.Println("report length:", len(report))

	if *memprofile != "" {
		f, err := os.Create(*memprofile)
		if err != nil {
			fmt.Fprintln(os.Stderr, "could not create memory profile:", err)
			os.Exit(1)
		}
		defer f.Close()
		runtime.GC()
		if err := pprof.WriteHeapProfile(f); err != nil {
			fmt.Fprintln(os.Stderr, "could not write memory profile:", err)
			os.Exit(1)
		}
	}
}

buildReportSlow(50000)とbuildReportFast(50000)は同じ内容・同じ長さの文字列を返します(外部から見た戻り値は不変)。Grow(n * 32)によって、バッファの拡張(内部スライスの再確保とコピー)はほぼ発生しなくなり、WriteStringは既存バッファへの追記だけで済みます。

5. 修正前後のプロファイル比較とベンチマーク実行時のプロファイル取得

プロファイルの変化予測:

修正前はbuildReportSlowのalloc_spaceがn²オーダーで巨大でしたが、修正後はbuildReportFastのalloc_spaceが最終的な文字列サイズとほぼ同じオーダー(Growにより1〜数回の内部バッファ確保のみ)まで劇的に減少します。alloc_spaceとinuse_spaceの乖離もほぼ解消され、両者が近い値になるはずです。これは「ゴミを大量に作ってGCに掃除させる」パターンから「必要な分だけ確保する」パターンへ変わったことの直接的な証拠になります。

ベンチマークでのプロファイル取得:

// main_test.go
package main

import "testing"

func BenchmarkBuildReportFast(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buildReportFast(50000)
	}
}

func BenchmarkBuildReportSlow(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buildReportSlow(50000)
	}
}
go test -bench . -benchmem -memprofile=mem.prof -benchtime=5x
go tool pprof -top -alloc_space mem.prof

-benchmemはベンチマーク結果のサマリ(1回あたりのアロケーション回数・バイト数)をテキストで即座に表示してくれる一方、-memprofileはさらに詳細な「どの行が」アロケーションしているかまでgo tool pprofで掘り下げられる形で保存します。両者は併用が前提であり、まず-benchmemの数字で「悪化していないか」を確認し、悪化していれば-memprofileで原因箇所を特定する、という順序で使います。

6. 静的なエスケープ解析予測と動的なメモリプロファイリングの違い

観点静的解析(Day037: -gcflags="-m")動的プロファイリング(Day039: runtime/pprof)
いつ得られる情報かコンパイル時(プログラムを一度も実行せずにわかる)実行時(実際にプログラムを動かして初めてわかる)
わかることある値がヒープに逃げるかどうか、その理由(ポインタが返る・インターフェースへの変換・容量が実行時までわからない等)実際に何バイト・何回アロケーションが発生したか、どの関数・どの行が支配的か、入力サイズに応じてどう変化するか
わからないこと実行時のループ回数・入力サイズに依存する実際のアロケーション量(report += ...が1回逃げると分かっても、それがn回実行されて起きる総量まではわからない)なぜその値がヒープに逃げる設計になっているのか、というコンパイラの判断根拠そのもの(結果は見えるが理由は解析ツール側で追う必要がある)
入力依存性入力に依存しない(コード構造だけで決まる)実際の入力データ・実際のトラフィックパターンに強く依存する
実務での使い方コードレビュー時に「明らかに避けられるヒープ確保」を実行前に潰す一次フィルタステージング・本番環境で実際のアロケーション量が想定と乖離していないかを実測で検証する二次フィルタ
📌
両者は対立する手法ではなく、組み合わせて使うのが実務での定石です。まず-gcflags="-m"でコード上の「逃げやすい書き方」を減らし、その上でruntime/pprofのヒーププロファイルを使って、実際のワークロードで想定以上にアロケーションが積み上がっている箇所がないかを確認します。今回のbuildReportSlowのケースでは、report += ...という1行だけを見ても「ヒープに逃げる」ことは-gcflags="-m"でもある程度推測できますが、それがループの中でn回繰り返されることでn²オーダーの総量になるという規模感は、実際に動かして-alloc_spaceを見て初めて実感できます。

🪜 Step-by-Step 解説

1
コードを読んで「不変な値への繰り返し追記」を目視で探す
プロファイラを使う前に、「文字列やスライスへの+=が繰り返しループの中にある」パターンに当たりをつける習慣を持ちます。今回はbuildReportSlowのreport += ...が典型例です。
2
-memprofileフラグでプロファイルを取得し、runtime.GC()を挟む
WriteHeapProfileを呼ぶ直前にruntime.GC()を実行しておくと、直前のガベージがまだ回収されずに残っていることによる誤差を減らし、inuse_*の値をより正確なスナップショットにできます。
3
-alloc_spaceと-inuse_spaceの両方でtopを確認する
片方だけを見ていると「累積で大量に確保している」問題と「今生き残っているものが多い」問題を混同してしまいます。必ず両方のフラグで比較します。
4
listで行単位の内訳を確認する
go tool pprof -alloc_space mem.profの対話モードでlist buildReportSlowを実行すると、report += ...の行に確保バイト数が集中していることが確認できます。
5
strings.Builderに書き換え、再度プロファイルを取って改善を実測で確認する
コードを直しただけで満足せず、修正後も同じ手順でプロファイルを取り直し、alloc_spaceが実際に劇的に減ったかを確認します。これがDay 038で学んだベンチマーク駆動最適化のサイクルを、メモリ軸でも同じように回すということです。

💡 設計思想・なぜこう書くのか

📌
不変性とパフォーマンスを利用者の選択に委ねる: Goの文字列が不変である設計は、並行処理下での安全性(複数のgoroutineが同じ文字列を安全に共有できる)というメリットをもたらす一方、「文字列を繰り返し書き換えるような処理」を素朴に書くと本問のような二次関数的なアロケーションを生みやすいというトレードオフを伴います。Goはこの問題に対して+=を禁止するのではなく、strings.Builderという専用の可変バッファ型を標準ライブラリに用意することで対応しています。「不変性はデフォルトで守るが、性能が必要な場面では明示的に可変なバッファ型を選べる」という、安全性とパフォーマンスの両立を利用者の選択に委ねる設計思想です。
📌
時間とメモリを同じ解析基盤の上に統一する: runtime/pprofがCPUプロファイルと全く同じインターフェース(importして数行書くだけ)でヒーププロファイルも扱えるようにしている点も重要です。「時間を計測する仕組み」と「メモリを計測する仕組み」を別々の学習コストがかかる外部ツールとして提供するのではなく、同じgo tool pprofという一つの解析基盤の上に統一することで、エンジニアが計測対象を切り替えるたびに新しいツールを覚え直す必要がないようにしています。

🌐 他言語との比較

言語同等の概念Goとの違い
Javaヒープダンプ(jmap, VisualVM)、StringとStringBuilderの関係JavaにもGoと全く同じ「不変なString」と「可変なStringBuilder」の関係がある。ヒープダンプの取得・解析は専用GUIツール(VisualVM, JDK Mission Control)に依存することが多いが、Goはgo tool pprofのCLI一本で完結する
Pythontracemalloc, objgraphtracemallocは標準ライブラリだがGoのalloc_space/inuse_spaceほど明確に「累積」と「現存」を分けて可視化する使い勝手ではない。またPythonの文字列も不変だが、CPythonには小さな文字列の連結を最適化する特殊なケースが一部あり、Goほど素直に「毎回新規確保」という直感が成り立つとは限らない
RustString::push_strとスタック/ヒーププロファイラ(heaptrack, valgrind --tool=massif)RustのStringはもともと可変(push_strで追記可能)であり、Vec<T>同様に内部で容量管理を行う。Goのstrings.Builderが担っている役割を、RustではString自体が標準で持っている点が異なる

🏆 実務での使いどころ

  • バッチ処理・レポート生成のパフォーマンス劣化調査: 大量データを文字列やスライスに逐次追記していく処理は、データ量が増えるにつれて指数関数的にではなく二次関数的に遅くなることが多い。ユーザー数・データ件数の増加でバッチが急に遅くなったときの典型的な原因の一つ
  • GC負荷が高いサービスの調査: レイテンシのp99が不安定なサービスで、CPU使用率は低いのにGCの実行頻度が高い場合、-alloc_spaceを見て「無駄に大量のゴミを作っている箇所」を特定する
  • メモリリークとアロケーション過多の切り分け: 「メモリ使用量が増え続けている」という報告に対して、inuse_spaceを時系列で複数回取得し増加傾向があればリーク、alloc_spaceだけが大きくinuse_spaceは安定していればアロケーション過多(churn)、と原因を切り分けられる
  • 文字列・スライス構築処理のコードレビュー基準: ループ内で+=(文字列)や、容量指定なしのappend(スライス)を見つけたら、strings.Builderやmake([]T, 0, cap)への置き換えを検討する、という具体的なレビュー観点として使える

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
inuse_spaceが小さいから問題ないと判断してしまう「今のメモリ使用量」だけを見てGC負荷を見落とすalloc_spaceが大きい場合、メモリリークではなくてもGCの実行頻度・停止時間が増え、CPUを間接的に消費している。両方のフラグを確認する習慣が必要
runtime.GC()を呼ばずにヒーププロファイルを取り、inuse_spaceの値がGCタイミングによってばらつくヒーププロファイルは「今この瞬間のスナップショット」であることを軽視するヒーププロファイルはCPUプロファイルのようなサンプリング集計ではなく、スナップショットである。直前にruntime.GC()を呼ぶかどうかで、直近に作られたがまだ回収されていないゴミが含まれるかどうかが変わり、結果が変動する
strings.BuilderにGrowを呼ばずに使い、「Builderに変えたのに大して速くならない」と誤解するBuilder自体が魔法のように最適化してくれると思い込むGrowを呼ばない場合、Builderの内部バッファもスライスと同様に容量不足のたびに再確保(コピー)が発生する。事前にサイズが見積もれるならGrowで確保しておくことが、append前のmake(..., 0, cap)と同じ理由で重要
ベンチマークの-benchmemの数字だけで満足し、-memprofileで行単位の内訳まで見ないサマリの数字が「わかった気」にさせる-benchmemは「1回あたり何バイト・何回確保したか」という集計値しかわからない。どのコード行がその確保を引き起こしているかを特定するには-memprofileとgo tool pprofのlistが必要

🚀 次のステップ

  • 発展: go tool pprof -http=:8080 -alloc_space mem.profを実行し、ブラウザ上でFlame Graphを確認してみましょう。buildReportSlowの呼び出し1回1回がどのように積み上がっているかを、テキストのtopよりも直感的に把握できます
  • 次回予告: ベンチマーク駆動最適化 — Day 040では、Day 038(CPU)・Day 039(メモリ)で学んだ2種類のプロファイリングを組み合わせ、Before/Afterの数値を定量的に比較しながら最適化を進める一連のワークフローを扱います

🎯 自己評価

自分の回答

気づき・メモ