ゼロアロケーション(zero-allocation)は、繰り返し実行される処理の中でヒープへのメモリ割り当てを0バイトにし、その処理の割り当てに由来するガベージコレクション(GC)の負荷を抑える設計です。この記事では、C#(.NET 10)で割り当てを消す手法を実測値つきで並べ、割り当て量の測り方、Unity・Goでの確認方法、そしてやりすぎてはいけない場面までを扱います。数値はすべて .NET 10.0.12(x64・Release)とGo 1.26.5で実行して得たものです。
まとめ:ゼロアロケーションの要点
- 対象はプログラム全体ではなく、毎フレーム・毎リクエストで回るホットパスの定常状態。起動時に確保して使い回すのは「ゼロ」に含めてよい
- 先に測る。C#は
GC.GetAllocatedBytesForCurrentThread()かBenchmarkDotNetの[MemoryDiagnoser]、Goは-benchmemのallocs/opで0を確認する - C#の主な実装手法は、
Span<T>・stackalloc(文字列の分割・整形)、ArrayPool<T>(一時バッファ)、ObjectPool<T>(使い回すオブジェクト)、ValueTask<T>(同期完了が多い非同期) - 85,000バイト以上の配列はLOH(ラージオブジェクトヒープ)に入り、第2世代GCでしか回収されない。大きな一時バッファほどプール化の価値がある
- 計測で割り当てが問題になっていない箇所まで書き換えない。プールの返却忘れや返却後の使用は、GCより厄介なバグを生む
ゼロアロケーションの定義:ヒープ割り当てとGCの関係
ゼロアロケーションは、GCのある言語(C#、Java、Go など)で使われる用語です。対象処理の割り当てをなくすと、割り当て増加に伴うGCの負荷を抑えられます。ただし、別の処理による割り当て、メモリ不足、明示的なGC呼び出しなどでもGCは発生するため、停止時間がなくなる保証はありません。速度そのものよりも、処理時間のばらつきを減らすための手法だと捉えると採否を判断しやすくなります。
スタックとヒープ領域の違い
ヒープ領域は、new で作ったクラスのインスタンスや配列、文字列が置かれる領域で、不要になった分はGCが回収します。スタックはメソッド呼び出しごとに積まれる領域で、メソッドを抜けた時点で丸ごと捨てられるためGCの対象になりません。C#では int や struct などの値型がスタックに置かれることが多い一方、ラムダに捕捉されたローカル変数やクラスのフィールドになった値型はヒープに移ります。「値型なら必ずスタック」ではない点が、割り当てを見落とす典型的な原因です。値型を object やインターフェースとして扱ったときのボックス化も同じ種類の見落としで、詳しくはC#のボックス化の起きる場面と割り当てバイト数で実測しています。
.NETのGC世代とLOHの85,000バイト境界
.NETのGCは、新しいオブジェクトを第0世代に置き、回収を生き延びたものを第1世代・第2世代へ昇格させます。第0世代の回収は安く、第2世代の回収はヒープ全体をなめるので重いという差があります。例外は85,000バイト以上のオブジェクトで、最初からLOHに置かれ、第2世代の回収でしか解放されません。x64の.NET 10で確かめると、new byte[84975] は第0世代、new byte[84976] は第2世代(LOH)でした。配列ヘッダーの24バイトを足したオブジェクト全体が85,000バイトに達した時点で切り替わります。受信バッファなどを毎回 new byte[100_000] で作るコードは、少量でも第2世代GCの頻度を押し上げます。
「ゼロ」が指す範囲:起動時の確保と定常状態
ゼロアロケーションは「一度も割り当てない」という意味ではありません。起動時やシーンの読み込み時にバッファやプールを確保し、以降のループではそれを使い回して新規確保を0にする、というのが実務上の定義です。ライブラリが「zero-allocation」をうたうときも、この定常状態のホットパスを指しています。例えばCysharpのZString(最新2.6.0)はゼロアロケーションの文字列ビルダー、ZLogger(最新2.5.10)はゼロアロケーションのロガーで、LINQを置き換えるZLinqのゼロアロケーションLINQも同じ考え方です。
割り当て量の測り方:コード内計測・BenchmarkDotNet・dotnet-counters
書き換えの前に、どこでどれだけ割り当てているかを数字で押さえます。推測で Span に置き換えても、実際の割り当て元が別の場所なら効果はありません。
GC.GetAllocatedBytesForCurrentThreadでコード内計測
GC.GetAllocatedBytesForCurrentThread() は、現在のスレッドがこれまでに割り当てた累計バイト数を返します。前後の差を取れば、同じスレッドで同期的に実行した区間のマネージドヒープ割り当て量がわかります。他スレッドやネイティブメモリの割り当ては含まれません。次の Action に非同期ラムダを渡すと完了まで計測できないため、同期処理に限定してください。JITの段階的コンパイル(tiered compilation)によって割り当て量が変わる場合があります。数百回の呼び出しだけで最適化完了を保証することはできませんが、初期化の影響を減らすため、先に数百回ウォームアップしてから測ります。
static long AllocatedPerCall(Action body, int n = 1000)
{
for (int i = 0; i < 200; i++) body();
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < n; i++) body();
long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
return bytes / n + (bytes % n == 0 ? 0 : 1);
}
単体テストに組み込み、「この関数の割り当ては0バイト」をアサートしておくと、後からの変更で割り当てが混入したときに気づけます。
BenchmarkDotNetのMemoryDiagnoserで比較
書き換え前後の比較には、BenchmarkDotNet(最新0.15.8)のクラスに [MemoryDiagnoser] を付けます。結果表に1回あたりの割り当て量(Allocated列)と、1,000回あたりの第0世代GC回数(Gen0列)が加わります。
[MemoryDiagnoser]
public class CsvBench
{
private const string Csv = "3,1,4,1,5,9,2,6";
[Benchmark(Baseline = true)]
public int Split()
{
int total = 0;
foreach (var part in Csv.Split(',')) total += int.Parse(part);
return total;
}
[Benchmark]
public int SpanSplit()
{
int total = 0;
ReadOnlySpan<char> s = Csv;
foreach (var range in s.Split(',')) total += int.Parse(s[range]);
return total;
}
}
.NET 10.0.12で実行した結果です(ShortRunJob・Intel Core i9-9880H)。StringBuilderの2行は同じクラスに加えた別メソッド(CSV文字列とセミコロンを Append して Length を返すだけで、ToString による文字列生成は含まない)の結果で、コードは後述のObjectPoolの節と同じ取得・返却の形です。
| メソッド | 平均時間 | Gen0 | Allocated |
|---|---|---|---|
| string.Split | 549.52 ns | 0.0334 | 280 B |
| ReadOnlySpan.Split | 240.64 ns | – | -(0 B) |
| new StringBuilder | 65.15 ns | 0.0124 | 104 B |
| ObjectPoolのStringBuilder | 47.90 ns | – | -(0 B) |
この結果のAllocated列の「-」は、計測上の割り当て量が0だったことを示します。未実行の分岐や異なる入力、他スレッドでの割り当てまで存在しないと保証するものではありません。マイクロベンチマーク全般の落とし穴(最適化による計測対象の消失やウォームアップ不足)はマイクロベンチマークの基本概念と注意点を参照してください。
dotnet-countersによる本番環境の割り当て速度の監視
本番やステージングでは、コードを変えずに dotnet-counters で割り当て速度を見ます。.NET 8以前のランタイムでは System.Runtime の「Allocation Rate(alloc-rate)」が更新間隔ごとの割り当てバイト数です。.NET 9以降の組み込みメトリクスでは dotnet.gc.heap.total_allocated(プロセス開始からの累計割り当てバイト数)に置き換わっており、間隔ごとの増え方を見ます。
dotnet tool install -g dotnet-counters
dotnet-counters monitor -n MyApp --counters System.Runtime
ゼロアロケーション化した処理を流しているのに累計値が増え続けるなら、割り当て元が別にあります。その場合は dotnet-trace やVisual Studioの割り当てプロファイラで呼び出し元を特定します。
C#で割り当てを消す実装手法と.NET 10での実測値
代表的な書き換えを、同じ計測方法(ウォームアップ200回・1,000回の平均)で比べました。
| 処理 | 書き換え前 | 書き換え後 |
|---|---|---|
| 8個の整数をカンマ連結 | string += 352 B | stackalloc+TryFormat 0 B |
| CSV文字列を分割して合計 | string.Split 280 B | ReadOnlySpan.Split 0 B |
| 4KBの一時バッファ | new byte[4096] 4,120 B | ArrayPool.Rent 0 B |
| 条件に合う要素の検索 | ラムダで引数を捕捉 88 B | forループ 0 B |
| 同期完了する非同期メソッド | Task<int> 72 B | ValueTask<int> 0 B |
| Listに100件追加 | 容量指定なし 1,184 B | 容量100を指定 456 B |
最後の行だけは0になりません。リスト本体は残るので、定常状態で0にしたいならリストをフィールドに持って Clear() して使い回します。
Span<T>とstackallocによる文字列処理の割り当て削減
Span<T> は配列・スタック領域・ネイティブメモリの一部分を、コピーせずに指す型です。ref struct なのでヒープに置けず、クラスや通常の構造体のフィールドには保持できません。ただし、別の ref struct のフィールドには保持できます。C# 13以降では、await をまたいで使用しない範囲で async メソッド内にも宣言できます。その制約と引き換えに、部分文字列を切り出しても新しい文字列を作りません。stackalloc はスタック上に一時バッファを確保し、Span<T> で受ければ unsafe なしで使えます。
static int WriteCsv(ReadOnlySpan<int> xs, Span<char> dest)
{
int pos = 0;
foreach (var x in xs)
{
if (pos >= dest.Length || !x.TryFormat(dest[pos..^1], out int written))
throw new ArgumentException("出力バッファが不足しています。", nameof(dest));
pos += written;
dest[pos++] = ',';
}
return pos;
}
Span<char> buf = stackalloc char[64];
int len = WriteCsv([3, 1, 4, 1, 5], buf);
Console.WriteLine(buf[..len].ToString());
最後の ToString() で初めて文字列が1個作られます。書き出し先がストリームやソケットなら、そこも省けます。stackalloc には公式ドキュメントが守るべき規則を挙げています。確保量を制限する(例として const int MaxStackLimit = 1024; を超えたら配列に切り替えるコードを掲載)、ループの中で使わない(ループの外で確保して使い回す)、確保直後の中身は未定義なので初期化してから読む、の3点です。サイズが入力次第で決まる場合は、次のように分岐させます。
const int MaxStackLimit = 1024;
Span<byte> buffer = length <= MaxStackLimit
? stackalloc byte[length]
: new byte[length];
ArrayPool<T>による一時バッファの再利用
スタックに載せられない大きさのバッファは、ArrayPool<T>.Shared から借りて返します。4KBのバッファを毎回 new すると1回4,120バイトの割り当てになり、Rent と Return に変えると0バイトでした。
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int read = stream.Read(buffer, 0, 4096);
Process(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
守るべき点は3つあります。Rent は要求以上の長さを返すことがあり、実測で Rent(1000) は長さ1,024、Rent(4097) は8,192の配列でした。buffer.Length ではなく自分が要求した長さで処理します。次に、借りた配列には前の利用者のデータが残っています。個人情報や鍵の消去を保証したい場合は、返却前に System.Security.Cryptography.CryptographicOperations.ZeroMemory(buffer) でバッファを消去します。Return(buffer, clearArray: true) だけでは、プールが配列を保持しない場合の消去は保証されません。最後に、Return した配列を触り続けるコードは、別の場所で貸し出された同じ配列を書き換えます。try/finally で返却位置を1か所に固定するのが安全です。
ObjectPool<T>によるオブジェクトの再利用
配列以外のオブジェクトは、NuGetパッケージ Microsoft.Extensions.ObjectPool(最新10.0.12)でプール化できます。StringBuilder 用には専用のファクトリーがあり、先のベンチマークでは毎回 new した場合の104バイトが0になりました。
private static readonly ObjectPool<StringBuilder> Pool =
new DefaultObjectPoolProvider().CreateStringBuilderPool();
public string Format(ReadOnlySpan<int> xs)
{
var sb = Pool.Get();
try
{
foreach (var x in xs) sb.Append(x).Append(',');
return sb.ToString();
}
finally
{
Pool.Return(sb);
}
}
この例は戻り値の文字列1個ぶんの割り当てが残ります。プールで消えるのは StringBuilder 本体と内部バッファの割り当てです。生成コストの低い小さなオブジェクトまでプールに入れると、取得と返却の手間のほうが高くつきます。プール化するのは、生成が重いもの(バッファを内部に持つもの)か、毎秒大量に作って捨てているものに絞ります。
ValueTask<T>:同期完了が多い非同期メソッド
async Task<int> のメソッドは、await せずに値を返した場合でも結果を入れる Task オブジェクトを作ります(実測72バイト。ごく小さな整数など一部の値はランタイムがキャッシュ済みの Task を返します)。キャッシュヒットで即座に返ることが多いメソッドは、戻り値を ValueTask<int> にすると同期完了時の割り当てが0になりました。ただし ValueTask は2回 await できず、.Result を完了前に読むこともできません。呼び出し側が結果を保持して何度も待つ設計なら Task のままにします。
ラムダの変数捕捉とコレクション容量の見落とし
外側のローカル変数や引数を捕捉するラムダでは、捕捉用オブジェクトとデリゲートの割り当てが発生することがあります。割り当てはラムダ式を評価するたび(下の例では FindIndex を呼ぶたび)に起き、作ったデリゲートを呼び出すだけなら起きません。静的メンバーだけを参照するラムダは変数を捕捉しません。Array.FindIndex(xs, x => x > threshold) は1回88バイト、同じ処理を for ループで書くと0バイトでした。変数を捕捉しないラムダはデリゲートがキャッシュされるので、捕捉しないことが分かっているなら static ラムダにしてコンパイラに保証させます。List<T> は容量が足りなくなるたびに2倍の配列を作り直すため、件数が分かっているなら容量を指定します。100件の追加で1,184バイトが456バイトになりました。
Unityでのゼロアロケーション:GC.AllocとIncremental GC
Unityのゲームで割り当てを減らす動機は、フレーム時間のスパイクです。UnityのGCは世代別ではない Boehm-Demers-Weiser 方式で、オブジェクトを詰め直さない(non-compacting)ため、.NETの第0世代のような安い回収がありません。公式マニュアル(Unity 6.0)は、頻繁なマネージドヒープ割り当てを「理想的には1フレームあたり0バイト」、そうでなくても可能な限り0に近づけるよう求めています。
ProfilerのGC.Allocによる割り当て元の特定
Profilerウィンドウの CPU Usage モジュールで Hierarchy 表示にすると、各メソッドの「GC Alloc」列に1フレームの割り当て量が出ます。GC.Alloc マーカーは、自動GCの対象になるマネージドヒープ上の割り当てを表します。Update() の中で文字列を連結してUIに表示する、毎フレーム new List<T>() する、といった箇所がここに並びます。見つけたら、前章のバッファ再利用や容量指定の考え方を適用します。Span<T> を含むAPIの利用可否はUnityのAPI互換性レベルで確認し、.NET 10向けの掲載コードをそのまま移植できるとは限らない点に注意してください。弾や敵キャラクターのように生成と破棄を繰り返すオブジェクトには、Unity標準の UnityEngine.Pool.ObjectPool<T> を使います。LINQを多用するコードはZLinqに置き換える選択肢もあります。
Incremental GCの停止時間分散と割り当て削減の違い
UnityではIncremental GCが既定で有効で、GCの処理を複数フレームに分けて1回の停止を短くします。ただし分散するだけで回収の総量は変わらず、割り当てが多ければ毎フレームGCの時間が差し引かれます。また公式マニュアルは、Webプラットフォーム(WebGL)ではIncremental GCが使えないと明記しています。Web向けのビルドでは停止がそのまま出るため、割り当てを0に近づける価値がほかのプラットフォームより高くなります。ロード中に必要なメモリを確保し、割り当てを抑えたステージ実行中だけ GarbageCollector.GCMode でGCを止める方法もありますが、その間はメモリ使用量が減らないので、ステージ中の割り当てが少ないことが前提です。
GoとRustでのゼロアロケーション
Goでは、ベンチマークに -benchmem を付けると1回あたりのバイト数(B/op)と割り当て回数(allocs/op)が出ます。「zero allocation」をうたうGoライブラリ(ロガーのzapやzerologなど)は、この allocs/op が0であることを指しています。testing.AllocsPerRun を使えばテストで0を保証できます。
func joinAppend(buf []byte, xs []int) []byte {
buf = buf[:0]
for _, x := range xs {
buf = strconv.AppendInt(buf, int64(x), 10)
buf = append(buf, ',')
}
return buf
}
func TestZeroAlloc(t *testing.T) {
xs := []int{3, 1, 4, 1, 5, 9, 2, 6}
buf := make([]byte, 0, 64)
n := testing.AllocsPerRun(100, func() { buf = joinAppend(buf, xs) })
if n != 0 {
t.Fatalf("allocs = %v", n)
}
}
Go 1.26.5で、s += strconv.Itoa(x) + "," で連結する版は 80 B/op・8 allocs/op・512.8 ns/op、上の AppendInt 版は 0 B/op・0 allocs/op・85.29 ns/op でした。呼び出し側が持つ buf を使い回すのがC#の Span と同じ発想です。Goの基本はGo言語の導入とHTTP APIの実装入門で扱っています。
RustにはGCが無く、ヒープ確保は Box や Vec などを作ったときに明示的に起きます。GCの停止は起きませんが、アロケーターの呼び出しそのものに時間がかかるため、ホットループでは Vec::with_capacity で確保して clear() で使い回す書き方が同じく定石です。所有権とヒープの扱いはRust入門の所有権の章を参照してください。
ゼロアロケーションを目指すべきでない場面
ゼロアロケーションは読みやすさと安全性を代償にする最適化です。次の条件に当てはまるなら、書き換えないほうが得です。
- 計測でGCが問題になっていない:第0世代GCは短時間で終わります。1リクエストで数百バイトを割り当てるWeb APIを
Spanで書き換えても、応答時間の大半を占めるDBやネットワーク待ちは変わりません。先にプロファイラやdotnet-countersでGC時間の割合を確認します。 - ホットパスではない:起動時、設定の読み込み、管理画面など1回きりの処理を最適化しても効果はほぼ0です。
- プールの所有権を管理しきれない:
ArrayPoolやObjectPoolで返却忘れ・二重返却・返却後の使用が起きると、無関係な処理のデータが化けます。GCが起きていた頃より調査が難しいバグになるため、借用と返却が同じメソッド内で閉じない設計ならプール化を見送ります。 Spanの制約で設計が歪む:Span<T>をawaitをまたいで使用したり、クラスや通常の構造体のフィールドに保持したりできないため、無理に通すとメソッド分割が増えます。その場合はMemory<T>を使うか、割り当てを許容します。
判断の目安は、フレーム予算(60fpsなら約16.7ミリ秒)やレイテンシのSLOに対してGCの停止が実測で食い込んでいるかどうかです。食い込んでいるなら、割り当て量の多い順に上の手法を当てていきます。.NET自体の版ごとの改善(エスケープ解析によるスタック割り当ての拡大など)は.NET 10(LTS)の新機能と移行でまとめています。版を上げるだけで割り当てが減る場合もあるので、書き換えの前に確認する価値があります。
よくある質問
ゼロアロケーション(zero-allocation)とはどういう意味ですか?
処理の実行中にヒープへの新しいメモリ割り当てを行わないことです。起動時に確保したバッファやプールを使い回し、定常状態のループでの割り当てを0バイトにします。目的は割り当てに伴うGCの負荷を抑え、処理時間のばらつきを減らすことです。
GCとは何ですか?
GC(ガベージコレクション)は、プログラムが使い終わったヒープ上のメモリを自動で見つけて解放する仕組みです。C#・Java・Goなどが備えています。解放の作業中はアプリの処理が止まったり遅くなったりする時間があり、ゲームのフレーム落ちや低レイテンシ処理の遅延の原因になります。
ヒープ領域とは何ですか?
プログラムの実行中に必要な分だけ確保するメモリ領域です。C#ではクラスのインスタンス・配列・文字列がここに置かれ、GCが回収します。メソッドを抜けると自動で捨てられるスタック領域と違い、ヒープ上のオブジェクトはGCの対象になります。
メモリプールとオブジェクトプールの違いは何ですか?
メモリプールは配列やバイト列などのメモリ領域を貸し出す仕組みで、.NETでは ArrayPool<T> や MemoryPool<T> が該当します。オブジェクトプールは StringBuilder などの初期化済みオブジェクトを貸し出す仕組みで、Microsoft.Extensions.ObjectPool やUnityの UnityEngine.Pool.ObjectPool<T> が該当します。どちらも「借りて返す」ことで新規割り当てを避ける点は同じです。
allocation rate(アロケーションレート)とは何ですか?
単位時間あたりにヒープへ割り当てられたバイト数です。割り当て速度が高いほど第0世代のGCが頻繁に走ります。.NETではdotnet-countersで確認でき、ゼロアロケーション化の効果を本番環境で確かめる指標になります。