Day 036 — GC・エスケープ解析の理解

2026-09-02 🟡 上級者 / Phase 3 概念理解 GC・エスケープ解析の理解

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

Day 035ではhappens-before関係という「可視性」の軸を学びました。Day 036ではGoランタイムのもう1つの重要な軸——ある変数がスタックに置かれるかヒープに置かれるか——を扱います。

Goにはnewや&T{}のような明示的な「ヒープ確保」構文がなく、Java/Pythonのように「オブジェクトは常にヒープ」でもありません。代わりにGoコンパイラはエスケープ解析(escape analysis)という静的解析を行い、「その変数への参照が、それを生成した関数のスコープを超えて生き残る可能性があるか」をコンパイル時に判定します。生き残る可能性がなければスタックに、可能性があれば(関数の戻り値として返される、goroutineに渡される、インターフェースに格納される、ポインタが外部に漏れる、など)ヒープに配置されます。これが「エスケープする(escape to heap)」という言葉の意味です。

なぜこの区別が重要なのでしょうか。スタック割当はGCの管理対象外であり、関数のリターンとともに自動的に解放されます。一方ヒープ割当はGC(ガベージコレクタ)の管理対象となり、生存確認(マーキング)・回収(スイープ)のコストがかかります。Goの並行GC(concurrent mark-and-sweep)は優秀ですが、それでも「ヒープに何も置かない」ことに勝る最適化はありません。ホットパス(高頻度に呼ばれる関数)で不要なヒープ割当が起きていると、GC負荷が増大し、STW(Stop-The-World、GCが全goroutineを一時停止させる区間)の頻度・レイテンシへの間接的な影響が無視できなくなります。

📌
エスケープ解析はプログラマが明示的に制御する機能ではなく、コンパイラが決定を下す最適化です。プログラマにできるのは「エスケープしにくいコードを書く」ことと、go build -gcflags="-m"でコンパイラの判断結果を観察することだけです。

「ポインタを返せばヒープに乗る」という単純な理解では不十分で、値レシーバでもクロージャに捕捉されればエスケープしますし、逆にポインタを返しても関数がinline化されれば呼び出し元のスタックで完結することもあります。Day 036では、この「コンパイラの判断ルール」を正確に理解し、-gcflags="-m"の出力を読めるようになることを目標にします。

📝 問題

以下の6つの関数について、それぞれ後述の要件に答えてください。

package main

import "fmt"

// (A)
func makeIntLocal() int {
	x := 42
	return x
}

// (B)
func makeIntPointer() *int {
	x := 42
	return &x
}

// (C)
func makeSliceSmall() []int {
	s := make([]int, 3)
	s[0] = 1
	return s
}

// (D)
type Counter struct {
	n int
}

func (c *Counter) Inc() {
	c.n++
}

func useCounterLocal() int {
	c := Counter{}
	c.Inc()
	c.Inc()
	return c.n
}

// (E)
func startWorker() {
	x := 100
	go func() {
		fmt.Println(x)
	}()
}

// (F)
type Stringer interface {
	String() string
}

type MyInt int

func (m MyInt) String() string {
	return fmt.Sprintf("%d", int(m))
}

func useAsInterface() Stringer {
	var m MyInt = 7
	return m
}

要件

  1. (A)〜(F) それぞれについて、変数(x, s, c, mなど主要なローカル変数)がスタックに置かれるか、ヒープにエスケープするかを判定し、その理由を説明する
  2. (B)は「ポインタを返しているのでヒープに確保される」と考えがちだが、それが常に正しいとは限らない。この判断がどのような条件に依存するかを、Goのエスケープ解析の原則から説明する
  3. (F)がエスケープする理由を、「インターフェース型への代入」という観点から説明する。値型(MyIntという小さい整数型)であってもエスケープするのはなぜか
  4. go build -gcflags="-m"(または-gcflags="-m -m"でより詳細)を使って(A)〜(F)を実際に検証する手順を示し、期待される出力例を示す
  5. ホットパスで不要なヒープ割当を避けるための実践的なコーディング指針を3つ挙げる

🔍 ヒント(段階的開示)

ヒント1 — 方向性

「ポインタを返す=ヒープ」「値を返す=スタック」という単純化されたルールは、多くの場合当てはまりますがエスケープ解析の本質ではありません。本質は「その変数への参照が、関数のスタックフレームが破棄された後も生き続ける必要があるか」という生存期間の解析です。ポインタであってもコンパイラがinline化やライフタイム解析によって「呼び出し元のスタックで完結する」と判断できれば、ヒープに逃げない場合があります。

ヒント2 — アプローチ

主なエスケープの典型パターンです。

  • 関数からポインタ・スライス・マップ・チャネルなど参照を返すが、呼び出し元のコンパイラの解析がそれを追跡できない場合(別コンパイル単位、動的ディスパッチなど)
  • goroutine(go文)やクロージャに変数のアドレスを渡す場合——goroutineは関数のリターンより後まで生きる可能性があるため、参照先は必ずヒープに置かれる
  • インターフェース型に値を代入する場合——インターフェースは内部的に「型情報+データへのポインタ」の組で表現されるため、格納される値がヒープに逃げることが多い(ただし一部の小さい値やコンパイラの最適化で回避されるケースもある)
  • 値のサイズがコンパイル時に確定しない、または非常に大きい場合(make([]int, n)でnが動的な値)
  • サイズが大きすぎてスタックに収まらない場合(Goのgoroutineスタックは可変長だが、それでも一定の閾値を超える巨大な値は最初からヒープに置かれる)

-gcflags="-m"の出力例:

./main.go:8:2: moved to heap: x
./main.go:14:9: &x escapes to heap
ヒント3 — コード骨格

検証手順の骨格:

mkdir day036 && cd day036
go mod init example.com/day036
# main.go に問題のコードを保存

go build -gcflags="-m" ./... 2>&1 | grep -E "escapes|moved to heap|inlin"

(A)のような「戻り値として値そのもの(intのコピー)を返す」ケースは、return xが値のコピーを返しているだけなのでスタックに留まる可能性が高いです。(B)はxのアドレスを返しているため、呼び出し元がそのポインタをいつまで使うかコンパイラが保証できず、多くの場合エスケープします。実際に-gcflags="-m"で両者を比較し、出力の違いを確認してください。

✅ 模範解答

1. 判定表

パターン変数スタック/ヒープ理由
(A) makeIntLocalxスタックreturn xはxの値のコピーを返しているだけで、x自体のアドレスはどこにも渡っていない。関数の戻り値はスタック経由(またはレジスタ経由)でコピーされ、xという変数そのものの生存期間は関数呼び出しの範囲に収まる
(B) makeIntPointerxヒープにエスケープreturn &xでxのアドレスを返しているため、呼び出し元は関数のスタックフレームが破棄された後もこのポインタを使い続ける可能性がある。コンパイラは「呼び出し元がいつまでこのポインタを保持するか」を静的に追跡できないため、安全側に倒してxをヒープに配置する
(C) makeSliceSmallsの裏配列ケースによる(多くはエスケープ)make([]int, 3)はサイズが定数(3)でコンパイル時に確定するため、sをそのまま返さず関数内で使い切るだけならスタックに置かれる可能性がある。しかしこの関数のようにスライスヘッダーを戻り値として返す場合、裏配列への参照が関数外に渡るため、通常はヒープにエスケープする
(D) useCounterLocalcスタックc.Inc()は(&c).Inc()と等価にコンパイルされ、cのアドレスがメソッド呼び出しのレシーバとして使われるが、そのアドレスはuseCounterLocal関数内でしか使われず、関数外に一切渡らない。コンパイラはこれを検出でき、cはスタックに留まる(ポインタレシーバ=ヒープという誤解の典型的な反例)
(E) startWorkerxヒープにエスケープgo func() { fmt.Println(x) }()はクロージャがxをキャプチャしており、そのgoroutineはstartWorker関数のリターンより後まで実行され続ける可能性がある。関数のスタックフレームが破棄された後もgoroutineがxを参照するため、xは必ずヒープに置かれる
(F) useAsInterfacemヒープにエスケープreturn mの戻り値の型がStringerインターフェースであり、MyIntという具象型の値を格納する際、インターフェース値は内部的に「型記述子+データへのポインタ」のペアとして表現される。動的ディスパッチのためにデータ部分がポインタ経由でアクセスされる必要があり、多くの場合ヒープに逃げる

2. (B)が「常にヒープ」とは限らない理由

エスケープ解析の判断基準は「ポインタか値か」ではなく、「参照がコンパイラの追跡可能な範囲を超えて生存するか」です。

  • return &xの呼び出し元が同じコンパイル単位内にあり、コンパイラがそのポインタの使われ方全体を解析できる場合、インライン化(inlining)によってmakeIntPointerの呼び出し自体が呼び出し元のコードに展開されることがある。インライン化された結果、&xが事実上「呼び出し元の中のローカル変数のアドレス」に等価変換されれば、外部への漏洩が起きず、スタックに留まる可能性がある
  • 逆に、関数が大きすぎてインライン化されない、呼び出し元が動的ディスパッチ経由(インターフェース越しの呼び出しなど)でこの関数を呼ぶ、あるいは戻り値のポインタが構造体のフィールドやグローバル変数に代入される場合は、確実にヒープへエスケープする

つまり「ポインタを返す関数は必ずヒープ」という理解は、インライン化が起きない典型的なケースでは正しいことが多いが、コンパイラの最適化能力に依存する経験則にすぎません。正確な判断は-gcflags="-m"の出力を見るしかありません。

3. (F)がエスケープする理由(インターフェース代入の観点)

Goのインターフェース値は、内部的に2ワード構造(itabまたは型情報へのポインタ+データへのポインタ)で表現されます。MyIntのような小さい値型であっても、インターフェースに代入される際には動的な型情報とともに扱えるようにデータ部分がポインタ経由で参照可能な形に変換される必要があります。

これは、インターフェース値がその後どのように使われるか(例えばStringer型のスライスに格納される、別の関数に渡される、interface{}として長期間保持される等)をコンパイラが静的に追跡しきれないためです。値型であっても、「動的型情報を伴う抽象化」という性質上、インターフェースへの代入はエスケープの典型的なトリガーになります。

ただし、Go 1.9以降で導入されたmid-stack inliningや、特定のコンパイラバージョンでの最適化(小さい定数値のインターフェース変換の特別扱いなど)により、単純なケースでは回避されることもあります。これも「必ずエスケープする」という断定ではなく、-gcflags="-m"で実際に確認すべき事項です。

4. -gcflags="-m"による検証手順

ファイル構成: day036/go.mod + day036/main.go

// go.mod
module example.com/day036

go 1.22

main.goは問題文の(A)〜(F)のコード一式をそのまま配置します。検証コマンドは以下の通りです。

go build -gcflags="-m" ./... 2>&1

期待される出力例(バージョンにより多少の差異あり):

./main.go:7:6: can inline makeIntLocal
./main.go:8:2: x escapes to heap        # ※実際にはinline化状況により変化する
./main.go:13:6: can inline makeIntPointer
./main.go:15:9: &x escapes to heap
./main.go:14:2: moved to heap: x
./main.go:20:6: can inline makeSliceSmall
./main.go:21:11: make([]int, 3) escapes to heap
./main.go:35:6: can inline useCounterLocal
./main.go:36:2: c does not escape       # ← ポインタレシーバでもエスケープしない好例
./main.go:44:6: can inline startWorker
./main.go:46:2: moved to heap: x        # ← goroutineキャプチャによるエスケープ
./main.go:47:15: x escapes to heap
./main.go:63:6: can inline useAsInterface
./main.go:65:9: m escapes to heap       # ← インターフェース代入によるエスケープ

-mを-m -mにすると、なぜその判断に至ったかのより詳細な理由が追加で出力されます。確認のポイント: X does not escapeという行が出れば、コンパイラがそのケースをスタック配置と判断したことを意味します。(D)のc does not escapeが最も直感に反しやすく、重要な学びになります。

5. ホットパスで不要なヒープ割当を避ける実践的指針

  1. 不要なポインタ返却・インターフェース経由の抽象化を避ける: ホットパスの内部処理では、汎用性より局所性を優先し、具象型の値渡しで完結できないか検討する。fmt.Sprintfやinterface{}を挟む処理はエスケープの典型的な発生源になるため、頻度の高い箇所では避ける
  2. goroutine・クロージャへのキャプチャを最小限にする: go func(){ ... }()で外部変数を大量にキャプチャすると、それらすべてがヒープにエスケープする。必要な値だけを引数として明示的に渡す(go func(x int){ ... }(x))ことで、キャプチャ対象を絞り込める
  3. -gcflags="-m"とベンチマーク(go test -bench . -benchmem)を組み合わせて計測してから最適化する: エスケープ解析の結果は推測ではなく実際に確認する。-benchmemのallocs/opが高い箇所を特定し、そこだけピンポイントで-gcflags="-m"を当てて原因のエスケープ箇所を特定するのが効率的。推測だけで「これはヒープに乗っているはず」と決めつけて過剰にコードを歪めるのは避ける(Day 037以降のpprofによる計測駆動最適化にもつながる考え方)

🪜 Step-by-Step 解説

1
エスケープ解析は「参照の生存期間」を追跡する解析だと理解する
コンパイラは各変数について「この変数へのポインタが、関数のスタックフレームが破棄された後も使われうるか」を静的に解析します。使われうると判断されれば、その変数はヒープに「エスケープ」します。「ポインタか値か」は判断材料の1つにすぎず、本質は生存期間です。
2
典型的なエスケープトリガーを覚える
関数外にポインタ・スライス・マップを返す(追跡できない場合)、goroutine・クロージャに変数のアドレスをキャプチャさせる、インターフェース型への代入、サイズがコンパイル時に確定しない・大きすぎる値、といったパターンを機械的にチェックする習慣が、コードレビューでの「ここはヒープに乗りそうだ」という勘所を養います。
3
「ポインタレシーバ=ヒープ」という誤解を(D)で払拭する
(D)のc does not escapeは非常に重要な学びです。メソッドのレシーバがポインタ型でも、そのポインタが関数(あるいはメソッドチェーン)の外に一切渡らなければ、コンパイラは安全にスタックに配置できます。「ポインタレシーバを使うと必ずヒープに逃げる」という思い込みは誤りであり、実際にコンパイラの出力を見て確認する姿勢が重要です。
4
-gcflags="-m"を計測のツールとして使いこなす
-mは1回でも情報を出しますが、-m -mにするとより詳細な理由が出ます。日常的なパフォーマンスチューニングでは、まず-benchmemで「アロケーションが多い関数」を特定し、その関数に絞って-gcflags="-m"を当てる、という順序で進めるのが効率的です。

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

📌
プログラマがメモリ配置を気にせず自然にコードを書けることと実行効率の両立: Goがエスケープ解析を「言語機能として明示的に選択させる」のではなく「コンパイラが自動判断する」設計にしたのは、プログラマがメモリ配置を気にせず自然にコードを書けることと、実行時の効率を両立させるという設計思想の表れです。C言語ではmalloc/スタック変数を明示的に使い分ける必要があり、Rustでは所有権システムによってコンパイル時にメモリ管理の責任範囲がプログラマに強制されます。Goはその中間——「ヒープかスタックかはコンパイラが決める」という抽象化を提供しつつ、-gcflags="-m"という「逃げ道」で内部動作を観察可能にする——という均衡点を選んでいます。
📌
「デフォルトはシンプルに、必要な人だけが内部を覗ける」という一貫した思想: これはGoの一貫した設計哲学の延長線上にあります。並行処理のプリミティブ(channel)がロックの詳細を隠しながらも-raceで検証可能にしているのと同じ構造が、メモリ配置(エスケープ解析)と-gcflags="-m"の関係にも見て取れます。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaJITコンパイラによるスカラー置換(Scalar Replacement)とエスケープ解析Javaのオブジェクトはデフォルトですべてヒープに置かれるが、HotSpot VMのJITがエスケープしないと判断したオブジェクトをスタック上のフィールドに分解して配置する最適化を行う。Goは逆に「デフォルトはスタック、必要な場合のみヒープ」という前提が言語仕様レベルで組み込まれている点が対照的
C/C++malloc/new vs スタック変数の明示的な使い分けC/C++ではプログラマが自分でヒープ確保(malloc/new)かスタック確保(ローカル変数)かを選択する。Goはこの選択をコンパイラに委ね、プログラマは「ポインタを外に漏らすかどうか」というコードの書き方でしか間接的に制御できない
Rust所有権システムとBox<T>Rustはデフォルトでスタック確保であり、ヒープに置きたい場合は明示的にBox::new()等を使う。所有権・借用チェッカーがコンパイル時に「参照がスコープを超えて生存しないこと」を保証するため、Goのエスケープ解析のような「安全側に倒してヒープに逃がす」判断ではなく、コンパイルエラーとして早期に問題を検出する設計になっている

🏆 実務での使いどころ

  • ホットパスのGC負荷削減: 高頻度に呼ばれるAPIハンドラやループ内で-benchmemのallocs/opが高い場合、-gcflags="-m"で該当箇所のエスケープ原因を特定し、インターフェース経由の抽象化やクロージャキャプチャを見直す
  • sync.Poolの適用判断: エスケープが避けられずヒープ割当が頻発する構造体(一時的なバッファなど)に対して、sync.Poolによるオブジェクト再利用を検討する際の前提知識になる(Phase 2のDay 015、Phase 4での応用につながる)
  • コードレビューでの「隠れたアロケーション」の指摘: interface{}を多用するAPI設計や、ログ出力で毎回fmt.Sprintfを呼ぶコードなど、一見軽量に見えて実はエスケープを誘発しているパターンをレビューで指摘できるようになる
  • ライブラリ設計時のAPI選択: 値レシーバ・ポインタレシーバのどちらを使うか、戻り値をポインタにするか値にするかという設計判断において、「呼び出し側での使われ方次第でエスケープの有無が変わる」ことを踏まえた慎重な設計ができる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「ポインタを返す関数は必ずヒープに確保される」と決めつける表面的な「ポインタ=ヒープ」という単純化されたルールが広まっている実際はインライン化やコンパイラの追跡可能性に依存する。(D)のように、ポインタレシーバでも外部に一切漏れなければスタックに留まる。-gcflags="-m"で確認するまでは断定できない
「値型を使えばエスケープしない」と考える値渡しはコピーであり安全というイメージ(F)のように、値型でもインターフェースに代入されればエスケープする。エスケープの有無は型のカテゴリではなく「参照の生存期間」で決まる
エスケープ解析の結果は実行のたびに変わると思い込む「実行時の動的な判断」という言葉のイメージに引きずられるエスケープ解析はコンパイル時に静的に決定される。同じソースコード・同じコンパイラバージョンであれば、結果は常に同じ(実行時の分岐やデータ量には依存しない)
GC負荷を気にして全てのローカル変数をsync.Poolで管理しようとするエスケープ=悪、という短絡的な最適化志向まず計測(-benchmem)してアロケーションが実際にボトルネックかを確認するのが先。エスケープを避けること自体が目的化すると、コードの可読性を犠牲にした割に効果が薄い「早すぎる最適化」になりがち

🚀 次のステップ

  • 発展: 実際にday036のコードをgo build -gcflags="-m -m"でビルドし、(A)〜(F)の詳細な理由の文言を読み比べてみましょう。特に(D)のc does not escapeがどのように導かれるかの理由を確認します
  • 次回予告: pprofによるプロファイリング①(CPU) — Day 037では、Day 036で学んだ「静的な予測」から一歩進めて、実際に動いているプログラムのCPU使用状況をpprofで計測し、ホットパスを実測データから特定する方法を学びます

🎯 自己評価

自分の回答

気づき・メモ