Gooseが示す、ヒープなしのシステムコード設計
Gooseはヒープ・GC・ライフタイム注釈を持たないメモリ安全なシステム言語。スタックだけの設計がRustやリージョンについて示すことを解説。

少なくとも二つの言語コミュニティを苛立たせそうな主張から始めます。借用チェッカーは回り道だった、と。失敗だったわけではありません。Rustの所有権モデルは実際の問題を解決し、業界全体を前に進めました。しかしそれは、より古くシンプルなアイデアを迂回する回り道であり、私たちは今まさにそのアイデアを再発見しつつあります。そのアイデアとはリージョンです。大きなスタック状のアリーナにメモリを確保し、スコープが終わるとまとめて解放するというものです。今週話題になっている新しいシステム言語Gooseは、このアイデアに対して私が見てきた中で最も大胆な現代的な賭けです。そしてその設計は居心地の悪い問いを突きつけます。C++やRustで我慢してきたことのうち、どれだけが本当に構造を支えているのか。どれだけが単にヒープが騒いでいるだけなのか、と。
Gooseにはヒープアロケータがありません。ガベージコレクタも、参照カウントも、デストラクタも、ライフタイム注釈も、unsafeもありません。動的な値はすべて、コンパイラが静的に割り当てる少数のデータスタックのどれかの上にインラインで置かれます。成長はポインタを進めるだけです。百万要素のネストした構造体を解放するのも、スタックトップへの一回の書き込みで済みます。作者の報告では、ベンチマークは慣用的なC++より3.3倍、安全なRustより1.12倍速く、しかもメモリは少なくて済むとのことです。そして、John Carmackが公に示した反応は、私が最も面白いと感じた点を正確に指摘していました。データ構造そのものをファイル形式にしてしまう相対参照です。Gooseが本番ソフトウェアで使われるかどうかに関係なく、ヒープを削除し、それを言い訳しないとき、システム言語がどんな姿になるかを示す本格的な思考実験です。
メモリモデルこそが言語そのもの
多くの言語機能は交渉可能です。構文、モジュールシステム、エラー処理でさえ、正反対の選択をした二つの合理的な言語を想像できます。しかしメモリモデルはそうではありません。一つ選ぶと、その下流のすべてが決まってしまいます。データ構造も、並行性の方針も、FFIも、エラーメッセージも。Gooseはそれを理解しており、思い切ってそちらへ振り切っています。
ルールは簡単に言えます。プログラムはネイティブのコールスタック、静的データ、そしてN本のデータスタックを持ち、Nはコンパイラが決定します。各スタックには同時に生きているリサイズ可能な値が高々一つだけあり、それは必ずトップにあります。この単一の不変条件が驚くほど多くのものを生み出します。拡張される値は決して移動しません。その上に再配置が必要なものが存在しないからです。したがって、伸びていく配列への参照は永遠に有効なままです。C++では、再割り当てが起きるstd::vector<T>がそこへのすべてのポインタとイテレータを無効化します。だからこそ、多くのC++プログラマがpush_backをまたいで参照を保持して痛い目を見てきました。安全なRustは、そもそもそれを記述不能にすることでこの問題を回避しています。代わりにu32のインデックスを使うことになり、その代償は境界チェックと使い勝手です。Gooseはただ、参照を保持させてくれます。型とライフタイムのルールはコンパイル時にチェックされ、コンパイラは各参照が何に「根ざしている」かを推論し、所有者より長生きしうる参照を拒否します。ただし、注釈もエイリアシングのルールも排他性の規則もありません。
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
Rustの借用チェッカーに触れたことがあるなら、これは手品に見えるでしょう。ライフタイムはどこへ消えたのか。正直な答えは「制約の中へ」です。Rustの所有権システムは、グラフ、循環、スコープをまたぐ共有可変構造といった任意の共有パターンの安全性を証明できます。Gooseは、ずっと狭い範囲のパターンの安全性だけを証明し、それ以外は単に受け付けません。この表現力とシンプルさの交換は言語設計で最も古いトレードオフであり、ここで私が引っ張り続ける糸です。Rustの借用チェッカーにおける健全性とは何かについての記事を読んだことがあれば、Gooseはそのコインの裏側だと分かるでしょう。より強力な解析ではなく、より小さな言語によって健全性を達成しているのです。
すべてインライン:第一級の機能としてのデータレイアウト
二つ目の大きなアイデアは、単一の配列型が存在しないことです。Gooseには、サイズの扱いだけが異なる配列の系統があります。コンパイル時に固定されたもの、構築後に凍結されるもの、インラインのキャパシティ内で伸びるもの、拡張のみ可能なもの、拡張と縮小が可能なもの。どれも[メタデータ][要素...]という形で、連続し、詰め込まれ、要素ブロックへのポインタにはなりません。文字列は単なるu8配列なので、構造体は宣言順にインラインで可変長の部分を持てます。可変長の文字列フィールドと可変長のサブレコードリストを持つレコードは、間接参照ゼロの一続きのバイト列になります。
公表されている数値は目を引きます。Gooseでの注文レコードは29バイトでアロケーションはゼロ。一方、C++のstd::stringとstd::vector<Item>では160バイトで1回のアロケーション、Rustでは153バイトで4回のアロケーションです。ただ、議論の中ですぐに一つ妥当な指摘が出てきました。Gooseの公式チュートリアルにある「文字列のベクタ」の例は、主要なSTL実装がすべて備えているSmall String Optimization(SSO)を無視しているというものです。C++では短い文字列はアロケーションしません。これで議論が崩れるわけではありません。SSOが効くのは約15〜22バイト以下の文字列に対してだけで、ネストした可変長データという一般的なケースには何の効果もありません。とはいえ、言語のトップページにあるベンチマーク表はマーケティングである、という教訓でもあります。たとえその言語が優れていてもです。作者の名誉のために言えば、不利な項目もきちんと載せており、これは多くのプロジェクトがやらないことです。
実際のコストもあり、Gooseはそれを隠していません。可変サイズの要素からなる配列は逐次的です。反復はできますが、インデックスアクセスはできません。k番目の要素は、バイトが落ちた場所にあるからです。ランダムアクセスが必要なら、参照の配列をもう一つインデックスとして持つことになります。これは、データ指向設計の人々が何十年も手作業で行ってきた種類のトレードオフです(カラムナ形式やFlatBuffers風のレイアウトの仕組みも基本的にこれです)。しかしここではそれがデフォルトになっており、オプトアウトするには余計な作業が必要です。それが機能なのか拘束衣なのかは、アクセスパターン次第です。
相対参照:データ構造がそのままファイル形式になる
Gooseで私のお気に入りのアイデアは相対参照で、Carmackも注目したものです。通常の参照はマシンアドレスで、8バイトの絶対値であり、プロセスの外では意味を持ちません。相対参照は同じリンクを、1、2、4バイトのオフセットとして格納します。オフセットは、フィールド自身からか、名前付きプールのベースからかのどちらかで測られます。自己相対リンクで組まれた構造体は位置非依存です。メモリ上のどこに置かれても同じ意味を持ちます。つまり、データ構造の保存はwrite()であり、読み込みはread()と検証パスの組み合わせになります。
最後の一文は見た目以上に重要です。このトリックの素朴な版、つまりポインタだらけのファイルをメモリマップすることは、古典的なセキュリティ事故です。Gooseの版は検証します。from_bytesはバイト列が値になる前に、フレーミング、すべてのタグ、すべての長さ、すべてのリンクをチェックします。そのため、壊れた、あるいは悪意あるファイルは野良ポインタではなくfalseを返します。mmapされた構造体の速さと、パーサに近い安全性の両方が得られるわけです。
Carmackの指摘は正確でした。「ファイルやネットワークデータの『パース』は、必要な速度より100倍遅いことが多い」。ホットパスの逆シリアライズをプロファイリングしたことがある人なら誰でも知っています。これまでの業界の答えは、スキーマ駆動のゼロコピー形式、Cap'n ProtoやFlatBuffersでした。これらは機能しますが、スキーマコンパイラとランタイムライブラリをプロジェクトに追加することになります。Gooseの答えは、言語が最初から絶対アドレスへのポインタを持たなかったので、メモリ内のレイアウトと転送時のレイアウトが構造的に同じものになる、というものです。二分探索木の例はほとんど挑発的です。ノードは12バイト(リンクを含む)、挿入は一回のpushと一回の書き込み、そして木全体はバイト列を書き出すだけで永続化されます。Lisp使いなら、その哲学的な動きに見覚えがあるでしょう。誰もが根本的だと思っていた区別を崩すという手法で、これは60年を経た今もLispが今でも重要である理由の一部であり、設計上の参照点になっています。
かつて通ってきた道:リージョン、アリーナ、そしてML Kit
Gooseについて歴史を踏まえた反応は、コメント欄に予想どおり現れました。「MLの人たちがこの道を進んだ記憶がある。確かリージョンと呼ばれていたはず」。その通りです。リージョンベースのメモリ管理は、1990年代のML Kitの仕事にまでさかのぼります。TofteとTalpinのリージョン推論により、MLコンパイラはガベージコレクションを、静的に決まるスタック状のリージョンに置き換えることができました。オブジェクト単位の管理をせず、リージョン全体を確保・解放するわけです。歴史的な評価としては、これはある種の行き止まりだったと見なされています。リージョン推論は機能しました。しかし現実のプログラムでは、病的なメモリ増大が十分に見られたのです。リージョンが死んだデータを必要以上に長く生かし続けてしまう。そのため研究コミュニティの多くは先に進みました。ガベージコレクタが王冠を守り続けたのです。
しかしこのアイデアは実は死んでいません。職を変えただけです。アリーナアロケーションはゲームエンジン、コンパイラ、高性能サーバでは標準的な手法です。Apacheのメモリプール、Webサーバのリクエスト単位のアリーナ、ゲームループの「毎フレームスクラッチバッファをリセットする」パターン。これらは別名のリージョンです。言語が助けてくれないので、手作業で管理されています。Gooseが ML Kitと違う点は正確に述べておく価値があります。汎用の関数型言語にリージョンを推論させるのではなく(難しく、予測もできません)、スタックモデルをそのまま露出させ、プログラマの意図を型の一部にしてしまうのです。T[>..]は「これは伸びる、参照は有効なままである」と言います。コンパイラの仕事は規律を発見することではなく、宣言された規律をチェックすることです。これははるかに易しい問題であり、それ以前の自動ライフタイム方式が苦戦した場所でRustが成功した理由と同じです。プログラマは、目に見える契約なら受け入れるのです。
ここには、主流の言語が研究をどう吸収するかについてのより広い教訓があります。GCを完全自動で置き換えるリージョン推論は失敗しました。手動のシステムパターンとしてのリージョンは至る所にあります。借用チェッカーは大成功であり、リージョンのアイデアや線形型から大きく借りています。Erlang型の共有なしプロセスは正しさが証明され、Gooseのスレッドはまさにそれです。ワーカーは別々のプログラムとしてコンパイルされ、それぞれ独自のメモリを持ち、値は型付きキューをmemcpyで渡り、データ競合は表現不能です。誰も最初から全体像を正しく捉えることはできません。アイデアは、機能する形になるまで何十年も流通するものなのです。
モデルが破綻する場所
反論的な主張から始めると言いましたので、ここで破綻する点を認めます。プログラムの共有パターンが本当に任意であるなら、借用チェッカーは回り道ではありません。そして膨大な種類のプログラムがまさにそういう形をしています。Goose議論で最も鋭い問いは、同時に最もシンプルでもありました。プログラムがランダムなタイミングでランダムな文字列を挿入・削除するとき、キーから文字列へのハッシュテーブルをどう作るのか。削除が致命的です。拡張のみのスタックは、途中の穴を回収できません。提案された回避策、つまりサイズクラスごとのスタック、定期的なコンパクション、「解放」が単にスロットを再利用可能にするだけのフリーリスト付きプールは、どれも機能します。プール設計は本当に巧妙です(古いスロットは、メモリを壊すのではなく、正しい型の別の値を読むことになります)。しかしどれも、そのデータ構造のためにメモリマネージャの一部を、手作業で再実装しているのです。
同様に、メモリ使用量もあります。GCがないため、伸びたスタックはスコープ終了まで、かつて伸びた分すべてを保持します。寿命が不規則な長時間稼働プログラム、たとえばスレッドで話題になったJSONドキュメントの問題、キャッシュ、セッション状態などは、スコープ規律のパターンに収まるか、収まらないかのどちらかです。収まらない場合は、どれほどエレガントでも救えません。ML Kitの失敗モード、つまり生きているリージョンに死んだデータが固定されてしまう問題は、Gooseでも消えません。見えるようになり、手作業になるだけです。それはおそらく良いことですが、依然としてそこにあります。あるコメント投稿者が正しい姿勢を述べていました。Gooseは制限された目的のための言語であり、それで構わない、と。データ指向のワークロード、コンパイラ、ゲームのエンティティシステム、パイプライン型のもの(読み込み、変換、出力、リセット)には見事に合います。長寿命の可変なオブジェクトグラフはそうではありません。
最後に、見出しの数値について一つ注意点を。自己実施のベンチマークスイートには既知の失敗パターンがあります。あなたのベストのGooseと、誰かの「慣用的な」C++を比べることになり、慣用的かどうかで争いが起きます。SSOのミスは、比較用コードが敵対的にチューニングされていなかったことを示しています。安全なRustに対する1.12倍の数字は、3.3倍よりも興味深いものです。まさに小さいからこそです。これは、メモリモデルが魔法ではなくレイアウトとアロケーション削減で勝っていることを示し、アリーナとインデックスを使ったチューニング済みのRust版なら、その差の多くを埋めてしまうでしょう。それこそがポイントです。アリーナベースのRustは書けるのですが、言語がそれを面倒にしており、その面倒さが実際にどんなコードが書かれるかを決めてしまうのです。

主流言語がGooseから実際に盗むもの
今後数年で本番用のGooseを書く人はほぼいないでしょう。単一のCバックエンドを通してコンパイルする個人開発の言語が、すぐにそこへ到達することはありません。作者自身もその状況をそう述べています。それは重要な問いではありません。重要なのは、どのアイデアが既に私たちが使っている言語へ漏れ出すかです。いつもそうやって進んできたからです。私の予測を確信度の高い順に挙げます。
- 検証付きロードを伴う位置非依存の構造。ゼロコピーのシリアライズと安全性検査は、ニッチに留めておくには優れすぎています。FlatBuffersがぎこちなくやっていることを言語に統合したバージョンが増えるでしょう。特にデータ負荷の大きいサービスがパースコストに当たり続けるにつれてです。
- 配列は型ではなく系統。「スタックかヒープか」よりも「サイズはどうなるのか」の方が良い整理の問いです。固定、凍結、容量上限付き、拡張のみ。これらの区別は、どのコードベースにも非公式な慣習として存在しています。それを型にするのは安上がりで、実際のバグを捕まえられます。
- コンパイル時の関数値。Gooseは関数値をコンパイル時の実体として扱うので、
xs.filter() { it > 0 }は見た目どおりのループにコンパイルされます。C++のテンプレートやRustの単相化は既にこれを行っています。違いは、Gooseがそれを唯一の方法にし、実行時の関数ポインタという落とし穴を完全に取り除いている点です。 - 静的にチェックされる複数フレームのreturn。
return err from loadは、N個上のフレームから関数を返し、コンパイル時にチェックされます。アンワインダも、Resultのピラミッドも、すべての呼び出しに付く?もありません。エフェクトシステムは反対側からこの同じアイデアに迫っています。この10年のうちに、どれかが主流言語に入るでしょう。
共有なしのスレッドモデルにも触れておきます。最も新規性が低く、最も正しさが証明されているものだからです。これはErlangのプロセスであり、Dartのアイソレートであり、思想を抜きにしたアクターモデルです。これが何度も再発明されているという事実は、共有された可変状態に関する業界のコンセンサスが、理論上は決着しているのに実践では無視されていることを示しています。Gooseは少なくとも、それを無視することを不可能にしています。
本当の論点:制限によるシンプルさ
一歩引いて見ると、Gooseの設計は一つの手を繰り返しているだけです。安全性が安く証明できるようになるまで、表現可能なプログラムの集合を縮めるのです。ヒープがなければ、ヒープへのダングリングポインタもありません。拡張のみのスタックは、参照が無効化されないことを意味します。別ワーカーのスレッドは、競合が存在しえないことを意味します。コピーなしの構築は、意図しない一時オブジェクトがないことを意味します。どの制限も、バグのカテゴリを一つ消すと同時に、それを管理するために存在していた言語の仕組み、つまり借用チェッカーのエイリアシング規則、デストラクタ、アンワインダといったものを一つ丸ごと消し去ります。
これはRob Pikeの「less is exponentially more(少ないほど指数関数的に多い)」という主張を、手袋を外してなぞったものです。そしてPikeのGoが直面したのと同じ緊張に行き着きます。制限は、自分のプログラムがその制限に除外されるまでは解放のように感じられ、除外された途端に壁のように感じられます。Goは10年間ジェネリクスを除外し、それでも生き残りました。しかしそれは、正しいプログラムも多く除外しました。Gooseはもっと多くを除外します。それでも注目する理由は、Pikeの古いシンプルさのルールがよく年を取っているからです。複雑さの予算は本物だからです。言語が自動的に与えるすべての保証は、プログラマが頭の中で保持しなくてよい保証なのです。
では、借用チェッカーは回り道だったのか。汎用のシステムプログラミングについて言えば、違います。任意の共有のケースにはRustの表現力が本当に必要ですし、その成功によって業界は、所有権について語るための語彙を手に入れました。しかし、パイプライン型、アリーナ型、リクエスト型という、システムコードの大多数の静かな領域についてはどうでしょう。Gooseは、儀式の一部だけでメモリ安全性、速度、小さなバイナリを手に入れられたかもしれないことの証明です。ただし、より多くのプログラムに「ノー」と言う覚悟があったならば、の話です。言語設計とは、どのプログラムを禁じるかを選ぶ技術です。Gooseは私たちが慣れているものとは違うプログラムを禁じ、その結果は、ここ数年で読んだ中で最も興味深いシステム言語のスケッチです。自分でそれを出荷するつもりはありません。ただ、これから先もそこから盗み続けるだろうとは思っています。
Gooseの賭けは、Rustより多くのプログラムを禁じても言語は有用であり続けられる、というものです。ベンチマークは、その負け分も含めて真剣に検討する価値があることを示しています。


