はじめ
最近、フォーラムで非常に興味深い(そして直感に反する)質問を見かけました。
ある並行カウンタープログラムで、変数宣言をループの外から goroutine の中に移動しただけで、性能が10倍も向上した。
一 見すると取るに足らない変更に見えますが、実際には Go の実行モデルの核心に触れる問題です。
問題の再現:たった2行で生まれる巨大な差
遅いバージョン:
for i := 0; i < numCPU; i++ {
localCount := 0 // ← ループ内で宣言
go func() {
for j := 0; j < N; j++ {
localCount++
}
}()
}
速いバージョン:
for i := 0; i < numCPU; i++ {
go func() {
localCount := 0 // ← goroutine 内で宣言
for j := 0; j < N; j++ {
localCount++
}
}()
}
ベンチマーク結果:
| 実装 | 変数宣言位置 | 典型的な実行時間 |
|---|---|---|
| 遅い | ループ内 | 24ms |
| 速い | goroutine 内 | 2.4ms |
性能差は実に10倍です。
最初の仮説:キャッシュラインの擬似同期(false sharing)?
多くの人がまず思い浮かべるのは「キャッシュラインの擬似同期(false sharing)」でしょう。複数の CPU コアが同一キャッシュライン上のデータを書き換えることで、キャッシュ無効化が頻発する問題です。
しかし、このケースでは各 goroutine がそれぞれ独立した localCount を操作しています。仮に偶然同じキャッシュラインに配置されたとしても、影響は軽微であり、10倍もの差を説明することはできません。
本当の原因は、より深いところにあります。
それが「変数のヒープへのエスケープ」です。
詳細解析:真の性能キラーはヒープエスケープ
Go におけるクロージャ捕捉は「参照」
Go の無名関数(クロージャ)は、外側の変数を値ではなく参照として捕捉します。
遅いバージョンでは、localCount はループ内で宣言されています。しかし goroutine は非同期に実行されるため、実際に動き出す頃にはループがすでに終わっている可能性があります。
もし localCount がスタック上に置かれていたとすると、スタックフレームが解放されたあとに参照されれば、ダングリングポインタ を生じることになります。これを防ぐため、Go コンパイラは localCount をヒープに逃がします(escape to heap)。
以下のコマンドで確認できます:
go build -gcflags="-m" your_file.go
出力例:
localCount escapes to heap
ヒープ割り当てがもたらすコスト
localCount がヒープに配置されることで、以下のコストが発生します。
-
メモリ割り当てコスト
ヒープへの割り当てには、アロケータの呼び出しやロックの取得といったオーバーヘッドが伴います -
書き込みバリア(write barrier)
localCount++のたびに GC の書き込みバリアが介在します -
キャッシュ局所性の悪化
ヒープ上のオブジェクトは配置が散在しやすく、CPU キャッシュミスが増えます -
GC 負荷の増大
大量の goroutine がヒープオブジェクトを持つことで、GC のスキャン時間が増加します
これらのオーバーヘッドが高頻度ループ内で累積し、結果として劇的な性能低下を招きます。
false sharing は副次的要因にすぎない
ヒープ上のオブジェクトが同一キャッシュラインに配置される可能性はありますが、それは確率的な問題であり、影響も限定的です。
10倍の性能差の本質は:
ヒープ割り当て + 書き込みバリア + GC
にあります。
なぜ Go コンパイラは最適化しないのか
理由は明確です。
-
クロージャ捕捉の意味論
goroutine は非同期に実行され、ループ終了後にアクセスされる可能性があるため、スタック配置は安全ではありません -
エスケープ解析は保守的
コンパイラは goroutine の正確なライフタイムを静的に追跡できず、「可能性がある」場合は必ずエスケープさせます -
安全性優先の設計思想
プログラマにとって予測不能な最適化よりも、一貫した安全な挙動を優先します -
JIT が存在しない
Go は AOT(Ahead-Of-Time)コンパイルであり、実行時の動的最適化ができません
JVM との対比
Java では、Lambda は final な値/参照を捕捉し、JIT によるスカラー置換が可能です。その結果、実質的にエスケープせずに最適化されます。
他言語との比較
| 言語 | クロージャの意味論 | エスケープ解析 | 並行モデル | エスケープ |
|---|---|---|---|---|
| Go | 参照を捕捉 | 静的・保守的 | goroutine | あり |
| Java | final 値を捕捉 | JIT + スカラー置換 | Thread / Lambda | なし |
| PHP | 値コピー | 実行時管理 | コルーチン | なし |
実践的な指針
- ローカル変数は goroutine の中で宣言する
go func() {
localCount := 0
for j := 0; j < N; j++ {
localCount++
}
}()
- ループ変数をそのままクロージャで捕捉しない
for i := 0; i < numCPU; i++ {
go func(id int) {
fmt.Println(id)
}(i)
}
- エスケープ解析を活用する
go build -gcflags="-m -l" ./...
-
安全性と性能のトレードオフを理解する
Go は常に「明示性」と「安全性」を優先します
まとめ
- Go の並行処理では、ローカル変数のエスケープが深刻な性能低下を招く
- 原因は false sharing ではなく、ヒープ割り当て・書き込みバリア・GC
- JVM や PHP とは実行モデルと最適化戦略が根本的に異なる
- クロージャ・goroutine・エスケープ解析を正しく理解することが重要
付録:エスケープの即席確認方法
go build -gcflags="-m" main.go
次のメッセージが出力されていれば、ヒープへのエスケープが発生しています:
./main.go:10:13: localCount escapes to heap
逆に表示されなければ、その変数は安全にスタック上に配置されています。