ヒープメモリの確保

Basis
最終更新:

メモリレイアウトでは、スタックが逃れられない2つの制約を紹介した。サイズが小さい(通常数メガバイト)こと、そしてコンパイル時にサイズが完全に確定していなければならないことだ。どちらかの制約が問題になるとき、ヒープ(heap)に頼ることになる。ヒープとは、プログラムが実行時に必要な量だけ借りて返せる大きなメモリプールだ。

このチェックポイントでは具体的な仕組みを扱う。Zig でその要求をどう行うか、どうやってメモリを返すか、対応が崩れたときに何が起きるかだ。

スタックでは足りないとき

スタックに収まらない大きなデータ

スタックオーバーフロー(stack overflow)は実際に起きる障害モードだ。何百万要素もあるローカル配列を宣言すると、プログラムが何か有用なことをする前にスタックの空きがなくなる:

pub fn main() void {
    var huge: [10_000_000]u8 = undefined;  // 実行時にクラッシュする可能性が高い: スタックオーバーフロー
    _ = huge;
}

大きなバッファ — 画像、音声データ、大きな行列 — はヒープに置かなければならない。

コンパイル時にサイズが不明な配列

こちらがより一般的なケースだ。プログラムの実行中にユーザーが指定する長さの整数配列を考えてみよう:

const n = readUserInput();
var buffer: [n]u8 = undefined;  // ❌ コンパイルエラー: 配列の長さはコンパイル時定数でなければならない

コンパイラはこれを拒否する。関数のコンパイル時に buffer のサイズを決定できないからだ — そのサイズはユーザーが入力するものに依存し、それは実行時にしか存在しない。入力、ファイルの内容、ネットワークデータに基づいて伸縮しなければならないあらゆる配列がこの問題を抱えている。

ヒープはこの制約を取り除く。必要なときに必要なバイト数だけ要求すれば、OS がそれを提供してくれる。

アロケータインターフェイス

Zig には隠れたグローバルアロケータが存在しない。すべてのヒープ確保はアロケータ(allocator)— std.mem.Allocator インターフェイスを実装した値 — を経由する。アロケータを作成して、メモリを確保するコードに明示的に渡す。裏で何かが勝手に起きることはない。

std.heap.GeneralPurposeAllocator は開発に適した安全な汎用アロケータだ。デバッグビルドではリークを検出し、不正な解放を捕捉する。

const std = @import("std");

pub fn main() !void {
    // アロケータを準備する
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();           // メモリが解放されていなければリークレポートを出力する
    const allocator = gpa.allocator();

    // ヒープ上に i32 値を5つ分の配列を要求する
    const numbers = try allocator.alloc(i32, 5);
    defer allocator.free(numbers);    // このスコープを抜けるときにメモリを返す

    for (numbers, 0..) |*n, i| {
        n.* = @intCast(i * 10);
    }
    for (numbers) |n| {
        std.debug.print("{} ", .{n});
    }
    // 出力: 0 10 20 30 40
}

2つのコア操作を示す:

操作呼び出し動作
確保allocator.alloc(T, n)n × @sizeOf(T) バイトを予約し、![]T を返す
解放allocator.free(slice)それらのバイトをアロケータに返す

alloc はシステムがメモリ不足の可能性があるため、エラーユニオン(error union)![]T を返す。try を使ってそのエラーを呼び出し元に伝播させるのが正しい対応だ。返ってくる値はスライス(slice)[]T — 連続したメモリへのビューで、組み込みの .len フィールドを持ち、通常の固定サイズ配列とまったく同じように使える。

alloc の直後の行に defer free を組み合わせるのが、うっかりリークを防ぐ Zig の標準イディオムだ。defer キーワードは、囲んでいるスコープがどのように終了しても — 通常の return、早期 return、エラー — その文を実行する。

手動管理の危険性

すべての alloc にはちょうど1つの free が対応していなければならない。どちらの方向にこのルールを破っても、追跡が困難で本番環境では危険なバグが生じる。

メモリリーク

メモリを確保して解放しない。プロセスが終了するまで OS はそれらのバイトを回収できない:

fn process(allocator: std.mem.Allocator) !void {
    const buf = try allocator.alloc(u8, 1024);
    if (someCondition()) {
        return error.Cancelled;  // ❌ buf を解放せずに return している
    }
    allocator.free(buf);
}

長時間動くサーバーでは、これが徐々に使用可能な RAM をすべて使い果たす。デバッグモードで gpa.deinit() を呼ぶと GeneralPurposeAllocator がリークを報告してくれる — defer を維持するもう一つの理由だ。

Use-after-free

メモリを解放してからアクセスする。アロケータがすでにそれらのバイトを別の確保に使っている可能性があり、予測不能なデータを読み書きすることになる:

const buf = try allocator.alloc(u8, 4);
allocator.free(buf);
buf[0] = 99;   // ❌ 未定義動作 — バイトはすでにアロケータに返されている

use-after-free は最も悪用されるセキュリティ脆弱性のクラスの一つだ。攻撃者は特定のタイミングでプログラムにメモリを解放させ、まだ自分のものであるかのようにそれを読み書きする入力を作れる。

ダブルフリー(double free)

同じスライスに対して free を2回呼ぶ。これはアロケータ内部の管理情報を壊し、通常はクラッシュを引き起こす — あるいはサイレントに悪用可能な動作を生じさせる:

allocator.free(buf);
// ... 後で、おそらく別のコードパスで ...
allocator.free(buf);   // ❌ すでに解放されている

3つのバグはすべて同じ根本を持つ。コンパイラはすべての alloc に正しいタイミングでちょうど1つの free が対応しているかどうかを確認する手段を持っていない。

これが Rust が存在する理由

Zig では、正しい対応を保つ責任は完全にあなた自身にある。それは意図的な設計判断だ — Zig はあらゆるものを自分でコントロールする、透明で低レベルな言語を目指している。しかしプログラムが成長するにつれて確保の数も増え、不変条件を手動で維持することはどんどん難しくなる。

Rustはこれを所有権(ownership)と借用チェッカー(borrow checker)システムで解決する。コンパイラがコンパイル時に、コードのどの部分がヒープメモリの各ピースを所有しているかを追跡する。所有者のスコープが終わったとき、メモリは自動的に解放される — free の呼び出しも、ガベージコレクターの停止も不要だ。さらに重要なことに、借用チェッカーは use-after-free、ダブルフリー、そしてほとんどのメモリリークを実行時の惨事ではなくコンパイルエラーに変える。

直接的なヒープ確保の生の力と、すべての対応を手動で管理するコスト、その両面を見てきた。Rust の所有権モデルは、その力を保ちながらコストをなくすためにこそ存在する。

まとめ

  • スタックは大きすぎるデータ(スタックオーバーフロー)や、コンパイル時にサイズが不明なデータ(コンパイラは固定の配列長を必要とする)を保持できない。
  • ヒープメモリは両方を解決する。allocator.alloc(T, n) で今すぐ必要なバイト数だけ要求し、allocator.free(slice) で返す。
  • Zig では、すべての確保は明示的だ — アロケータを作成して渡し、すべての確保がコードに見える形で現れる。
  • alloc![]T(エラーユニオン)を返す。メモリ不足の失敗がサイレントに無視されないよう、常に try を使う。
  • リークを防ぐため、alloc の次の行に defer free を対にして書く。
  • メモリリークfree を忘れる。use-after-freefree 後にメモリにアクセスする。ダブルフリー — 同じ確保に対して free を2回呼ぶ。
  • Rust の所有権システムは、対応ルールをコンパイル時に検証し、所有者がスコープを外れたときに自動的にメモリを解放することで、3つすべてを排除する。