Goの開発チームは、プログラムの実行速度をより向上させるために、メモリ管理の最適化に絶えず取り組んでいます。
2026年2月に発表されたGo 1.25および1.26では、プログラムのパフォーマンスを阻害する大きな要因の一つであるヒープ割当の削減に焦点が当てられました。
特にスライスの扱いや、これまで困難だった動的なサイズ指定におけるメモリ割当の仕組みが劇的に進化しています。
本記事では、最新のGoコンパイラがどのようにメモリ割当を最適化し、開発者が意識せずとも高速なコードを実現しているのか、その詳細を紐解いていきます。
ヒープ割当とスタック割当の違い
Goのメモリ割当には大きく分けて「ヒープ」と「スタック」の2種類が存在します。
プログラムの高速化を考える上で、この違いを理解することは非常に重要です。
ヒープからメモリを割り当てる場合、Goのランタイムは複雑なロジックを実行して適切なメモリ領域を確保する必要があります。
また、ヒープ割当が増えると、ガベージコレクタ (GC) への負荷が増大します。
たとえGreen Teaのような最新のGC改良が施されていても、ヒープの管理コストは依然として無視できないオーバーヘッドとなります。
一方でスタック割当は、関数実行時に確保されるスタックフレーム内にメモリを配置するため、割当コストが極めて低い、あるいは実質無料です。
スタックフレームが破棄されるのと同時にメモリも自動的に回収されるため、GCに負荷をかけることがありません。
さらに、スタック上のデータはCPUキャッシュに乗りやすく、実行効率の面でも非常に有利です。
定数サイズスライスのスタック割当
Goのコンパイラは以前から、サイズが固定されており、関数の外に「エスケープ」しないスライスについてはスタック割当を行ってきました。
まずは、従来の動作を振り返るために以下のコードを見てみましょう。
func process2(c chan task) {
// 10個程度のタスクを処理することを想定し、キャパシティを事前に確保
tasks := make([]task, 0, 10)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks) // tasksがこの関数の外に漏れ出さない (エスケープしない) ことが前提
}
この例では、makeの第3引数 (キャパシティ) が 10 という定数です。
コンパイラはコンパイル時に必要なメモリ量を把握できるため、このスライスのバッキングストア (実体となる配列) をスタック上に配置します。
これにより、ヒープ割当回数をゼロに抑えることができていました。
Go 1.25:動的サイズ指定時の最適化
しかし、実務ではスライスのサイズを事前に定数で決めることが難しいケースが多くあります。
関数の引数で受け取った変数をキャパシティに指定する場合、Go 1.24まではスタック割当ができず、必ずヒープ割当が発生していました。
func process3(c chan task, lengthGuess int) {
// lengthGuessは変数であるため、Go 1.24以前ではヒープ割当になる
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Go 1.25では、この制約が緩和されました。
コンパイラは、投機的なスタック割当という手法を導入しています。
32バイトのスタックバッファ活用
Go 1.25のコンパイラは、makeに変数によるサイズ指定が行われた際、内部的に「小さなスタック領域 (現在は最大32バイト)」を事前に準備するようにコードを変換します。
指定されたサイズがこのスタック領域に収まる場合はスタックを使い、収まらない場合のみヒープへ切り替えるという処理を自動で行います。
これにより、lengthGuessが小さい値であれば、変数をサイズ指定に使っているコードでもヒープ割当をゼロにすることが可能になりました。
開発者は煩雑な最適化コードを手書きする必要がなくなったのです。
Go 1.26:append処理の劇的な進化
2026年にリリースされたGo 1.26では、さらに一歩進んだ最適化が導入されました。
それは、append関数におけるスタック割当の活用です。
通常、空の状態から append を繰り返すと、スライスの背後にあるメモリ領域は 1 → 2 → 4 → 8 … と倍々に再確保されます。
これらはすべてヒープ上で行われ、古い領域はガベージ(ゴミ)となっていました。
func process(c chan task) {
var tasks []task // 初期状態はnil
for t := range c {
// Go 1.26以前は、初回のappendからヒープ割当が発生していた
tasks = append(tasks, t)
}
processAll(tasks)
}
Go 1.26では、この最初の割当段階において、コンパイラが自動的にスタック上の小さなバッファを割り当てます。
例えば、タスクが4つまでスタックに収まる場合、最初の4回のループではヒープ割当が一切発生しません。
データ量が増えてスタックに入り切らなくなった時点で初めてヒープへ移行します。
この仕組みにより、小さなスライスを扱う際のスタートアップ・オーバーヘッドがほぼ解消されました。
エスケープするスライスの最適化:move2heap
最も興味深い進化は、関数から返されるスライス、つまり「エスケープする」スライスに対する最適化です。
本来、関数が終了するとスタックは破棄されるため、戻り値となるデータをスタックに置くことはできません。
しかし、Go 1.26ではコンパイラがコードを以下のように内部変換することで、効率を高めています。
// コンパイラによる内部変換のイメージ
func extract3(c chan task) []task {
var tasks []task
for t := range c {
// まずはスタック上でappendを試みる
tasks = append(tasks, t)
}
// 最後に必要最小限のサイズでヒープへコピーする
tasks = runtime.move2heap(tasks)
return tasks
}
この runtime.move2heap は、Go 1.26で導入された特殊なランタイム関数です。
もし対象のスライスがまだスタック上にある場合は、その時点で最適なサイズのヒープメモリを1回だけ確保し、データをコピーします。
これまでのGoでは、ループ内での append ごとに何度もヒープ割当と古いデータのコピーが発生していました。
この新技術により、最終的なデータ量がスタックバッファ内に収まる程度であれば、ヒープ割当はプログラム全体を通して最後に1回だけで済むようになります。
| 最適化の対象 | Go 1.24以前 | Go 1.25 / 1.26 |
|---|---|---|
定数サイズの make | スタック割当可能 | スタック割当可能 (継続) |
変数サイズの make | 常にヒープ割当 | 小サイズならスタック割当 (1.25〜) |
初期状態からの append | 常にヒープ割当 | 小サイズならスタック割当 (1.26〜) |
| エスケープするスライス | 常にヒープ割当 | 中間工程をスタック化、最後にヒープ移行 (1.26〜) |
パフォーマンスへの影響と注意点
これらの最適化は、特にマイクロサービスやAPIサーバーなどで頻繁に発生する「小さな一時的なリスト作成」において顕著な効果を発揮します。
手動で複雑な事前割当ロジックを書かなくても、最新のGoにアップグレードするだけで、アプリケーション全体のメモリ使用効率が向上し、GCによる停止時間(Stop The World)の短縮が期待できます。
ただし、もしこれらの新しい最適化によって予期しない動作や、非常に特殊なケースでのパフォーマンス低下が疑われる場合は、コンパイルフラグによって機能を無効化することも可能です。
-gcflags=all=-d=variablemakehash=n
このフラグを指定することで、変数をベースとしたスタック割当最適化をオフにできます。
万が一、不具合に遭遇した場合は、このフラグで切り分けを行い、公式のリポジトリへ Issue を報告することが推奨されています。
まとめ
Go 1.25および1.26におけるスライスの最適化は、Goが掲げる「シンプルでありながら高性能」という哲学を体現したアップデートです。
スタック割当の適用範囲を動的サイズやappend操作にまで広げたことで、開発者は実装の美しさを犠牲にすることなく、最高クラスのパフォーマンスの恩恵を受けられるようになりました。
特にGo 1.26で導入された move2heap の仕組みは、エスケープ解析の制約を賢く回避する画期的な手法です。
これにより、小規模なデータ処理における不必要なヒープ割当が徹底的に排除されました。
私たちはただ、最新のGo環境を整え、これまで通り慣れ親しんだ書き方でコードを書くだけで良いのです。
それだけで、背後のコンパイラがより賢く、より速く、私たちのプログラムを動作させてくれます。
