0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gooseの「ヒープもGCも不要」はどう動く?図解と4つのコードで読むメモリ管理

0
Last updated at Posted at 2026-10-27

大量のデータを作り、処理が終わったらまとめて捨てたい。そんな場面で「一つずつメモリを確保し、片付ける必要はあるのか」と考えると、Gooseの設計が見えてきます。

Gooseは、Wouter van Oortmerssen氏が設計した開発途上のシステムプログラミング言語です。作者のREADMEでは、人間の言語設計を基に、リポジトリの大部分をAIで実装したと説明されています。

特徴は、動的なデータを複数の「データスタック」に置き、寿命が終わるところでまとめて領域を再利用すること。この仕組みに、データを詰めて配置する型と、参照の寿命を静的に調べる規則が組み合わされています。

2026年10月27日に確認した公式リポジトリの固定版を読み、図とコード例で整理しました。本記事は公開資料の読解です。コンパイラの実行、掲載コードのコンパイル、性能の独立再測定は行っていません。 コードの表示例は仕様から想定したものです。

倉庫全体より、「作業台の端」を考える

例えば、注文データを1万件読み、集計表を作ったら、途中のデータは全部いらなくなる処理を考えます。

一般的なヒープ割当では、アロケータという仕組みに「この大きさの置き場所をください」と頼みます。Gooseのデータスタックは、あらかじめ用意した大きな領域の使った端を伸ばしていくイメージです。

汎用ヒープ割当とデータスタックの模式図。Gooseは領域の終端を進め、スコープ終了時に戻す。

図1:概念を比べる模式図です。実際のアロケータの配置を再現したものではありません。C++やRustでも、スタック配置やアリーナを使う設計は可能です。

「荷物を何も考えずに散らかす言語」と「きれいに片付ける言語」の対決ではありません。データの寿命や形に制約を置くことで、管理の仕方を単純にするという選択です。

何をなくし、何を残しているのか

GC(ガベージコレクション)は、使われなくなったデータを見つけ、メモリを回収する仕組みです。GooseはこのGCを使わず、「この範囲の処理が終わったら、ここで作ったデータはまとめて不要になる」という境界を利用します。この処理の範囲がスコープで、後のコード例では { ... } が目印になります。

Gooseには通常の関数呼出しのスタック、静的データ、そしてコンパイラが割り当てる複数のデータスタックがあります。

データスタックは、仮想アドレス空間に確保した大きな領域と、使用中の終端を指すポインタでできています。仮想アドレス空間は、プログラムから見たメモリの「住所」の範囲です。住所を広く予約しても、同じ量の物理メモリを最初から使うわけではありません。データを増やすときは終端を進め、所有するスコープが終わると以前の位置へ戻します。

動く図:追加してから、スコープの開始位置へ戻す(クリックで表示・非表示)

データスタックの使用中の終端が追加時に進み、スコープ終了時に元の位置へ戻るアニメーション。

図2の静止版を開く

図2:戻すのは「使ってよい範囲の境界」です。データを一つずつ消して回る図ではありません。

ここで「一括解放はO(1)」という説明が出てきます。対象は、あるデータスタックの終端を保存位置へ戻す操作です。要素が増えても、一つずつ破棄するループは必要ありません。複数のスタックを戻す場合は、その数も考える必要があります。

ただし、O(1)は「必ず0.0001秒」などの時間の保証ではありません。新しい物理ページを使う際のOS処理、ページフォルト、I/Oまで一命令になるわけでもありません。終端を戻しただけで、物理メモリが直ちにOSへ返るとも限りません。

また、「ヒープなし」はGooseの管理するデータの割当モデルの説明です。コンパイラ自身や、連携するCライブラリを含む全処理でヒープ利用がゼロ、と読むべきではありません。

出典:言語リファレンス §4、仕様のメモリモデル・OS側のページ管理。

配列を増やしても、なぜ参照先が動かない?

Gooseの伸長可能配列 T[>..] は、データスタックの一番上で成長します。一つのスタック上で、同時に生きている伸縮可能な値は最大一つという規則があり、コンパイラがスタックを割り当てます。

そのため、別のデータを押しのけるために配列全体を移し直す必要がありません。先頭要素への参照を取った後も、要素を追加できます。ただし、予約領域は有限で、無限に追加できるわけではありません。

配列の型には、ほかにも違いがあります。

型の例 主な性質
i32[4] 要素数がコンパイル時に決まる
i32[] 構築時に長さが決まり、その後は固定
i32[..16] インラインの容量16の範囲で増減できる
i32[>..] 成長を主用途とし、要素を移し直さず追加できる
i32[:] 別の配列の一部分を見るスライス

文字列もバイト配列です。例えば u8[][>..] は、伸長可能な外側の配列に、長さが確定した文字列を入れる形になります。文字列の本体を要素の中へ詰めて配置でき、ポインタをたどる回数を減らせます。

一方、可変サイズの要素を詰めた配列は、要素番号だけで直接アクセスできないという制約があります。順に読むか、必要なら参照の索引を別に作ります。配置を小さくする代わりに、アクセス方法も変わるのです。

出典:チュートリアル §5・§8。

ライフタイム注釈がなくても、「寿命」はなくならない

参照は、データそのものではなく、その置き場所を指すものです。置き場所の寿命が終わった後まで参照を使うと危険です。

Gooseのコンパイラは、参照やスライスの根元になる変数を追跡します。内側のスコープで作ったデータを指す参照を、それより長生きする場所へ保存するような操作は拒否します。

つまり、プログラマが 'a のような注釈を書かなくても、寿命の条件は検査されます。データをどこに持たせるかという設計は、依然として必要です。

なお、Rustもすべての場所でライフタイム注釈を書くわけではありません。Gooseの特徴は、Rustのライフタイム省略規則の有無だけで比べるより、データスタックと配置の規則まで含めて見ると理解しやすくなります。

4つの短いコードで読む

以下は、それぞれ別ファイルとして読む独立した例です。公式チュートリアル・サンプルを参考に短くしたもので、本記事では未コンパイル・未実行です。

1. 変数と表示

01_basic.goose:

import std;

fn main() {
    let name = "Goose";
    var score = 100;
    score += 50;
    print(name, ": ", score);
}

想定表示は Goose: 150 です。let は変数の再代入を禁止し、var は再代入を許可します。ただし、let が参照先の内容まで不変にするわけではありません。内容を読み取り専用にするための const は別の仕組みです。

2. 一時データを、繰り返しの中で作って捨てる

02_scope.goose:

import std;

fn main() {
    for round in 3 {
        var scratch: i64[>..] = [];
        for i in 10000 { scratch.push(i); }
        print("round ", round, ": ", scratch.len, " items");
    }
}

scratch の寿命はループ本体の中です。想定表示は、round 0〜2のそれぞれで 10000 items。閉じ波かっこへ進むと、その回の領域が使い直せる状態になります。

集計の途中データなど、「この処理が終わったら全部いらない」と説明できる仕事に対応します。

3. 追加前に取った参照を、追加後も使う

03_reference.goose:

import std;

struct Item { id: i32, weight: f32 }

fn main() {
    var items: Item[>..] = [];
    let first .= items.push(Item { 1, 0.5 });

    for i: i32 in 2..1001 {
        items.push(Item { i, 0.0 });
    }

    first.weight = 99.5;
    print(items.len, " items, first weight ", items[0].weight);
}

.= は参照を結び付ける記法です。push が返した最初の要素への参照を first に持たせ、その後に999個を追加しています。

想定表示は 1000 items, first weight 99.5。この性質は、配列を伸ばしながら要素同士を参照でつなぐ処理で役立ちます。「データが移動しない」という配置の規則が、この書き方を支えています。

4. 寿命がそろわないデータは、スロットを再利用する

「途中の一件だけ使わなくなる」場合のために、reusable プールも用意されています。

04_pool.goose:

import std;

struct Entry { id: i32 }

fn main() {
    reusable var pool: Entry[>..] = [];
    let slot = pool.alloc_index(Entry { 10 });
    pool.free(slot);
    let reused = pool.alloc_index(Entry { 20 });
    print("slot ", slot, " -> reused ", reused);
}

想定表示は slot 0 -> reused 0。ここでの free は、スロットを次のデータに使い回せるようにする操作です。その場でメモリをOSへ返す操作ではありません。

reusableプールのスロット0にあったID10を解放扱いにし、ID20へ再利用する模式図。古い参照が別のデータを見る論理バグは残る。

図3:同じ住所に、別の住人が入るイメージです。

古い参照が再利用後のスロットを指し続けると、型としては正しい別の値を読んでしまう可能性があります。作者の資料はこれを、メモリ破壊とは異なる論理バグとして説明しています。「メモリ安全なら、別人の注文を処理するようなバグも消える」という意味ではありません。

コードの参照元:チュートリアル、メモリの公式サンプル、reusableプールの仕様。

手元で試す際は、公式の導入手順を確認し、機密のない実験用フォルダで、例えば次のように実行します。本記事の作業ではGooseのインストールは行っていません。

goose 01_basic.goose

GooseはCへ変換し、同梱TinyCCでその場で実行する経路と、Cコンパイラで実行ファイルを作る経路があります。次の性能表は、単に上のコマンドを実行した速度の比較ではありません。

「C++やRustより速い」の数字をどう読むか

作者は、16種類のベンチマークのlargeサイズについて、次の値を公開しています。速度比とピークメモリ比を、それぞれ16ベンチマークで幾何平均したものです。幾何平均は、ここでは各テストの「何倍か」という比率を一つにまとめるための平均です。

比較対象の実装群 Gooseの速度比:MSVC / clang ピークメモリ比:比較対象 ÷ Goose
idiomatic C++ 3.51倍 / 3.54倍 1.95
手動最適化したexpert C++ 1.24倍 / 1.26倍 1.32
対象中で最速のsafe Rust 1.13倍 / 1.19倍 1.25

メモリ比1.95は、Goose側が比較対象のおよそ51%だったという意味です。「メモリが195%減る」という意味ではありません。

測定は作者公開の1台のWindows環境で、Ryzen 9 9950X3D、RAM 94GB、Windows 11、MSVC 19.51/clang-cl 22.1.3/rustc 1.98.0という条件です。ウォームアップ2回の後、3回中の最速値を採用し、入力生成・構築・後片付けを含む全プロセスの実時間を測っています。メモリの指標は、この環境ではpeak working setです。

CPUコアの固定は行っていません。作者も、小さな差やアロケータの負荷が大きい測定の再現性には注意を求めています。1.13倍といった差を、どの環境でも得られる改善率として扱わないようにします。

速度とメモリは、それぞれ有利な実装を別に選ぶ集計です。 同じ一つのプログラムが、表の速さとメモリ量を同時に達成したとは限りません。また、calc はGooseが481/491ms、Rustが436msで、Gooseの方が遅い結果です。

公開結果の測定日は2026年10月16日です。本記事で読んだソースの固定版とは日付が異なり、表の環境情報だけでは測定時のGooseのcommitまでは特定できません。現在の版をこちらで測り直した結果としては扱いません。

数字から持ち帰りたいのは、言語全体の順位ではなく、データ配置と管理方式を変えると、同じ仕事でも性能が変わるという点です。採用を判断するなら、自分のデータ量、検索・更新の比率、必要なメモリ、使用するCバックエンドで測り直す必要があります。

出典:公開結果と測定条件、集計コード。

採用前に考えるのは「何のアプリか」より「いつ捨てるか」

パース中に構文木を作り、その処理の終わりで捨てる。リクエストごとに応答データを構築する。こうした寿命の境界が明確な処理は、Gooseのモデルを検討しやすい題材です。公式ベンチマークにも、Webハンドラの応答データを題材にした respond があります。ただし、応答用データの構築・描画の比較であり、実際のHTTPサーバーの性能測定ではありません。確認したチュートリアルでは、標準ライブラリのネットワーク機能も未実装とされています。「Webサーバーだから不向き」と一括りにせず、モデルの相性と、実用に必要な機能の有無を分けて考えます。

反対に、長寿命キャッシュ、削除・再利用が入り交じるグラフ、外へ持ち出すクロージャが必要な処理は、データの持ち方や言語機能の制約を詳しく確認する必要があります。再帰呼出しをまたぐ伸長可能データにも制約があります。

リアルタイム処理や組み込みへの適合も、GCがないという一点では決まりません。OSのページ処理、仮想メモリを使える実行環境、外部ライブラリ、必要な遅延の上限を含めて評価します。

採用前の小さな検証では、次の順で調べると判断しやすくなります。

  1. 一時データと長寿命データを分け、「誰が持ち、いつ捨てるか」を書く。
  2. データの形に合う配列・索引・プールを選ぶ。
  3. 実データに近い入力で、速度・メモリ・エラー時の動きを測る。
  4. FFI、必要なライブラリ、コンパイラの安定性・保守方法を確認する。

参照の寿命を静的に検査する設計と、実装に不具合がないこと、C連携先まで安全であることは別です。現時点では、既存システムの即時置換を急ぐより、切り出せる小さな処理で制約と効果を確かめるところから検討したい言語です。

参考:制約一覧、ベンチマークの設計。図は公式資料を基に作成した独自の模式図で、実測のメモリ画像ではありません。

この記事を書いた人✏️@YushiYamamoto
個人事業ITPRODX.comの山本勇志です。PHPサイトの改修・保守、Web・CMS開発の担当経験を基に、既存システムの切り分けや設計判断を発信しています。
古いホームページやPHPサイトの調査・限定改修は、対応できる範囲を確認のうえご相談を承ります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?