Day 040 — pprofによるプロファイリング②(メモリ)(実践: スライス共有によるメモリリークのコードレビュー)

2026-09-06 🟡 上級者 / Phase 3 デバッグ・コードレビュー pprofによるプロファイリング②(メモリ)

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

Day 039では「文字列連結によるアロケーション過多(alloc_spaceがn²オーダーで膨らむが、inuse_spaceは小さい)」という、メモリリークではないが遅いパターンを扱いました。今回は逆に、inuse_spaceが実行時間とともに増え続ける、正真正銘のメモリリークをヒーププロファイルからどう読み解くかを扱います。

Goにはガベージコレクタがあるため、「メモリリーク」はC/C++のようにポインタの解放忘れでは起きません。Goでのメモリリークの典型パターンは、「本当はもう不要なデータへの参照を、どこかがまだ持ち続けている」ことです。GCは「参照されているかどうか」だけを見て回収を判断するため、開発者から見て「使い終わった」つもりのデータでも、コード上のどこかから辿れる限り回収されません。

📌
その中でも特に見落とされやすいのが、スライスの共有によるリークです。Goのスライスは「バッキング配列へのポインタ・長さ・容量」の3つを持つヘッダーに過ぎません。大きなバッキング配列から小さく再スライスしたものであっても、再スライスした側はバッキング配列全体への参照を保持し続けます。この小さなスライスをどこか(キャッシュ・グローバル変数など)に長期間保存すると、本来なら不要になった巨大な配列全体がGCから回収されないまま生き続けます。
📌
このタイプのリークは、go tool pprofの-inuse_spaceで見ると一見「奇妙」に見えます。「16バイトのセッションIDをキャッシュしているだけ」のつもりが、topコマンドの上位には16バイトとは全く関係のない、巨大なバッファを生成した関数が表示されるからです。この「参照元と参照先のギャップ」を読み解けるようになることが、本問のゴールです。

📝 問題

以下は、外部から届く1MBの生ログ行(raw)から、決まった位置に埋め込まれたセッションID(16バイト)を取り出し、リクエストIDをキーにしてキャッシュに保存するコードです。機能テストは通り、sessionCacheに保存されている論理的なデータ量は「セッションID 16バイト × リクエスト数」のはずですが、本番投入後、サーバーのメモリ使用量(RSS)がリクエスト数に比例してほぼ青天井に増え続け、最終的にOOM Killerに落とされる障害が発生しました。

package main

import (
	"fmt"
)

const rawSize = 1 << 20 // 1MB: 外部システムから届く生ログ行を想定

// sessionCache はリクエストIDをキーにセッションIDを保持するグローバルキャッシュ。
// サーバー起動中ずっと保持され続ける想定。
var sessionCache = make(map[int][]byte)

// generateRawLine は「外部から届いた1MBの生ログ行」を模擬する。
// 先頭100〜116バイト目にセッションIDが埋め込まれている。
func generateRawLine(id byte) []byte {
	raw := make([]byte, rawSize)
	for i := 100; i < 116; i++ {
		raw[i] = id
	}
	return raw
}

// extractSessionID は生ログ行からセッションID部分(16バイト)を取り出す。
func extractSessionID(raw []byte) []byte {
	return raw[100:116]
}

// handleRequest は1件のリクエストを処理し、セッションIDをキャッシュに保存する。
func handleRequest(reqID int) {
	raw := generateRawLine(byte(reqID))
	sessionCache[reqID] = extractSessionID(raw)
}

func main() {
	for i := 0; i < 2000; i++ {
		handleRequest(i)
	}
	fmt.Println("キャッシュ件数:", len(sessionCache))
	fmt.Println("論理的なデータ量の見積もり:", len(sessionCache)*16, "byte")
}

要件

  1. このプログラムをruntime.ReadMemStatsで計測した場合、HeapAllocは「キャッシュ件数 × 16バイト」に近い値になるか、それともリクエスト数 × 1MBに近い値になるか、スライスのヘッダー構造(ポインタ・長さ・容量)の観点から理由とともに説明する
  2. go tool pprof -top -inuse_spaceでこのプログラムのヒーププロファイルを取った場合、上位に表示されるはずの関数名を、コードの構造から予測する(extractSessionIDではなく別の関数が出るはずである。それはなぜか)
  3. extractSessionIDを、巨大なrawのバッキング配列への参照を断ち切る形に書き直す(go run main.goでそのまま動く完全なコードを提示すること)
  4. 修正前後でruntime.ReadMemStatsのHeapAllocがどう変化すると予測されるか、実際に計測して比較するための完全なmain関数(runtime.GC()を挟んだ前後比較)を示す
  5. string(raw[100:116])のようにバイトスライスを文字列に変換してから保存する場合、今回のリークは自動的に解消されるか。Goの「[]byteからstringへの変換」の仕様に基づいて説明する

🔍 ヒント(段階的開示)

ヒント1 — 方向性

extractSessionIDが返す[]byteは、makeで新しく確保されたものではありません。それが何を指しているスライスヘッダーなのか、raw[100:116]という式が何を表しているかを起点に考えてください。

ヒント2 — アプローチ

Goのスライスは「ポインタ・長さ・容量」の3つ組です。raw[100:116]は新しい配列を作らず、rawと同じバッキング配列を指す新しいヘッダー(ポインタはrawの先頭から100バイト進んだ位置、長さは16、容量はrawSize-100)を作るだけです。このヘッダーをsessionCacheに2000個保存すると、2000個の1MB配列すべてがGCの回収対象から外れ続けます。「巨大な配列を生成した関数」がヒーププロファイルのinuse_spaceで誰の名義になるかを考えてください(Goのプロファイラはアロケーションが発生した場所を記録します。参照している場所ではありません)。

ヒント3 — コード骨格

リークを断ち切るには、必要な16バイトだけを新しいバッキング配列にコピーして返す必要があります。

func extractSessionID(raw []byte) []byte {
	id := make([]byte, 16)
	copy(id, raw[100:116])
	return id
}

このように書き換えると、sessionCacheに保存される[]byteは独立した16バイトの配列を指すようになり、元の1MBのrawはどこからも参照されなくなった時点でGC対象になります。検証にはruntime.ReadMemStatsのHeapAllocを修正前後で比較するのが手軽です。

✅ 模範解答

package main

import (
	"fmt"
	"runtime"
)

const rawSize = 1 << 20 // 1MB: 外部システムから届く生ログ行を想定

// generateRawLine は「外部から届いた1MBの生ログ行」を模擬する。
// 先頭100〜116バイト目にセッションIDが埋め込まれている。
func generateRawLine(id byte) []byte {
	raw := make([]byte, rawSize)
	for i := 100; i < 116; i++ {
		raw[i] = id
	}
	return raw
}

// extractSessionIDLeaky はバグ版。raw と同じバッキング配列を指すヘッダーを返すだけなので、
// 戻り値がどこかに保存され続ける限り、raw の1MB配列全体がGCから回収されない。
func extractSessionIDLeaky(raw []byte) []byte {
	return raw[100:116]
}

// extractSessionIDFixed は修正版。必要な16バイトだけを新しい配列にコピーして返す。
// raw への参照を一切保持しないため、raw はこの関数から抜けた時点でGC対象になり得る。
func extractSessionIDFixed(raw []byte) []byte {
	id := make([]byte, 16)
	copy(id, raw[100:116])
	return id
}

func heapAllocMB() float64 {
	runtime.GC() // 直近のゴミを回収してから測定し、inuse相当の値に近づける
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return float64(m.HeapAlloc) / 1024 / 1024
}

func runScenario(name string, extract func([]byte) []byte, n int) {
	cache := make(map[int][]byte)
	before := heapAllocMB()
	for i := 0; i < n; i++ {
		raw := generateRawLine(byte(i))
		cache[i] = extract(raw)
	}
	after := heapAllocMB()
	fmt.Printf("[%s] リクエスト数=%d, 開始時HeapAlloc=%.2fMB, 終了時HeapAlloc=%.2fMB (差分=%.2fMB)\n",
		name, n, before, after, after-before)
	fmt.Printf("[%s] 論理的なデータ量の見積もり: %d byte (%.4fMB)\n",
		name, len(cache)*16, float64(len(cache)*16)/1024/1024)
	_ = cache // cacheがGC対象にならないよう関数末尾まで生存させる
}

func main() {
	const n = 2000

	fmt.Println("=== バグ版: extractSessionIDLeaky ===")
	runScenario("leaky", extractSessionIDLeaky, n)

	runtime.GC()
	fmt.Println()

	fmt.Println("=== 修正版: extractSessionIDFixed ===")
	runScenario("fixed", extractSessionIDFixed, n)
}

実行結果の傾向(環境依存だが、差は数百倍のオーダーになるはずである):

=== バグ版: extractSessionIDLeaky ===
[leaky] リクエスト数=2000, 開始時HeapAlloc=0.35MB, 終了時HeapAlloc=1953.61MB (差分=1953.26MB)
[leaky] 論理的なデータ量の見積もり: 32000 byte (0.0305MB)

=== 修正版: extractSessionIDFixed ===
[fixed] リクエスト数=2000, 開始時HeapAlloc=0.38MB, 終了時HeapAlloc=0.42MB (差分=0.04MB)
[fixed] 論理的なデータ量の見積もり: 32000 byte (0.0305MB)

🪜 Step-by-Step 解説

1
HeapAllocがどちらに近づくかを予測する
extractSessionIDLeakyが返す[]byteのヘッダーは、rawの先頭から100バイト進んだ位置を指すポインタ・長さ16・容量rawSize-100を持ちます。ポインタが指す先はrawと全く同じバッキング配列です。sessionCache[reqID] = ...でこのヘッダーがマップに保存されると、マップから辿れる限りその配列は「生きている」とGCに判定されます。2000回のリクエストで2000個の1MB配列が生き続けるため、HeapAllocはキャッシュの論理サイズ(32,000バイト)ではなく、リクエスト数×1MB(約2GB)に近づきます。
2
ヒーププロファイルのtopに何が出るかを予測する
go tool pprofは、あるオブジェクトがどの呼び出しスタックで確保(allocate)されたかを記録します。「誰が今それを参照しているか」は記録しません。今回、実際にヒープにバイト列を確保しているのはextractSessionIDLeakyではなくgenerateRawLineのmake([]byte, rawSize)です。extractSessionIDLeakyは既存のバッキング配列を指すヘッダーを作っているだけで、新たなアロケーションを一切行っていません。したがって-inuse_spaceのtopでは、generateRawLineが支配的な確保元として表示されます。「小さなセッションIDしか保存していないつもりなのに、topには見覚えのある巨大バッファ生成関数が出てくる」という一見矛盾した結果こそが、スライス共有リークのシグネチャです。
3
リークを断ち切る修正
extractSessionIDFixedのように、make([]byte, 16)で新しい独立したバッキング配列を確保し、必要な16バイトだけをcopyでコピーします。これにより、返されたスライスはrawとは無関係な配列を指すようになります。raw(および1MBの配列)はhandleRequestのスコープを抜けた時点でどこからも参照されなくなり、次回のGCで回収可能になります。
4
前後比較の計測
runtime.GC()を挟んでからruntime.ReadMemStatsでHeapAllocを読むことで、「直前に確保したがまだ回収されていないゴミ」を除いた、より正確な生存メモリ量を比較できます。模範解答ではrunScenario関数として抽象化し、バグ版・修正版の両方を同じ手順で計測できるようにしています。
5
string(raw[100:116])に変換すればリークは解消するか
解消します。Goの仕様では、[]byteからstringへの変換は変換元のバイト列を新しい文字列用のメモリ領域にコピーします(文字列は不変でなければならないため、可変な[]byteのバッキング配列をそのまま共有することはできません)。ただし、変換結果をすぐにマップのキーとして使うだけで別変数に保持しない場合など、一部のケースではコンパイラの最適化によりコピーが省略されることがある点には注意が必要です。今回のように変換結果を長期間保持する用途では、通常通りコピーが発生し、リークは解消されると考えてよいです。

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

📌
「窓(ビュー)」としてのスライス、判断は開発者に委ねる: Goがスライスの再スライス(raw[100:116])をコピーなしの軽量な操作として設計しているのは、パフォーマンスのためです。もし再スライスのたびに新しい配列へコピーが発生するなら、for i := range b { process(b[i:i+1]) }のような日常的なコードが著しく遅くなってしまいます。Goは「スライスはバッキング配列への窓(ビュー)である」という一貫したメンタルモデルを採用し、コピーが必要かどうかの判断を開発者に委ねています。この設計は「デフォルトで速い(コピーしない)が、意図せず巨大な配列を参照し続けてしまう罠が存在する」というトレードオフを伴います。
📌
言語で禁止せず、実測ツールを標準で提供する: Goはこの罠を防ぐために言語レベルで何かを強制するのではなく、go tool pprofという実測ツールを標準で提供することで、「疑わしければ実際に計測して確認する」という文化に倒しています。これはDay 037で学んだエスケープ解析(静的解析)だけでは検出しきれない、実行時のデータフローに依存する問題だからです。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaString.substring()JDK 6以前のString.substring()はGoのスライスと全く同じ「元のchar[]を共有する」実装で、本問と全く同じ種類のメモリリークが多数報告された。JDK 7以降はsubstring()が常にコピーを行う実装に変更され、この罠は解消された。GoはJava的な「常にコピー」ではなく「常に共有」を選び、リーク回避の責任を開発者に残している
Pythonスライス(b[100:116])CPythonのbytes・listのスライスは常に新しいオブジェクトへのコピーを作る。そのためGoのようなバッキング配列共有によるリークはPythonでは基本的に起きない(代わりに、スライスのたびにコピーコストがかかるという逆のトレードオフがある)。memoryviewを使うとGoのスライスに近い「コピーなしのビュー」が得られ、同種の参照保持問題が起こり得る
Rustスライス(&[u8])とライフタイムRustもスライスはコピーなしのビューだが、借用チェッカーが「参照元より長生きする参照」をコンパイルエラーにする。今回のように「短命なrawから取り出した参照を、長命なsessionCacheに保存する」というコードは、Rustではそもそもコンパイルが通らない。Goは実行時の実測(pprof)で気づく設計だが、Rustはコンパイル時に機械的に防ぐ設計という対照的なアプローチになっている

🏆 実務での使いどころ

  • APIゲートウェイ・プロキシでのヘッダー/トークン抽出: リクエストボディやヘッダーの巨大なバイト列から、一部(トークン・IDなど)だけを抜き出してログやキャッシュに保存する処理は、本問と全く同じパターンでリークしやすい典型例
  • ログ収集・パーサーの実装: 1行が長いログ(JSON全体など)から、特定のフィールドだけを抽出して後段の集計処理に渡す場合、抽出結果を[]byteのまま長期保持するコードは要注意
  • メモリリーク調査の初手としてのヒーププロファイル比較: 「RSSが右肩上がりに増え続ける」障害調査では、時間を空けて2回inuse_spaceのヒーププロファイルを取得し、topの上位関数を比較する。増加分のtopに見覚えのない・意図しない巨大アロケーション関数が出てきたら、その戻り値がどこかに長期保存されていないかを疑う
  • bytes.Clone(Go 1.20+)の活用: 標準ライブラリにbytes.Cloneが追加されたのは、まさに本問のような「独立したコピーが欲しい」というニーズが頻出するため。make+copyを毎回書く代わりにbytes.Clone(raw[100:116])と書ける

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
キャッシュに保存しているデータの型([]byte、サイズ16)を見て、メモリ使用量も16バイト相当だと思い込むスライスの「表面上の長さ・容量」と「実際に参照しているバッキング配列のサイズ」が別物であることを意識していないスライスヘッダーのlen/capは、そのスライス経由でアクセスできる範囲を示すだけで、参照しているバッキング配列全体のサイズとは無関係。GCの回収可否はバッキング配列全体が「どこかから参照されているか」で決まる
go tool pprofのtopに出てきた関数(generateRawLine)を見て、「この関数がバグの原因だ」と誤解し、生成処理自体を疑ってしまうプロファイラは「確保した場所」を示すのであり、「なぜ解放されないか」までは教えてくれないことを知らないtopはあくまで手がかり。「なぜこの関数の確保物がいつまでも生きているのか」は、その戻り値がどこに保存され続けているかを自分でコードから追う必要がある。listコマンドや、保存先(今回ならsessionCache)のコード上の使われ方を確認する
runtime.GC()を呼べば、リークしているデータも強制的に回収されると誤解するGCは「メモリを空ける魔法」だと思い込んでいるGCは到達不能になったオブジェクトだけを回収する。sessionCacheのようにグローバル変数からまだ参照されているデータは、runtime.GC()を何回呼んでも回収されない。回収させるには、参照そのもの(今回ならマップのエントリ、あるいはより根本的にはリークの原因となる参照構造)を断ち切る必要がある

🚀 次のステップ

  • 発展: sessionCacheにTTL(有効期限)付きのエビクション機構を追加し、「そもそも無期限に保持し続けるキャッシュ設計」自体の妥当性も見直してみましょう。今回はスライス共有が主因でしたが、無制限キャッシュ自体も別種のメモリ増大要因になり得ます
  • 次回予告: ベンチマーク駆動最適化 — Day 038(CPU)・Day 039(アロケーション過多)・Day 040(スライス共有リーク)で学んだ3種類のプロファイリング観点を組み合わせ、Before/Afterを定量的に比較しながら最適化を進める一連のワークフローを扱います

🎯 自己評価

自分の回答

気づき・メモ