「値渡し=遅い、ポインタ渡し=速い」という巨大な錯覚
CやC++、あるいは参照渡しが標準のオブジェクト指向言語からGoへ入門したエンジニアが、ほぼ100%の確率で引っかかる「定番の罠」があります。それが「構造体はポインタで渡すのが定石」という思い込みです。
「構造体全体をスタックにコピーするのは無駄だ。ポインタ(8バイトのアドレス)を渡せばコピーコストを最小化できるのだから、常にポインタを使うべきだ」
コードレビューでも「ここの引数、コピーを減らすために *User に変更しませんか?」といった的外れな指摘が頻繁に見受けられます。しかし断言しますが、その「良かれと思って書いたポインタ」こそが、本番環境でCPUスパイクとGCストップを招く元凶です。
Goにおいてメモリ効率と実行速度を支配するのは、「コピーされるバイト数」ではなく「変数がスタックに置かれるか、ヒープに置かれるか」です。そしてポインタの多用は、Goコンパイラに対して「この変数をヒープにエスケープさせろ」と指示しているのと同義なのです。
スタック割り当てとヒープ割り当ての決定的格差
なぜスタックにメモリを確保することがこれほどまでに優位なのか。両者の内部動作を比較すれば、その格差は一目瞭然です。
スタック割り当て: コストほぼ「0」の超高速領域
スタック領域のメモリ割り当ては、CPUのスタックポインタレジスタ(SP)を減算してフレームを確保するだけです。わずか1〜2CPUクロックで完了し、OSのシステムコールもメモリアロケータの探索も一切介しません。
さらに重要なのは「解放のコスト」です。関数がリターンした瞬間にスタックポインタを元に戻すだけで、そのスコープで使用したすべてのメモリが一瞬で解放されます。ガベージコレクタ(GC)はスタック上の変数に一切関与しません。 つまり、スタックに乗っている限り、メモリ解放コストは文字通り完全にゼロです。
ヒープ割り当て: ランタイムとGCに支払う莫大な負債
一方で、ヒープ領域にメモリを確保する場合はまったく別の世界が広がっています。
- アロケーションのオーバーヘッド: Goランタイムのスレッドローカルキャッシュ(
mcache)、中央フリーリスト(mcentral)、ヒープアリーナ(mheap)を順に辿って適切なサイズの空きブロックを探す必要があります。 - GCの探索対象の増大: ヒープ上に確保されたすべてのオブジェクトは、Goの並行トリカラー・マーク&スイープGCの巡回対象(ルートセットからの走査ツリー)に登録されます。
- CPUキャッシュの破壊: ヒープ上のオブジェクトは物理メモリ上に不連続に散らばるため、CPUのL1/L2キャッシュラインに乗りづらく、ポインタを辿るたびにメモリアクセス遅延が発生します。
エスケープ解析(Escape Analysis)の冷酷なメカニズム
Goコンパイラは、コードをコンパイルする際にエスケープ解析(Escape Analysis)という静的解析を実行します。「ある変数のライフタイムが、それが宣言された関数の実行期間(スタックフレーム)を超える可能性があるかどうか」を判定するためです。
もし変数が関数のスコープ外から参照される可能性があると判断されると、その変数はスタックではなくヒープに確保されます。これが「エスケープ(Escape to Heap)」です。
値返し vs ポインタ返し
次の2つのコンストラクタ関数を比較してみましょう。
package main
type User struct {
ID int64
Name string
Email string
}
// パターンA: 値を返す(Value Return)
func NewUserValue(id int64, name, email string) User {
return User{
ID: id,
Name: name,
Email: email,
}
}
// パターンB: ポインタを返す(Pointer Return)
func NewUserPointer(id int64, name, email string) *User {
u := User{
ID: id,
Name: name,
Email: email,
}
return &u // ローカル変数のアドレスを返している
}
この2つの挙動の違いを、Goのコンパイラフラグ -gcflags="-m" を使って可視化してみます。
$ go build -gcflags="-m" main.go
./main.go:20:9: &u escapes to heap
./main.go:16:2: moved to heap: u
コンパイラは即座に警告しています。NewUserPointer 内で宣言された u は、関数が終了した後も呼び出し元で使われ続けるため、スタックに置くことができません。そのため、コンパイラは強制的に u をヒープ領域へと退避(moved to heap)させます。
一方、NewUserValue のほうは一切エスケープしません。値そのものが呼び出し元のスタックフレームに直接コピーされるため、ヒープ割り当てはゼロ(0 allocs/op)のままです。
CPUキャッシュ局所性の破壊: ポインタチェイス(Pointer Chasing)
ポインタ多用がもたらす最大の被害は、実はGCの負荷だけではありません。現代のハードウェアアーキテクチャにおいて致命傷となるのが、CPUキャッシュ局所性の喪失です。
現代のCPUにおいて、L1キャッシュからのデータ読み込みは約1ナノ秒ですが、メインメモリ(DRAM)までデータを取りに行くと50〜100ナノ秒を要します。実に50〜100倍もの速度差が存在します。
[]User と []*User の決定的な構造格差
数万件のデータを扱うスライスを定義するとき、値のスライス []User とポインタのスライス []*User では、メモリ配置が根本から異なります。
// 1. []User(値のスライス: 連続した平坦なメモリ空間)
// [ User0 (32B) ][ User1 (32B) ][ User2 (32B) ][ User3 (32B) ]
// -> CPUのハードウェアプリフェッチャが次の要素を先読みし、L1/L2キャッシュに確実にヒットする。
// 2. []*User(ポインタのスライス: 散らばった孤島)
// [ ptr0 (8B) ][ ptr1 (8B) ][ ptr2 (8B) ][ ptr3 (8B) ]
// │ │ │ │
// ▼ ▼ ▼ ▼
// [User0] [User1] [User2] [User3] (ヒープ上のバラバラの番地)
// -> ループで要素を辿るたびにポインタ参照解決(Pointer Chasing)が発生し、キャッシュミスが頻発する。
[]User であれば、メモリ上で各要素が隙間なく一列に並んでいます。CPUは「次にどのメモリが読まれるか」を予測できるため、命令が実行される前にハードウェアプリフェッチャがL1/L2キャッシュへデータをロードしてくれます。
しかし []*User の場合、スライス自体に入っているのは単なるメモリアドレス(8バイトのポインタ)です。実体データはヒープ上のまったく無関係なメモリ領域に散らばっているため、CPUはループのたびにメモリアクセス待ち(キャッシュミス)でストール(待機)させられます。これが「ポインタチェイス」と呼ばれる深刻なハードウェアペナルティです。
ベンチマーク検証: ポインタ化でどれほど遅くなるのか?
理屈だけでなく、実際にベンチマークテストを実行して計測してみましょう。中規模な構造体(約48バイト)を定義し、値渡しとポインタ渡しでのスループットおよびアロケーション回数を比較します。
package bench
import "testing"
type Profile struct {
ID int64
Score float64
Active bool
FirstName [16]byte
LastName [16]byte
}
// 値渡し
func computeValue(p Profile) float64 {
return float64(p.ID) + p.Score
}
// ポインタ渡し(エスケープを伴う実務に近い構造)
func computePointer(p *Profile) float64 {
return float64(p.ID) + p.Score
}
func BenchmarkPassValue(b *testing.B) {
p := Profile{ID: 1001, Score: 85.5, Active: true}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = computeValue(p)
}
}
func BenchmarkSliceIterationValue(b *testing.B) {
const size = 10000
list := make([]Profile, size)
for i := 0; i < size; i++ {
list[i] = Profile{ID: int64(i), Score: float64(i)}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var total float64
for j := 0; j < size; j++ {
total += list[j].Score
}
}
}
func BenchmarkSliceIterationPointer(b *testing.B) {
const size = 10000
list := make([]*Profile, size)
for i := 0; i < size; i++ {
list[i] = &Profile{ID: int64(i), Score: float64(i)}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var total float64
for j := 0; j < size; j++ {
total += list[j].Score
}
}
}
このベンチマークを実行した結果が以下です(Go 1.23, Apple M2 Max)。
$ go test -bench=. -benchmem
BenchmarkPassValue-12 1000000000 0.29 ns/op 0 B/op 0 allocs/op
BenchmarkSliceIterationValue-12 1208420 982.5 ns/op 0 B/op 0 allocs/op
BenchmarkSliceIterationPointer-12 321940 3712.0 ns/op 0 B/op 0 allocs/op
結果は明白です。スライスを走査して合計値を計算する処理において、ポインタのスライス([]*Profile)は、値のスライス([]Profile)に比べて約3.8倍も低速です。コピーを嫌ってポインタにした結果、キャッシュミスの山を築いて処理速度を約4分の1に低下させていたのです。
それでもポインタを使うべき「3つの正当な理由」
ここまで「ポインタ渡しの害悪」を述べてきましたが、筆者は「ポインタを一切使うな」と言いたいわけではありません。Goにおいてポインタを使用すべき場面は、明確に以下の3点に絞られます。
1. 構造体の内部状態を変更(Mutation)する必要があるとき
メソッドや関数が、レシーバーや引数として受け取った構造体のフィールドを直接書き換える必要がある場合です。値渡しではコピーが書き換わるだけで呼び出し元に反映されないため、この場合はポインタレシーバー(func (u *User) UpdateName(...))が必須となります。
2. 構造体自体が巨大(数KB〜数十KB以上)なとき
構造体のサイズが数百バイトから数KBを超えてくると、スタックフレームへの memmove(メモリコピー)自体のコストが無視できなくなります。目安として、一般的なWebアプリケーションで扱う数十〜百数十バイト程度のエンティティであれば、コピーコストよりもヒープエスケープを避けるメリットの方が遥かに上回ります。
3. nil の表現、またはコピー禁止型を内包しているとき
「値が存在しない(未設定)」状態を表現したい場合(JSONのオプショナルフィールドなど)、あるいは sync.Mutex や sync.WaitGroup といった内部にポインタや状態を持つ「コピー禁止型(nocopy)」をフィールドに含む構造体は、必ずポインタで扱わなければなりません。これらを値渡ししてコピーすると、デッドロックやレースコンディションの原因になります。
まとめ: 「デフォルトは値渡し」から設計を始めよ
Goにおいて最も健全でパフォーマンスの高い設計方針は、次のシンプルなルールに集約されます。
- まずはすべて値渡し(Value)で書く。 DTO、レスポンス構造体、小さなエンティティはデフォルトで値型とする。
- 「変更が必要な場合」と「巨大なデータの場合」にのみポインタを検討する。
- 迷ったらベンチマーク(
testing.B)と-gcflags="-m"を取る。 根拠のない「ポインタのほうが速そう」という直感をコードベースに持ち込ませない。
C言語の亡霊を捨てましょう。GoのコンパイラとCPUキャッシュの力を信じ、安易なポインタ乱用をやめるだけで、あなたのGoアプリケーションは驚くほど軽快に、そしてGCの重圧から解放されて安定して稼働するようになります。